├── .dockerignore ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── codeql-analysis.yml │ ├── lint.yml │ ├── test.yml │ └── ts.yml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yaml ├── index.ts ├── jest.config.js ├── jest.setup.js ├── jest.transformer.js ├── lerna.json ├── package.json ├── packages ├── app │ ├── .babelrc │ ├── .eslintrc │ ├── .gitignore │ ├── .watchmanconfig │ ├── App.tsx │ ├── app.json │ ├── icon-16.png │ ├── icon.png │ ├── package.json │ ├── src │ │ ├── App.tsx │ │ ├── assets │ │ │ └── images │ │ │ │ ├── 0.jpg │ │ │ │ ├── alipay.png │ │ │ │ ├── background-cool.jpg │ │ │ │ ├── background.jpg │ │ │ │ ├── baidu.png │ │ │ │ ├── wuzeiniang.gif │ │ │ │ └── wxpay.png │ │ ├── components │ │ │ ├── Avatar.tsx │ │ │ ├── BackButton.tsx │ │ │ ├── Expression.tsx │ │ │ ├── Image.tsx │ │ │ ├── Loading.tsx │ │ │ ├── Nofitication.tsx │ │ │ ├── PageContainer.tsx │ │ │ └── Toast.tsx │ │ ├── hooks │ │ │ └── useStore.tsx │ │ ├── pages │ │ │ ├── Chat │ │ │ │ ├── Chat.tsx │ │ │ │ ├── ChatBackButton.tsx │ │ │ │ ├── ChatRightButton.tsx │ │ │ │ ├── ImageMessage.tsx │ │ │ │ ├── Input.tsx │ │ │ │ ├── InviteMessage.tsx │ │ │ │ ├── Message.tsx │ │ │ │ ├── MessageList.tsx │ │ │ │ ├── SystemMessage.tsx │ │ │ │ └── TextMessage.tsx │ │ │ ├── ChatList │ │ │ │ ├── ChatList.tsx │ │ │ │ ├── ChatListRightButton.tsx │ │ │ │ ├── Linkman.tsx │ │ │ │ └── SelfInfo.tsx │ │ │ ├── GroupInfo │ │ │ │ └── GroupInfo.tsx │ │ │ ├── GroupProfile │ │ │ │ └── GroupProfile.tsx │ │ │ ├── LoginSignup │ │ │ │ ├── Base.tsx │ │ │ │ ├── Login.tsx │ │ │ │ └── Signup.tsx │ │ │ ├── Other │ │ │ │ ├── Other.tsx │ │ │ │ ├── PrivacyPolicy.tsx │ │ │ │ └── Sponsor.tsx │ │ │ ├── SearchResult │ │ │ │ └── SearchResult.tsx │ │ │ └── UserInfo │ │ │ │ └── UserInfo.tsx │ │ ├── service.ts │ │ ├── socket.ts │ │ ├── state │ │ │ ├── action.ts │ │ │ ├── reducer.ts │ │ │ └── store.ts │ │ ├── types │ │ │ ├── global.d.ts │ │ │ ├── redux.ts │ │ │ └── socket.ts │ │ └── utils │ │ │ ├── constant.ts │ │ │ ├── convertMessage.ts │ │ │ ├── expressions.ts │ │ │ ├── fetch.ts │ │ │ ├── getFriendId.ts │ │ │ ├── getRandomColor.ts │ │ │ ├── linkman.ts │ │ │ ├── platform.ts │ │ │ ├── storage.ts │ │ │ ├── time.ts │ │ │ └── uploadFile.ts │ ├── tests │ │ └── state │ │ │ └── reducer.test.ts │ ├── tsconfig.json │ └── yarn.lock ├── assets │ ├── audios │ │ ├── apple.mp3 │ │ ├── default.mp3 │ │ ├── huaji.mp3 │ │ ├── mobileqq.mp3 │ │ ├── momo.mp3 │ │ └── pcqq.mp3 │ ├── fonts │ │ └── font.woff │ ├── images │ │ ├── alipay.png │ │ ├── android-apk.png │ │ ├── background-cool.jpg │ │ ├── background.jpg │ │ ├── baidu.png │ │ ├── huaji │ │ │ ├── 0.jpg │ │ │ ├── 1.gif │ │ │ ├── 10.jpeg │ │ │ ├── 11.jpeg │ │ │ ├── 12.jpeg │ │ │ ├── 13.jpeg │ │ │ ├── 14.jpeg │ │ │ ├── 15.jpeg │ │ │ ├── 16.jpeg │ │ │ ├── 17.jpeg │ │ │ ├── 18.gif │ │ │ ├── 19.jpeg │ │ │ ├── 2.jpeg │ │ │ ├── 20.jpeg │ │ │ ├── 21.jpeg │ │ │ ├── 22.jpeg │ │ │ ├── 23.jpeg │ │ │ ├── 24.jpeg │ │ │ ├── 25.png │ │ │ ├── 26.jpeg │ │ │ ├── 27.jpeg │ │ │ ├── 28.jpeg │ │ │ ├── 29.jpeg │ │ │ ├── 3.jpeg │ │ │ ├── 30.jpeg │ │ │ ├── 31.jpeg │ │ │ ├── 32.jpg │ │ │ ├── 33.gif │ │ │ ├── 34.gif │ │ │ ├── 35.gif │ │ │ ├── 36.gif │ │ │ ├── 4.jpeg │ │ │ ├── 5.jpg │ │ │ ├── 6.jpeg │ │ │ ├── 7.jpg │ │ │ ├── 8.jpeg │ │ │ └── 9.jpeg │ │ ├── ios-expo.png │ │ ├── no-linkman.jpeg │ │ ├── wuzeiniang.gif │ │ └── wxpay.png │ └── package.json ├── bin │ ├── index.ts │ ├── package.json │ ├── scripts │ │ ├── deleteMessages.ts │ │ ├── deleteTodayRegisteredUsers.ts │ │ ├── deleteUser.ts │ │ ├── doctor.ts │ │ ├── fixUsersAvatar.ts │ │ ├── getUserId.ts │ │ ├── register.ts │ │ └── updateDefaultGroupName.ts │ ├── tsconfig.json │ └── yarn.lock ├── config │ ├── client.ts │ ├── package.json │ ├── server.ts │ └── yarn.lock ├── database │ ├── mongoose │ │ ├── index.ts │ │ ├── initMongoDB.ts │ │ └── models │ │ │ ├── friend.ts │ │ │ ├── group.ts │ │ │ ├── history.ts │ │ │ ├── message.ts │ │ │ ├── notification.ts │ │ │ ├── socket.ts │ │ │ └── user.ts │ ├── package.json │ ├── redis │ │ └── initRedis.ts │ ├── tsconfig.json │ └── yarn.lock ├── docs │ ├── .gitignore │ ├── babel.config.js │ ├── docs │ │ ├── API.md │ │ ├── App.md │ │ ├── CHANGELOG.md │ │ ├── Config.md │ │ ├── FAQ.md │ │ ├── Getting-Start.md │ │ ├── INSTALL.md │ │ └── Script.md │ ├── docusaurus.config.js │ ├── i18n │ │ ├── en │ │ │ ├── code.json │ │ │ ├── docusaurus-plugin-content-docs │ │ │ │ └── current │ │ │ │ │ ├── API.md │ │ │ │ │ ├── App.md │ │ │ │ │ ├── CHANGELOG.md │ │ │ │ │ ├── Config.md │ │ │ │ │ ├── FAQ.md │ │ │ │ │ ├── Getting-Start.md │ │ │ │ │ ├── INSTALL.md │ │ │ │ │ └── Script.md │ │ │ └── docusaurus-theme-classic │ │ │ │ ├── footer.json │ │ │ │ └── navbar.json │ │ └── zh-Hans │ │ │ ├── code.json │ │ │ ├── docusaurus-plugin-content-docs │ │ │ └── current │ │ │ │ ├── API.md │ │ │ │ ├── App.md │ │ │ │ ├── CHANGELOG.md │ │ │ │ ├── Config.md │ │ │ │ ├── FAQ.md │ │ │ │ ├── Getting-Start.md │ │ │ │ ├── INSTALL.md │ │ │ │ └── Script.md │ │ │ └── docusaurus-theme-classic │ │ │ ├── footer.json │ │ │ └── navbar.json │ ├── package.json │ ├── sidebars.js │ ├── src │ │ ├── css │ │ │ └── custom.css │ │ └── pages │ │ │ ├── index.js │ │ │ └── styles.module.css │ ├── static │ │ ├── .nojekyll │ │ └── img │ │ │ ├── android-download-qrcode.png │ │ │ ├── cross-platform.png │ │ │ ├── favicon.png │ │ │ ├── logo.svg │ │ │ ├── open-source.png │ │ │ ├── screenshots │ │ │ ├── screenshot-app.png │ │ │ ├── screenshot-pc.png │ │ │ └── screenshot-phone.png │ │ │ ├── undraw_code_review.svg │ │ │ ├── undraw_note_list.svg │ │ │ ├── undraw_youtube_tutorial.svg │ │ │ └── website-app.png │ └── yarn.lock ├── i18n │ ├── en-US │ │ ├── bin.ts │ │ └── index.ts │ ├── node.index.ts │ ├── package.json │ ├── yarn.lock │ └── zh-CN │ │ ├── bin.ts │ │ └── index.ts ├── server │ ├── .nodemonrc │ ├── package.json │ ├── public │ │ ├── PrivacyPolicy.html │ │ ├── avatar │ │ │ ├── 0.jpg │ │ │ ├── 1.jpg │ │ │ ├── 10.jpg │ │ │ ├── 11.jpg │ │ │ ├── 12.jpg │ │ │ ├── 13.jpg │ │ │ ├── 14.jpg │ │ │ ├── 2.jpg │ │ │ ├── 3.jpg │ │ │ ├── 4.jpg │ │ │ ├── 5.jpg │ │ │ ├── 6.jpg │ │ │ ├── 7.jpg │ │ │ ├── 8.jpg │ │ │ └── 9.jpg │ │ ├── favicon-192.png │ │ ├── favicon-512.png │ │ ├── favicon-96.png │ │ ├── index.html │ │ └── manifest.json │ ├── src │ │ ├── app.ts │ │ ├── main.ts │ │ ├── middlewares │ │ │ ├── frequency.ts │ │ │ ├── isAdmin.ts │ │ │ ├── isLogin.ts │ │ │ ├── registerRoutes.ts │ │ │ └── seal.ts │ │ ├── routes │ │ │ ├── group.ts │ │ │ ├── history.ts │ │ │ ├── message.ts │ │ │ ├── notification.ts │ │ │ ├── system.ts │ │ │ └── user.ts │ │ └── types │ │ │ ├── index.d.ts │ │ │ └── server.d.ts │ ├── test │ │ ├── helpers │ │ │ └── middleware.ts │ │ └── middlewares │ │ │ ├── frequency.spec.ts │ │ │ ├── isAdmin.spec.ts │ │ │ ├── isLogin.spec.ts │ │ │ └── seal.spec.ts │ ├── tsconfig.json │ └── yarn.lock ├── utils │ ├── compressImage.ts │ ├── const.ts │ ├── convertMessage.ts │ ├── expressions.ts │ ├── getFriendId.ts │ ├── getRandomAvatar.ts │ ├── getRandomColor.ts │ ├── logger.ts │ ├── package.json │ ├── sleep.ts │ ├── socket.ts │ ├── test │ │ ├── getFriendId.spec.ts │ │ └── url.spec.ts │ ├── time.ts │ ├── ua.ts │ ├── url.ts │ ├── xss.ts │ └── yarn.lock └── web │ ├── .babelrc │ ├── build │ ├── webpack.common.js │ ├── webpack.dev.js │ └── webpack.prod.js │ ├── package.json │ ├── src │ ├── App.less │ ├── App.tsx │ ├── components │ │ ├── Avatar.tsx │ │ ├── Button.tsx │ │ ├── Dialog.less │ │ ├── Dialog.tsx │ │ ├── Dropdown.less │ │ ├── Dropdown.tsx │ │ ├── IconButton.less │ │ ├── IconButton.tsx │ │ ├── Input.less │ │ ├── Input.tsx │ │ ├── Loading.tsx │ │ ├── Menu.tsx │ │ ├── Message.less │ │ ├── Message.tsx │ │ ├── Progress.tsx │ │ ├── Select.tsx │ │ ├── Tabs.tsx │ │ ├── Tooltip.less │ │ └── Tooltip.tsx │ ├── context.ts │ ├── globalStyles.ts │ ├── hooks │ │ ├── useAction.ts │ │ ├── useAero.ts │ │ ├── useIsLogin.ts │ │ └── useStore.ts │ ├── localStorage.ts │ ├── main.tsx │ ├── modules │ │ ├── Chat │ │ │ ├── Chat.less │ │ │ ├── Chat.tsx │ │ │ ├── ChatInput.less │ │ │ ├── ChatInput.tsx │ │ │ ├── CodeEditor.less │ │ │ ├── CodeEditor.tsx │ │ │ ├── Expression.less │ │ │ ├── Expression.tsx │ │ │ ├── GroupManagePanel.less │ │ │ ├── GroupManagePanel.tsx │ │ │ ├── HeaderBar.less │ │ │ ├── HeaderBar.tsx │ │ │ ├── Message │ │ │ │ ├── CodeDialog.tsx │ │ │ │ ├── CodeMessage.less │ │ │ │ ├── CodeMessage.tsx │ │ │ │ ├── FileMessage.tsx │ │ │ │ ├── ImageMessage.tsx │ │ │ │ ├── InviteMessage.less │ │ │ │ ├── InviteMessageV2.tsx │ │ │ │ ├── Message.less │ │ │ │ ├── Message.tsx │ │ │ │ ├── SystemMessage.tsx │ │ │ │ ├── TextMessage.tsx │ │ │ │ └── UrlMessage.tsx │ │ │ ├── MessageList.less │ │ │ └── MessageList.tsx │ │ ├── FunctionBarAndLinkmanList │ │ │ ├── CreateGroup.less │ │ │ ├── CreateGroup.tsx │ │ │ ├── FunctionBar.less │ │ │ ├── FunctionBar.tsx │ │ │ ├── FunctionBarAndLinkmanList.less │ │ │ ├── FunctionBarAndLinkmanList.tsx │ │ │ ├── Linkman.less │ │ │ ├── Linkman.tsx │ │ │ ├── LinkmanList.less │ │ │ └── LinkmanList.tsx │ │ ├── GroupInfo.tsx │ │ ├── InfoDialog.less │ │ ├── InviteInfo.tsx │ │ ├── LoginAndRegister │ │ │ ├── Login.tsx │ │ │ ├── LoginAndRegister.less │ │ │ ├── LoginAndRegister.tsx │ │ │ ├── LoginRegister.less │ │ │ └── Register.tsx │ │ ├── Sidebar │ │ │ ├── About.less │ │ │ ├── About.tsx │ │ │ ├── Admin.less │ │ │ ├── Admin.tsx │ │ │ ├── Common.less │ │ │ ├── Download.less │ │ │ ├── Download.tsx │ │ │ ├── OnlineStatus.less │ │ │ ├── OnlineStatus.tsx │ │ │ ├── Reward.less │ │ │ ├── Reward.tsx │ │ │ ├── SelfInfo.less │ │ │ ├── SelfInfo.tsx │ │ │ ├── Setting.less │ │ │ ├── Setting.tsx │ │ │ ├── Sidebar.less │ │ │ └── Sidebar.tsx │ │ └── UserInfo.tsx │ ├── service.ts │ ├── socket.ts │ ├── state │ │ ├── action.ts │ │ ├── reducer.ts │ │ └── store.ts │ ├── styles │ │ ├── iconfont.less │ │ ├── normalize.less │ │ └── variable.less │ ├── template.html │ ├── themes.ts │ ├── types │ │ └── index.d.ts │ └── utils │ │ ├── fetch.ts │ │ ├── getRandomHuaji.ts │ │ ├── inobounce.ts │ │ ├── notification.ts │ │ ├── playSound.ts │ │ ├── readDiskFile.ts │ │ ├── setCssVariable.ts │ │ ├── uploadFile.ts │ │ └── voice.ts │ ├── test │ ├── components │ │ ├── Avatar.spec.tsx │ │ └── Button.spec.tsx │ ├── localStorage.spec.ts │ └── state │ │ └── reducer.spec.ts │ ├── tsconfig.json │ └── yarn.lock ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_module 2 | packages/docs/ 3 | packages/web/.linaria-cache/ 4 | packages/web/dist/ 5 | 6 | yarn-error.log -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | public/ 4 | build/ 5 | docs/ 6 | *.d.ts 7 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Lint Code Style 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node-version: [10.x] 18 | steps: 19 | - uses: actions/checkout@master 20 | - uses: bahmutov/npm-install@v1.4.5 21 | - run: yarn lint 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [10.x] 15 | steps: 16 | - uses: actions/checkout@master 17 | - uses: bahmutov/npm-install@v1.4.5 18 | - run: yarn test -------------------------------------------------------------------------------- /.github/workflows/ts.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Typescript Type Check 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | ts: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node-version: [10.x] 18 | steps: 19 | - uses: actions/checkout@master 20 | - uses: bahmutov/npm-install@v1.4.5 21 | - run: yarn ts-check 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | coverage/ 5 | .idea/ 6 | .linaria-cache/ 7 | 8 | npm-debug.log 9 | yarn-error.log 10 | .eslintcache 11 | lerna-debug.log 12 | 13 | .env 14 | 15 | packages/server/public/* 16 | !packages/server/public/avatar/ 17 | packages/server/public/avatar/*_*.* 18 | !packages/server/public/favicon-96.png 19 | !packages/server/public/favicon-192.png 20 | !packages/server/public/favicon-512.png 21 | !packages/server/public/manifest.json 22 | !packages/server/public/index.html 23 | !packages/server/public/PrivacyPolicy.html -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "arrowParens": "always", 6 | "printWidth": 80 7 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14 2 | 3 | WORKDIR /usr/app/fiora 4 | 5 | COPY packages ./packages 6 | COPY package.json tsconfig.json yarn.lock lerna.json ./ 7 | RUN touch .env 8 | 9 | RUN yarn install 10 | 11 | RUN yarn build:web 12 | 13 | CMD yarn start 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015-2021 碎碎酱 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.2' 2 | 3 | services: 4 | mongodb: 5 | image: mongo 6 | restart: always 7 | redis: 8 | image: redis 9 | restart: always 10 | fiora: 11 | build: . 12 | restart: always 13 | ports: 14 | - "9200:9200" 15 | environment: 16 | - Database=mongodb://mongodb/fiora 17 | - RedisHost=redis 18 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | moduleNameMapper: { 4 | '^.+\\.(css|less|jpg|png|gif|mp3)$': '/jest.transformer.js', 5 | }, 6 | collectCoverage: true, 7 | globals: { 8 | 'ts-jest': { 9 | isolatedModules: true, 10 | }, 11 | __TEST__: true, 12 | }, 13 | setupFilesAfterEnv: ['./jest.setup.js'], 14 | collectCoverageFrom: [ 15 | '**/*.{ts,tsx}', 16 | '!**/node_modules/**', 17 | '!**/config/**', 18 | '!**/test/helpers/**', 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | jest.mock('./packages/web/node_modules/linaria', () => ({ 2 | css: jest.fn(() => ''), 3 | })); 4 | 5 | jest.mock('./packages/database/node_modules/redis', () => jest.requireActual('redis-mock')); 6 | -------------------------------------------------------------------------------- /jest.transformer.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | process(src, filename) { 5 | return `module.exports = ${JSON.stringify(path.basename(filename))};`; 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "version": "independent", 6 | "npmClient": "yarn" 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fiora", 3 | "version": "1.0.0", 4 | "description": "An interesting chat application power by socket.io, koa, mongodb and react", 5 | "license": "MIT", 6 | "bin": "index.ts", 7 | "scripts": { 8 | "start": "npx lerna run start --stream", 9 | "dev:server": "npx lerna run dev:server --stream", 10 | "dev:web": "npx lerna run dev:web --stream", 11 | "build:web": "npx lerna run build:web --stream", 12 | "dev:app": "cd packages/app && yarn dev:app && cd ../../", 13 | "build:android": "cd packages/app && yarn build:android && cd ../../", 14 | "build:ios": "cd packages/app && yarn build:ios && cd ../../", 15 | "script": "npx lerna run script --stream", 16 | "dev:docs": "npx lerna run dev:docs --stream", 17 | "build:docs": "npx lerna run build:docs --stream", 18 | "deploy:docs": "npx lerna run deploy:docs --stream", 19 | "lint": "eslint ./ --ext js,jsx,ts,tsx --ignore-pattern .eslintignore --cache --fix", 20 | "test": "jest", 21 | "ts-check": "tsc --noEmit", 22 | "install": "npx lerna bootstrap && yarn link" 23 | }, 24 | "engines": { 25 | "node": ">= 14" 26 | }, 27 | "author": { 28 | "name": "碎碎酱", 29 | "email": "yinxin630@gmail.com" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/yinxin630/fiora" 34 | }, 35 | "devDependencies": { 36 | "@testing-library/jest-dom": "^4.2.4", 37 | "@testing-library/react": "^12.0.0", 38 | "@types/jest": "^24.0.18", 39 | "@types/node": "^15.14.1", 40 | "@typescript-eslint/eslint-plugin": "^2.0.0", 41 | "@typescript-eslint/parser": "^2.0.0", 42 | "eslint": "^7.30.0", 43 | "eslint-config-airbnb": "^18.2.1", 44 | "eslint-config-prettier": "^8.3.0", 45 | "eslint-plugin-import": "^2.23.4", 46 | "eslint-plugin-jest": "^24.3.6", 47 | "eslint-plugin-jsx-a11y": "^6.4.1", 48 | "eslint-plugin-react": "^7.24.0", 49 | "eslint-plugin-react-hooks": "^4.2.0", 50 | "jest": "^26.1.0", 51 | "lerna": "^4.0.0", 52 | "redis-mock": "^0.56.3", 53 | "ts-jest": "^26.1.3", 54 | "ts-node": "^10.1.0", 55 | "typescript": "^3.8.2" 56 | }, 57 | "dependencies": { 58 | "commander": "^8.0.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/app/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["babel-preset-expo"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/app/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.eslintrc", 3 | "rules": { 4 | "no-use-before-define": "off", 5 | "consistent-return": "off" 6 | } 7 | } -------------------------------------------------------------------------------- /packages/app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # expo 4 | .expo/ 5 | .expo-shared/ 6 | 7 | # dependencies 8 | /node_modules 9 | 10 | # misc 11 | .env.local 12 | .env.development.local 13 | .env.test.local 14 | .env.production.local 15 | 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | -------------------------------------------------------------------------------- /packages/app/.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /packages/app/App.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-props-no-spreading */ 2 | import React from 'react'; 3 | import { Provider } from 'react-redux'; 4 | import App from './src/App'; 5 | import store from './src/state/store'; 6 | 7 | export default function Main(props: any) { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /packages/app/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "privacy": "public", 4 | "name": "fiora", 5 | "icon": "./icon.png", 6 | "version": "1.1.4", 7 | "description": "App for fiora. An online chatroom", 8 | "slug": "fiora", 9 | "scheme": "fiora", 10 | "ios": { 11 | "bundleIdentifier": "com.suisuijiang.fiora", 12 | "buildNumber": "1.1.4", 13 | "infoPlist": { 14 | "LSApplicationQueriesSchemes": ["wxp"], 15 | "CFBundleLocalizations" : ["zh_CN"], 16 | "CFBundleDevelopmentRegion": "zh_CN" 17 | } 18 | }, 19 | "android": { 20 | "package": "com.suisuijiang.fiora", 21 | "versionCode": 10, 22 | "useNextNotificationsApi": true 23 | }, 24 | "updates": { 25 | "enabled": false 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/app/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/app/icon-16.png -------------------------------------------------------------------------------- /packages/app/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/app/icon.png -------------------------------------------------------------------------------- /packages/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fiora/app", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "main": "./node_modules/expo/AppEntry.js", 7 | "scripts": { 8 | "dev:app": "expo start", 9 | "eject": "expo eject", 10 | "build:android": "expo build:android -t apk", 11 | "build:ios": "expo build:ios -t archive" 12 | }, 13 | "dependencies": { 14 | "@expo/vector-icons": "^12.0.5", 15 | "@react-native-async-storage/async-storage": "~1.15.0", 16 | "@react-native-community/masked-view": "0.1.10", 17 | "@react-native-toolkit/triangle": "^0.0.1", 18 | "autobind-decorator": "^2.1.0", 19 | "deepmerge": "^4.2.2", 20 | "expo": "^42.0.0", 21 | "expo-constants": "~11.0.1", 22 | "expo-image-picker": "~10.2.2", 23 | "expo-notifications": "~0.12.3", 24 | "expo-web-browser": "~9.2.0", 25 | "immer": "^9.0.6", 26 | "native-base": "^2.4.5", 27 | "prop-types": "^15.6.1", 28 | "randomcolor": "^0.6.2", 29 | "react": "16.13.1", 30 | "react-native": "https://github.com/expo/react-native/archive/sdk-42.0.0.tar.gz", 31 | "react-native-dialog": "^6.2.0", 32 | "react-native-gesture-handler": "~1.10.2", 33 | "react-native-image-zoom-viewer": "^3.0.1", 34 | "react-native-reanimated": "~2.2.0", 35 | "react-native-router-flux": "^4.3.1", 36 | "react-native-safe-area-context": "3.2.0", 37 | "react-native-screens": "~3.4.0", 38 | "react-redux": "^7.2.2", 39 | "redux": "^4.0.0", 40 | "socket.io-client": "^4.1.3" 41 | }, 42 | "devDependencies": { 43 | "@types/randomcolor": "^0.5.5", 44 | "@types/react": "^17.0.14", 45 | "@types/react-native": "^0.64.12", 46 | "@types/react-redux": "^7.1.16", 47 | "@types/redux": "^3.6.0", 48 | "@types/socket.io-client": "^3.0.0", 49 | "expo-cli": "^4.7.3", 50 | "jest-expo": "^42.0.0", 51 | "react-test-renderer": "16.3.1" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/app/src/assets/images/0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/app/src/assets/images/0.jpg -------------------------------------------------------------------------------- /packages/app/src/assets/images/alipay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/app/src/assets/images/alipay.png -------------------------------------------------------------------------------- /packages/app/src/assets/images/background-cool.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/app/src/assets/images/background-cool.jpg -------------------------------------------------------------------------------- /packages/app/src/assets/images/background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/app/src/assets/images/background.jpg -------------------------------------------------------------------------------- /packages/app/src/assets/images/baidu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/app/src/assets/images/baidu.png -------------------------------------------------------------------------------- /packages/app/src/assets/images/wuzeiniang.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/app/src/assets/images/wuzeiniang.gif -------------------------------------------------------------------------------- /packages/app/src/assets/images/wxpay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/app/src/assets/images/wxpay.png -------------------------------------------------------------------------------- /packages/app/src/components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { getOSSFileUrl } from '../utils/uploadFile'; 3 | 4 | import Image from './Image'; 5 | 6 | type Props = { 7 | src: string; 8 | size: number; 9 | }; 10 | export default function Avatar({ src, size }: Props) { 11 | const targetUrl = getOSSFileUrl( 12 | src, 13 | `image/resize,w_${size * 2},h_${size * 2}/quality,q_90`, 14 | ) as string; 15 | return ( 16 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /packages/app/src/components/BackButton.tsx: -------------------------------------------------------------------------------- 1 | import { View, Icon, Text } from 'native-base'; 2 | import React from 'react'; 3 | import { TouchableOpacity } from 'react-native'; 4 | import { Actions } from 'react-native-router-flux'; 5 | 6 | type Props = { 7 | text?: string; 8 | }; 9 | 10 | function BackButton({ text = '' }: Props) { 11 | return ( 12 | Actions.pop()}> 13 | 14 | 18 | 25 | {text} 26 | 27 | 28 | 29 | ); 30 | } 31 | 32 | export default BackButton; 33 | -------------------------------------------------------------------------------- /packages/app/src/components/Expression.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View } from 'react-native'; 3 | 4 | import Image from './Image'; 5 | import uri from '../assets/images/baidu.png'; 6 | 7 | type Props = { 8 | size: number; 9 | index: number; 10 | style?: any; 11 | }; 12 | 13 | export default function Expression({ size, index, style }: Props) { 14 | return ( 15 | 18 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /packages/app/src/components/Image.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Image as BaseImage, ImageSourcePropType } from 'react-native'; 3 | import { getOSSFileUrl } from '../utils/uploadFile'; 4 | import { referer } from '../utils/constant'; 5 | 6 | type Props = { 7 | src: string; 8 | width?: string | number; 9 | height?: string | number; 10 | style?: any; 11 | }; 12 | 13 | export default function Image({ 14 | src, 15 | width = '100%', 16 | height = '100%', 17 | style, 18 | }: Props) { 19 | // @ts-ignore 20 | let source: ImageSourcePropType = src; 21 | if (typeof src === 'string') { 22 | let uri = getOSSFileUrl(src, `image/quality,q_80`); 23 | if (width !== '100%' && height !== '100%') { 24 | uri = getOSSFileUrl( 25 | src, 26 | `image/resize,w_${Math.ceil(width as number)},h_${Math.ceil( 27 | height as number, 28 | )}/quality,q_80`, 29 | ); 30 | } 31 | source = { 32 | uri: uri as string, 33 | cache: 'force-cache', 34 | headers: { 35 | Referer: referer, 36 | }, 37 | }; 38 | } 39 | return ; 40 | } 41 | -------------------------------------------------------------------------------- /packages/app/src/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View, Text, Dimensions, StyleSheet } from 'react-native'; 3 | import { Spinner } from 'native-base'; 4 | import { useStore } from '../hooks/useStore'; 5 | 6 | const { width: ScreenWidth, height: ScreenHeight } = Dimensions.get('window'); 7 | 8 | export default function Loading() { 9 | const { loading } = useStore().ui; 10 | if (!loading) { 11 | return null; 12 | } 13 | 14 | return ( 15 | 16 | 17 | 18 | {loading} 19 | 20 | 21 | ); 22 | } 23 | 24 | const styles = StyleSheet.create({ 25 | loadingView: { 26 | width: ScreenWidth, 27 | height: ScreenHeight, 28 | position: 'absolute', 29 | backgroundColor: 'rgba(0,0,0,0.15)', 30 | alignItems: 'center', 31 | justifyContent: 'center', 32 | }, 33 | loadingBox: { 34 | width: 120, 35 | height: 120, 36 | backgroundColor: 'rgba(0,0,0,0.7)', 37 | borderRadius: 10, 38 | alignItems: 'center', 39 | }, 40 | loadingText: { 41 | color: 'white', 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /packages/app/src/components/PageContainer.tsx: -------------------------------------------------------------------------------- 1 | import { View } from 'native-base'; 2 | import React from 'react'; 3 | import { ImageBackground, SafeAreaView, StyleSheet } from 'react-native'; 4 | 5 | type Props = { 6 | children: any; 7 | disableSafeAreaView?: boolean; 8 | }; 9 | 10 | function PageContainer({ children, disableSafeAreaView = false }: Props) { 11 | return ( 12 | 17 | 18 | {disableSafeAreaView ? ( 19 | children 20 | ) : ( 21 | 22 | {children} 23 | 24 | )} 25 | 26 | 27 | ); 28 | } 29 | 30 | export default PageContainer; 31 | 32 | const styles = StyleSheet.create({ 33 | container: { 34 | flex: 1, 35 | }, 36 | backgroundImage: { 37 | flex: 1, 38 | resizeMode: 'cover', 39 | }, 40 | children: { 41 | flex: 1, 42 | backgroundColor: 'rgba(241, 241, 241, 0.6)', 43 | }, 44 | }); 45 | -------------------------------------------------------------------------------- /packages/app/src/components/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { Toast } from 'native-base'; 2 | 3 | export default { 4 | success(message: string) { 5 | Toast.show({ 6 | text: message, 7 | type: 'success', 8 | position: 'top', 9 | }); 10 | }, 11 | warning(message: string) { 12 | Toast.show({ 13 | text: message, 14 | type: 'warning', 15 | position: 'top', 16 | }); 17 | }, 18 | danger(message: string) { 19 | Toast.show({ 20 | text: message, 21 | type: 'danger', 22 | position: 'top', 23 | }); 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /packages/app/src/hooks/useStore.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux'; 2 | import { State, User } from '../types/redux'; 3 | 4 | export function useStore() { 5 | return useSelector((state: State) => state); 6 | } 7 | 8 | export function useUser() { 9 | return useStore().user as User; 10 | } 11 | 12 | export function useSelfId() { 13 | const user = useUser(); 14 | return (user && user._id) || ''; 15 | } 16 | 17 | export function useIsLogin() { 18 | return !!useSelfId(); 19 | } 20 | 21 | export function useIsAdmin() { 22 | const user = useUser(); 23 | return (user && user.isAdmin) || false; 24 | } 25 | 26 | export function useTheme() { 27 | const { ui } = useStore(); 28 | const { primaryColor, primaryTextColor } = ui; 29 | return { 30 | primaryColor8: `rgba(${primaryColor}, 0.8)`, 31 | primaryColor10: `rgba(${primaryColor}, 1)`, 32 | primaryTextColor10: `rgba(${primaryTextColor}, 1)`, 33 | }; 34 | } 35 | 36 | export function useLinkmans() { 37 | const data = useStore(); 38 | return data.linkmans || []; 39 | } 40 | 41 | export function useFocusLinkman() { 42 | const data = useStore(); 43 | const { linkmans, focus = '' } = data; 44 | if (linkmans) { 45 | return linkmans.find((linkman) => linkman._id === focus); 46 | } 47 | return null; 48 | } 49 | 50 | export function useFocus() { 51 | const data = useStore(); 52 | return data.focus || ''; 53 | } 54 | -------------------------------------------------------------------------------- /packages/app/src/pages/Chat/ChatBackButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import BackButton from '../../components/BackButton'; 3 | import { useStore } from '../../hooks/useStore'; 4 | 5 | function ChatBackButton() { 6 | const store = useStore(); 7 | const unread = store.linkmans.reduce((result, linkman) => { 8 | result += linkman.unread; 9 | return result; 10 | }, 0); 11 | 12 | return ; 13 | } 14 | 15 | export default ChatBackButton; 16 | -------------------------------------------------------------------------------- /packages/app/src/pages/Chat/ChatRightButton.tsx: -------------------------------------------------------------------------------- 1 | import { View, Icon } from 'native-base'; 2 | import React from 'react'; 3 | import { StyleSheet, TouchableOpacity } from 'react-native'; 4 | import { Actions } from 'react-native-router-flux'; 5 | import { useFocusLinkman } from '../../hooks/useStore'; 6 | 7 | function ChatRightButton() { 8 | const linkman = useFocusLinkman(); 9 | 10 | function handleClick() { 11 | if (linkman?.type === 'group') { 12 | Actions.push('groupProfile'); 13 | } else { 14 | Actions.push('userInfo', { user: linkman }); 15 | } 16 | } 17 | 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | 27 | export default ChatRightButton; 28 | 29 | const styles = StyleSheet.create({ 30 | container: { 31 | width: 44, 32 | height: 44, 33 | flexDirection: 'row', 34 | alignItems: 'center', 35 | justifyContent: 'center', 36 | }, 37 | icon: { 38 | color: 'white', 39 | fontSize: 26, 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /packages/app/src/pages/Chat/SystemMessage.tsx: -------------------------------------------------------------------------------- 1 | import { View, Text } from 'native-base'; 2 | import React from 'react'; 3 | import { StyleSheet } from 'react-native'; 4 | import { Message } from '../../types/redux'; 5 | import { getPerRandomColor } from '../../utils/getRandomColor'; 6 | 7 | type Props = { 8 | message: Message; 9 | }; 10 | 11 | function SystemMessage({ message }: Props) { 12 | const { content, from } = message; 13 | return ( 14 | 15 | 21 | {from.originUsername} 22 |   23 | 24 | {content} 25 | 26 | ); 27 | } 28 | 29 | export default SystemMessage; 30 | 31 | const styles = StyleSheet.create({ 32 | container: { 33 | flexDirection: 'row', 34 | alignItems: 'center', 35 | }, 36 | text: { 37 | fontSize: 14, 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /packages/app/src/pages/ChatList/SelfInfo.tsx: -------------------------------------------------------------------------------- 1 | import { Text, View } from 'native-base'; 2 | import React from 'react'; 3 | import { StyleSheet } from 'react-native'; 4 | import Avatar from '../../components/Avatar'; 5 | import { useIsLogin, useStore, useTheme, useUser } from '../../hooks/useStore'; 6 | 7 | function SelfInfo() { 8 | const isLogin = useIsLogin(); 9 | const user = useUser(); 10 | const { primaryTextColor10 } = useTheme(); 11 | const { connect } = useStore(); 12 | 13 | if (!isLogin) { 14 | return null; 15 | } 16 | 17 | const { avatar, username } = user; 18 | 19 | return ( 20 | 21 | 22 | 23 | 29 | 30 | 31 | 32 | {username} 33 | 34 | 35 | 36 | ); 37 | } 38 | 39 | export default SelfInfo; 40 | 41 | const styles = StyleSheet.create({ 42 | container: { 43 | flexDirection: 'row', 44 | alignItems: 'center', 45 | height: 38, 46 | paddingLeft: 8, 47 | paddingRight: 8, 48 | }, 49 | avatar: { 50 | position: 'relative', 51 | }, 52 | nickname: { 53 | marginLeft: 8, 54 | }, 55 | onlineStatus: { 56 | width: 10, 57 | height: 10, 58 | borderRadius: 5, 59 | position: 'absolute', 60 | right: 0, 61 | bottom: 0, 62 | }, 63 | online: { 64 | backgroundColor: 'rgba(94, 212, 92, 1)', 65 | }, 66 | offline: { 67 | backgroundColor: 'rgba(206, 12, 35, 1)', 68 | }, 69 | }); 70 | -------------------------------------------------------------------------------- /packages/app/src/pages/LoginSignup/Login.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Container } from 'native-base'; 3 | import { Actions } from 'react-native-router-flux'; 4 | 5 | import fetch from '../../utils/fetch'; 6 | import platform from '../../utils/platform'; 7 | import action from '../../state/action'; 8 | 9 | import Base from './Base'; 10 | import { setStorageValue } from '../../utils/storage'; 11 | import { Friend, Group } from '../../types/redux'; 12 | 13 | export default function Login() { 14 | async function handleSubmit(username: string, password: string) { 15 | const [err, res] = await fetch('login', { 16 | username, 17 | password, 18 | ...platform, 19 | }); 20 | if (!err) { 21 | const user = res; 22 | action.setUser(user); 23 | 24 | const linkmanIds = [ 25 | ...user.groups.map((g: Group) => g._id), 26 | ...user.friends.map((f: Friend) => f._id), 27 | ]; 28 | const [err2, linkmans] = await fetch('getLinkmansLastMessagesV2', { 29 | linkmans: linkmanIds, 30 | }); 31 | if (!err2) { 32 | action.setLinkmansLastMessages(linkmans); 33 | } 34 | 35 | Actions.pop(); 36 | await setStorageValue('token', res.token); 37 | } 38 | } 39 | return ( 40 | 41 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /packages/app/src/pages/LoginSignup/Signup.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Container, Toast } from 'native-base'; 3 | import { Actions } from 'react-native-router-flux'; 4 | 5 | import fetch from '../../utils/fetch'; 6 | import platform from '../../utils/platform'; 7 | import action from '../../state/action'; 8 | 9 | import Base from './Base'; 10 | import { setStorageValue } from '../../utils/storage'; 11 | import { Friend, Group } from '../../types/redux'; 12 | 13 | export default function Signup() { 14 | async function handleSubmit(username: string, password: string) { 15 | const [err, res] = await fetch('register', { 16 | username, 17 | password, 18 | ...platform, 19 | }); 20 | if (!err) { 21 | Toast.show({ 22 | text: '创建成功', 23 | type: 'success', 24 | }); 25 | 26 | const user = res; 27 | action.setUser(user); 28 | 29 | const linkmanIds = [ 30 | ...user.groups.map((g: Group) => g._id), 31 | ...user.friends.map((f: Friend) => f._id), 32 | ]; 33 | const [err2, linkmans] = await fetch('getLinkmansLastMessagesV2', { 34 | linkmans: linkmanIds, 35 | }); 36 | if (!err2) { 37 | action.setLinkmansLastMessages(linkmans); 38 | } 39 | 40 | Actions.chatlist(); 41 | await setStorageValue('token', res.token); 42 | } 43 | } 44 | return ( 45 | 46 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /packages/app/src/pages/Other/PrivacyPolicy.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from 'native-base'; 2 | import React from 'react'; 3 | import { Linking, StyleSheet, TouchableOpacity } from 'react-native'; 4 | import Dialog from 'react-native-dialog'; 5 | import { removeStorageValue, setStorageValue } from '../../utils/storage'; 6 | 7 | export const PrivacyPolicyStorageKey = 'privacy-policy'; 8 | 9 | type Props = { 10 | visible: boolean; 11 | onClose: () => void; 12 | }; 13 | 14 | function PrivacyPolicy({ visible, onClose }: Props) { 15 | function handleClickPrivacyPolicy() { 16 | Linking.openURL('https://fiora.suisuijiang.com/PrivacyPolicy.html'); 17 | } 18 | 19 | async function handleAgree() { 20 | await setStorageValue(PrivacyPolicyStorageKey, 'true'); 21 | onClose(); 22 | } 23 | 24 | async function handleDisagree() { 25 | await removeStorageValue(PrivacyPolicyStorageKey); 26 | onClose(); 27 | } 28 | 29 | return ( 30 | 31 | 服务协议和隐私条款 32 | 33 | 欢迎使用 fiora 34 | APP。我们非常重视您的个人信息和隐私保护,在您使用之前,请务必审慎阅读 35 | 36 | 《隐私政策》 37 | 38 | ,并充分理解协议条款内容。我们将严格按照您同意的各项条款使用您的个人信息,以便为您提供更好的服务。 39 | 40 | 41 | 42 | 43 | ); 44 | } 45 | 46 | export default PrivacyPolicy; 47 | 48 | const styles = StyleSheet.create({ 49 | container: { 50 | textAlign: 'left', 51 | }, 52 | text: { 53 | fontSize: 12, 54 | color: '#2a7bf6', 55 | }, 56 | }); 57 | -------------------------------------------------------------------------------- /packages/app/src/pages/Other/Sponsor.tsx: -------------------------------------------------------------------------------- 1 | import { View, Text } from 'native-base'; 2 | import React from 'react'; 3 | import { StyleSheet } from 'react-native'; 4 | import Dialog from 'react-native-dialog'; 5 | 6 | type Props = { 7 | visible: boolean; 8 | onClose: () => void; 9 | onOK: () => void; 10 | }; 11 | 12 | function Sponsor({ visible, onClose, onOK }: Props) { 13 | return ( 14 | 15 | 赞助 16 | 17 | 18 | 19 | 如果你觉得这个聊天室还不错的话, 希望能赞助一下~~ 20 | 21 | 22 | 请在转账备注中填写您的 fiora 账号 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | 32 | export default Sponsor; 33 | 34 | const styles = StyleSheet.create({ 35 | text: { 36 | fontSize: 14, 37 | color: '#333', 38 | marginTop: 16, 39 | }, 40 | tip: { 41 | fontSize: 12, 42 | color: '#666', 43 | textAlign: 'center', 44 | marginTop: 12, 45 | }, 46 | }); 47 | -------------------------------------------------------------------------------- /packages/app/src/state/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from 'redux'; 2 | import reducer from './reducer'; 3 | 4 | const store = createStore( 5 | // @ts-ignore 6 | reducer, 7 | // @ts-ignore 8 | window.__REDUX_DEVTOOLS_EXTENSION__ && 9 | window.__REDUX_DEVTOOLS_EXTENSION__(), 10 | ); 11 | export default store; 12 | -------------------------------------------------------------------------------- /packages/app/src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@react-native-toolkit/triangle'; 2 | declare module 'react-native-dialog'; 3 | 4 | declare module '*.png'; 5 | -------------------------------------------------------------------------------- /packages/app/src/types/socket.ts: -------------------------------------------------------------------------------- 1 | export type Socket = { 2 | on: (event: string, callback: (...params: any) => void) => void; 3 | }; 4 | -------------------------------------------------------------------------------- /packages/app/src/utils/constant.ts: -------------------------------------------------------------------------------- 1 | export const referer = 'https://fiora.suisuijiang.com/'; 2 | -------------------------------------------------------------------------------- /packages/app/src/utils/expressions.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | default: [ 3 | '呵呵', 4 | '哈哈', 5 | '吐舌', 6 | '啊', 7 | '酷', 8 | '怒', 9 | '开心', 10 | '汗', 11 | '泪', 12 | '黑线', 13 | '鄙视', 14 | '不高兴', 15 | '真棒', 16 | '钱', 17 | '疑问', 18 | '阴险', 19 | '吐', 20 | '咦', 21 | '委屈', 22 | '花心', 23 | '呼', 24 | '笑眼', 25 | '冷', 26 | '太开心', 27 | '滑稽', 28 | '勉强', 29 | '狂汗', 30 | '乖', 31 | '睡觉', 32 | '惊哭', 33 | '升起', 34 | '惊讶', 35 | '喷', 36 | '爱心', 37 | '心碎', 38 | '玫瑰', 39 | '礼物', 40 | '星星月亮', 41 | '太阳', 42 | '音乐', 43 | '灯泡', 44 | '蛋糕', 45 | '彩虹', 46 | '钱币', 47 | '咖啡', 48 | 'haha', 49 | '胜利', 50 | '大拇指', 51 | '弱', 52 | 'ok', 53 | ], 54 | }; 55 | -------------------------------------------------------------------------------- /packages/app/src/utils/fetch.ts: -------------------------------------------------------------------------------- 1 | import Toast from '../components/Toast'; 2 | import socket from '../socket'; 3 | 4 | export default function fetch( 5 | event: string, 6 | data: any = {}, 7 | { toast = true } = {}, 8 | ): Promise<[string | null, T | null]> { 9 | return new Promise((resolve) => { 10 | socket.emit(event, data, (res: any) => { 11 | if (typeof res === 'string') { 12 | if (toast) { 13 | Toast.danger(res); 14 | } 15 | resolve([res, null]); 16 | } else { 17 | resolve([null, res]); 18 | } 19 | }); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /packages/app/src/utils/getFriendId.ts: -------------------------------------------------------------------------------- 1 | export default function getFriendId(userId1: string, userId2: string) { 2 | if (userId1 < userId2) { 3 | return userId1 + userId2; 4 | } 5 | return userId2 + userId1; 6 | } 7 | -------------------------------------------------------------------------------- /packages/app/src/utils/getRandomColor.ts: -------------------------------------------------------------------------------- 1 | import randomColor from 'randomcolor'; 2 | 3 | type ColorMode = 'dark' | 'bright' | 'light' | 'random'; 4 | 5 | /** 6 | * 获取随机颜色, 刷新页面不变 7 | * @param seed when passed will cause randomColor to return the same color each time 8 | */ 9 | export function getRandomColor(seed: string, luminosity: ColorMode = 'dark') { 10 | return randomColor({ 11 | luminosity, 12 | seed, 13 | }); 14 | } 15 | 16 | type Cache = { 17 | [key: string]: string; 18 | }; 19 | 20 | const cache: Cache = {}; 21 | 22 | /** 23 | * 获取随机颜色, 刷新页面后重新随机 24 | * @param seed 随机种子 25 | * @param luminosity 亮度 26 | */ 27 | export function getPerRandomColor( 28 | seed: string, 29 | luminosity: ColorMode = 'dark', 30 | ) { 31 | if (cache[seed]) { 32 | return cache[seed]; 33 | } 34 | cache[seed] = randomColor({ luminosity }); 35 | return cache[seed]; 36 | } 37 | -------------------------------------------------------------------------------- /packages/app/src/utils/linkman.ts: -------------------------------------------------------------------------------- 1 | import { Friend, Group, Linkman } from '../types/redux'; 2 | 3 | export function formatLinkmanName(linkman: Linkman) { 4 | if (linkman!.type === 'group' && (linkman as Group).members.length > 0) { 5 | return `${(linkman as Group).name} (${ 6 | (linkman as Group).members.length 7 | })`; 8 | } 9 | if ( 10 | linkman!.type !== 'group' && 11 | (linkman as Friend).isOnline !== undefined 12 | ) { 13 | return `${(linkman as Friend).name} (${ 14 | (linkman as Friend).isOnline ? '在线' : '离线' 15 | })`; 16 | } 17 | return linkman.name; 18 | } 19 | -------------------------------------------------------------------------------- /packages/app/src/utils/platform.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from 'react-native'; 2 | import Constants from 'expo-constants'; 3 | // eslint-disable-next-line import/extensions 4 | import packageInfo from '../../app.json'; 5 | 6 | const os = Platform.OS === 'ios' ? 'iOS' : 'Android'; 7 | 8 | export const isiOS = Platform.OS === 'ios'; 9 | export const isAndroid = Platform.OS === 'android'; 10 | 11 | export default { 12 | os, 13 | browser: 'App', 14 | environment: `App ${ 15 | process.env.NODE_ENV === 'development' 16 | ? '开发版' 17 | : packageInfo.expo.version 18 | } on ${os} ${ 19 | isiOS ? Constants.platform?.ios?.systemVersion : Constants.systemVersion 20 | } ${isiOS ? Constants.platform?.ios?.model : ''}`, 21 | }; 22 | -------------------------------------------------------------------------------- /packages/app/src/utils/storage.ts: -------------------------------------------------------------------------------- 1 | import AsyncStorage from '@react-native-async-storage/async-storage'; 2 | 3 | export async function getStorageValue(key: string) { 4 | return AsyncStorage.getItem(key); 5 | } 6 | 7 | export async function setStorageValue(key: string, value: string) { 8 | return AsyncStorage.setItem(key, value); 9 | } 10 | 11 | export async function removeStorageValue(key: string) { 12 | return AsyncStorage.removeItem(key); 13 | } 14 | -------------------------------------------------------------------------------- /packages/app/src/utils/time.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | isToday(time1: Date, time2: Date) { 3 | return ( 4 | time1.getFullYear() === time2.getFullYear() && 5 | time1.getMonth() === time2.getMonth() && 6 | time1.getDate() === time2.getDate() 7 | ); 8 | }, 9 | isYesterday(time1: Date, time2: Date) { 10 | const prevDate = new Date(time1); 11 | prevDate.setDate(time1.getDate() - 1); 12 | return ( 13 | prevDate.getFullYear() === time2.getFullYear() && 14 | prevDate.getMonth() === time2.getMonth() && 15 | prevDate.getDate() === time2.getDate() 16 | ); 17 | }, 18 | isSameYear(time1: Date, time2: Date) { 19 | return time1.getFullYear() === time2.getFullYear(); 20 | }, 21 | getHourMinute(time: Date) { 22 | const hours = time.getHours(); 23 | const minutes = time.getMinutes(); 24 | return `${hours < 10 ? `0${hours}` : hours}:${ 25 | minutes < 10 ? `0${minutes}` : minutes 26 | }`; 27 | }, 28 | getMonthDate(time: Date) { 29 | return `${time.getMonth() + 1}/${time.getDate()}`; 30 | }, 31 | getYearMonthDate(time: Date) { 32 | return `${time.getFullYear()}/${time.getMonth() + 1}/${time.getDate()}`; 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /packages/app/src/utils/uploadFile.ts: -------------------------------------------------------------------------------- 1 | import fetch from './fetch'; 2 | 3 | /** 4 | * 上传文件 5 | * @param blob 文件blob数据 6 | * @param fileName 文件名 7 | */ 8 | export default async function uploadFile( 9 | blob: Blob | string, 10 | fileName: string, 11 | isBase64 = false, 12 | ): Promise { 13 | const [uploadErr, result] = await fetch('uploadFile', { 14 | file: blob, 15 | fileName, 16 | isBase64, 17 | }); 18 | if (uploadErr) { 19 | throw Error(`上传图片失败::${uploadErr}`); 20 | } 21 | return result.url; 22 | } 23 | 24 | export function getOSSFileUrl(url: string | number = '', process = '') { 25 | if (typeof url === 'number') { 26 | return url; 27 | } 28 | const [rawUrl = '', extraPrams = ''] = url.split('?'); 29 | if (/^\/\/cdn\.suisuijiang\.com/.test(rawUrl)) { 30 | return `https:${rawUrl}?x-oss-process=${process}${ 31 | extraPrams ? `&${extraPrams}` : '' 32 | }`; 33 | } 34 | if (url.startsWith('//')) { 35 | return `https:${url}`; 36 | } 37 | if (url.startsWith('/')) { 38 | return `https://fiora.suisuijiang.com${url}`; 39 | } 40 | return `${url}`; 41 | } 42 | -------------------------------------------------------------------------------- /packages/app/tests/state/reducer.test.ts: -------------------------------------------------------------------------------- 1 | import { mergeLinkmans } from '../../src/state/reducer'; 2 | import { Linkman } from '../../src/types/redux'; 3 | 4 | describe('mergeLinkmans', () => { 5 | it('should return linkmans which is newly and reserve history messages', () => { 6 | const linkmans1 = [ 7 | { 8 | _id: 'l1', 9 | name: 'l1', 10 | messages: [], 11 | }, 12 | { 13 | _id: 'l2', 14 | name: 'l2', 15 | messages: [ 16 | { 17 | _id: 'm1', 18 | }, 19 | { 20 | _id: 'm2', 21 | }, 22 | ], 23 | }, 24 | ]; 25 | const linkmans2 = [ 26 | { 27 | _id: 'l2', 28 | name: 'l2', 29 | messages: [ 30 | { 31 | _id: 'm1', 32 | }, 33 | { 34 | _id: 'm2', 35 | }, 36 | ], 37 | }, 38 | { 39 | _id: 'l3', 40 | name: 'l3', 41 | messages: [], 42 | }, 43 | ]; 44 | 45 | const linkmans = mergeLinkmans( 46 | linkmans1 as any, 47 | linkmans2 as any, 48 | ) as Linkman[]; 49 | expect(linkmans).toHaveLength(2); 50 | expect(linkmans[0]._id).toBe('l2'); 51 | expect(linkmans[1]._id).toBe('l3'); 52 | 53 | expect(linkmans[0].messages).toHaveLength(2); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /packages/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | } 4 | -------------------------------------------------------------------------------- /packages/assets/audios/apple.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/audios/apple.mp3 -------------------------------------------------------------------------------- /packages/assets/audios/default.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/audios/default.mp3 -------------------------------------------------------------------------------- /packages/assets/audios/huaji.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/audios/huaji.mp3 -------------------------------------------------------------------------------- /packages/assets/audios/mobileqq.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/audios/mobileqq.mp3 -------------------------------------------------------------------------------- /packages/assets/audios/momo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/audios/momo.mp3 -------------------------------------------------------------------------------- /packages/assets/audios/pcqq.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/audios/pcqq.mp3 -------------------------------------------------------------------------------- /packages/assets/fonts/font.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/fonts/font.woff -------------------------------------------------------------------------------- /packages/assets/images/alipay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/alipay.png -------------------------------------------------------------------------------- /packages/assets/images/android-apk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/android-apk.png -------------------------------------------------------------------------------- /packages/assets/images/background-cool.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/background-cool.jpg -------------------------------------------------------------------------------- /packages/assets/images/background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/background.jpg -------------------------------------------------------------------------------- /packages/assets/images/baidu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/baidu.png -------------------------------------------------------------------------------- /packages/assets/images/huaji/0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/0.jpg -------------------------------------------------------------------------------- /packages/assets/images/huaji/1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/1.gif -------------------------------------------------------------------------------- /packages/assets/images/huaji/10.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/10.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/11.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/11.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/12.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/12.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/13.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/13.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/14.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/14.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/15.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/15.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/16.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/16.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/17.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/17.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/18.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/18.gif -------------------------------------------------------------------------------- /packages/assets/images/huaji/19.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/19.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/2.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/20.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/20.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/21.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/21.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/22.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/22.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/23.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/23.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/24.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/24.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/25.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/25.png -------------------------------------------------------------------------------- /packages/assets/images/huaji/26.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/26.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/27.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/27.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/28.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/28.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/29.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/29.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/3.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/3.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/30.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/30.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/31.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/31.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/32.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/32.jpg -------------------------------------------------------------------------------- /packages/assets/images/huaji/33.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/33.gif -------------------------------------------------------------------------------- /packages/assets/images/huaji/34.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/34.gif -------------------------------------------------------------------------------- /packages/assets/images/huaji/35.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/35.gif -------------------------------------------------------------------------------- /packages/assets/images/huaji/36.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/36.gif -------------------------------------------------------------------------------- /packages/assets/images/huaji/4.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/4.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/5.jpg -------------------------------------------------------------------------------- /packages/assets/images/huaji/6.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/6.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/7.jpg -------------------------------------------------------------------------------- /packages/assets/images/huaji/8.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/8.jpeg -------------------------------------------------------------------------------- /packages/assets/images/huaji/9.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/huaji/9.jpeg -------------------------------------------------------------------------------- /packages/assets/images/ios-expo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/ios-expo.png -------------------------------------------------------------------------------- /packages/assets/images/no-linkman.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/no-linkman.jpeg -------------------------------------------------------------------------------- /packages/assets/images/wuzeiniang.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/wuzeiniang.gif -------------------------------------------------------------------------------- /packages/assets/images/wxpay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/assets/images/wxpay.png -------------------------------------------------------------------------------- /packages/assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fiora/assets", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": true 6 | } 7 | -------------------------------------------------------------------------------- /packages/bin/index.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | 5 | const script = process.argv[2]; 6 | if (!script) { 7 | console.log(chalk.green('没有任何事发生~')); 8 | process.exit(0); 9 | } 10 | 11 | const file = path.resolve(__dirname, `scripts/${script}.ts`); 12 | if (!fs.existsSync(file)) { 13 | console.log(chalk.red(`[${script}] 脚本不存在`)); 14 | } 15 | 16 | // @ts-ignore 17 | import(file).then((module) => { 18 | module.default(); 19 | }); 20 | -------------------------------------------------------------------------------- /packages/bin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fiora/bin", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "scripts": { 7 | "script": "ts-node --transpile-only index.ts" 8 | }, 9 | "dependencies": { 10 | "@fiora/config": "^1.0.0", 11 | "@fiora/database": "^1.0.0", 12 | "bcryptjs": "^2.4.3", 13 | "chalk": "^4.1.1", 14 | "detect-port": "^1.3.0", 15 | "inquirer": "^8.1.2" 16 | }, 17 | "devDependencies": { 18 | "@types/bcryptjs": "^2.4.2", 19 | "@types/detect-port": "^1.3.1", 20 | "@types/inquirer": "^7.3.3" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/bin/scripts/deleteMessages.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import inquirer from 'inquirer'; 4 | import { promisify } from 'util'; 5 | import chalk from 'chalk'; 6 | import initMongoDB from '@fiora/database/mongoose/initMongoDB'; 7 | import Message from '@fiora/database/mongoose/models/message'; 8 | import History from '@fiora/database/mongoose/models/history'; 9 | 10 | export async function deleteMessages() { 11 | const shouldDeleteAllMessages = await inquirer.prompt({ 12 | type: 'confirm', 13 | name: 'result', 14 | message: 'Confirm to delete all messages?', 15 | }); 16 | if (!shouldDeleteAllMessages.result) { 17 | return; 18 | } 19 | 20 | await initMongoDB(); 21 | 22 | const deleteResult = await Message.deleteMany({}); 23 | console.log('Delete result:', deleteResult); 24 | 25 | const deleteHistoryResult = await History.deleteMany({}); 26 | console.log('Delete history result:', deleteHistoryResult); 27 | 28 | const shouldDeleteAllFiles = await inquirer.prompt({ 29 | type: 'confirm', 30 | name: 'result', 31 | message: 'Confirm to delete all message files(Except OSS files)?', 32 | }); 33 | if (!shouldDeleteAllFiles.result) { 34 | return; 35 | } 36 | 37 | const files = await promisify(fs.readdir)( 38 | path.resolve(__dirname, '../../server/public/'), 39 | ); 40 | const iamgesAndFiles = files.filter( 41 | (filename) => 42 | filename.startsWith('ImageMessage_') || 43 | filename.startsWith('FileMessage_'), 44 | ); 45 | const unlinkAsync = promisify(fs.unlink); 46 | await Promise.all( 47 | iamgesAndFiles.map((file) => 48 | unlinkAsync(path.resolve(__dirname, '../../server/public/', file)), 49 | ), 50 | ); 51 | console.log('Delete files:', chalk.green(iamgesAndFiles.length.toString())); 52 | console.log(chalk.red(iamgesAndFiles.join('\n'))); 53 | 54 | console.log(chalk.green('Successfully deleted all messages')); 55 | } 56 | 57 | async function run() { 58 | await deleteMessages(); 59 | process.exit(0); 60 | } 61 | export default run; 62 | -------------------------------------------------------------------------------- /packages/bin/scripts/deleteTodayRegisteredUsers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Delete users created today and their related data 3 | */ 4 | import chalk from 'chalk'; 5 | 6 | import inquirer from 'inquirer'; 7 | import initMongoDB from '@fiora/database/mongoose/initMongoDB'; 8 | import User from '@fiora/database/mongoose/models/user'; 9 | import { deleteUser } from './deleteUser'; 10 | 11 | export async function deleteTodayRegisteredUsers() { 12 | await initMongoDB(); 13 | 14 | const now = new Date(); 15 | const time = new Date( 16 | `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()} 00:00:00`, 17 | ); 18 | const users = await User.find({ 19 | createTime: { 20 | $gte: time, 21 | }, 22 | }); 23 | console.log( 24 | `There are ${chalk.green( 25 | users.length.toString(), 26 | )} newly registered users today`, 27 | ); 28 | if (users.length === 0) { 29 | return; 30 | } 31 | 32 | const shouldDeleteUsers = await inquirer.prompt({ 33 | type: 'confirm', 34 | name: 'result', 35 | message: 'Confirm to delete these users?', 36 | }); 37 | if (!shouldDeleteUsers.result) { 38 | return; 39 | } 40 | await Promise.all( 41 | users.map((user) => deleteUser(user._id.toString(), false)), 42 | ); 43 | 44 | console.log( 45 | chalk.green('Successfully deleted today’s newly registered users'), 46 | ); 47 | } 48 | 49 | async function run() { 50 | await deleteTodayRegisteredUsers(); 51 | process.exit(0); 52 | } 53 | export default run; 54 | -------------------------------------------------------------------------------- /packages/bin/scripts/doctor.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import cp from 'child_process'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import detect from 'detect-port'; 6 | import server from '@fiora/config/server'; 7 | import initRedis from '@fiora/database/redis/initRedis'; 8 | import initMongoDB from '@fiora/database/mongoose/initMongoDB'; 9 | 10 | export async function doctor() { 11 | console.log(chalk.yellow('===== Run Fiora Doctor =====')); 12 | 13 | const nodeVersion = cp.execSync('node --version').toString(); 14 | console.log( 15 | chalk.green(`node ${nodeVersion.slice(0, nodeVersion.length - 1)}`), 16 | ); 17 | 18 | await initMongoDB(); 19 | console.log(chalk.green('MongoDB is OK')); 20 | 21 | await (async () => 22 | new Promise((resolve) => { 23 | const redis = initRedis(); 24 | redis.on('connect', resolve); 25 | }))(); 26 | console.log(chalk.green('Redis is OK')); 27 | 28 | const avaliablePort = await detect(server.port); 29 | if (avaliablePort === server.port) { 30 | console.log(chalk.green(`Port [${server.port}] is OK`)); 31 | } else { 32 | console.log(chalk.red(`Port [${server.port}] was occupied`)); 33 | } 34 | 35 | const indexFilePath = path.resolve( 36 | __dirname, 37 | '../../server/public/index.html', 38 | ); 39 | const indexFile = fs.readFileSync(indexFilePath); 40 | if (!indexFile) { 41 | console.log(chalk.red('Homepage not exists')); 42 | } else if (indexFile.toString().includes('默认首页')) { 43 | console.log( 44 | chalk.red( 45 | 'Homepage is default. Please build web client by [yarn build:web]', 46 | ), 47 | ); 48 | } else { 49 | console.log(chalk.green(`Homepage is OK`)); 50 | } 51 | } 52 | 53 | async function run() { 54 | await doctor(); 55 | process.exit(0); 56 | } 57 | export default run; 58 | -------------------------------------------------------------------------------- /packages/bin/scripts/fixUsersAvatar.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import inquirer from 'inquirer'; 3 | import User from '@fiora/database/mongoose/models/user'; 4 | import initMongoDB from '@fiora/database/mongoose/initMongoDB'; 5 | 6 | export async function fixUsersAvatar( 7 | searchValue: string, 8 | replaceValue: string, 9 | ) { 10 | searchValue = searchValue || 'fioraavatar'; 11 | replaceValue = replaceValue || 'fiora/avatar'; 12 | 13 | await initMongoDB(); 14 | 15 | const users = await User.find({ avatar: { $regex: 'fioraavatar' } }); 16 | if (users.length) { 17 | console.log(chalk.red('Oh No!'), "Some user's avatar is wrong"); 18 | users.forEach((user) => { 19 | console.log(user._id, user.username, user.avatar); 20 | }); 21 | 22 | const shouldFix = await inquirer.prompt({ 23 | type: 'confirm', 24 | name: 'result', 25 | message: 'Confirm to fix?', 26 | }); 27 | if (shouldFix.result) { 28 | await Promise.all( 29 | users.map((user) => { 30 | user.avatar = user.avatar.replace( 31 | searchValue, 32 | replaceValue, 33 | ); 34 | return user.save(); 35 | }), 36 | ); 37 | console.log(chalk.green('Congratulations! Fixed!')); 38 | } 39 | } else { 40 | console.log(chalk.green('OK!'), "All user's avatar is corrent"); 41 | } 42 | } 43 | 44 | async function run() { 45 | const searchValue = process.argv[3]; 46 | const replaceValue = process.argv[4]; 47 | await fixUsersAvatar(searchValue, replaceValue); 48 | process.exit(0); 49 | } 50 | export default run; 51 | -------------------------------------------------------------------------------- /packages/bin/scripts/getUserId.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import User from '@fiora/database/mongoose/models/user'; 3 | import initMongoDB from '@fiora/database/mongoose/initMongoDB'; 4 | 5 | export async function getUserId(username: string) { 6 | if (!username) { 7 | console.log(chalk.red('Wrong command, [username] is missing.')); 8 | return; 9 | } 10 | 11 | await initMongoDB(); 12 | 13 | const user = await User.findOne({ username }); 14 | if (!user) { 15 | console.log(chalk.red(`User [${username}] does not exist`)); 16 | } else { 17 | console.log( 18 | `The userId of [${username}] is:`, 19 | chalk.green(user._id.toString()), 20 | ); 21 | } 22 | } 23 | 24 | async function run() { 25 | const username = process.argv[3]; 26 | await getUserId(username); 27 | process.exit(0); 28 | } 29 | export default run; 30 | -------------------------------------------------------------------------------- /packages/bin/scripts/updateDefaultGroupName.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import initMongoDB from '@fiora/database/mongoose/initMongoDB'; 3 | import Group from '@fiora/database/mongoose/models/group'; 4 | 5 | export async function updateDefaultGroupName(newName: string) { 6 | if (!newName) { 7 | console.log(chalk.red('Wrong command, [newName] is missing.')); 8 | return; 9 | } 10 | 11 | await initMongoDB(); 12 | 13 | const group = await Group.findOne({ isDefault: true }); 14 | if (!group) { 15 | console.log(chalk.red('Default group does not exist')); 16 | } else { 17 | group.name = newName; 18 | try { 19 | await group.save(); 20 | console.log(chalk.green('Update default group name success!')); 21 | } catch (err) { 22 | console.log( 23 | chalk.red('Update default group name fail!'), 24 | err.message, 25 | ); 26 | } 27 | } 28 | } 29 | 30 | async function run() { 31 | const newName = process.argv[3]; 32 | await updateDefaultGroupName(newName); 33 | process.exit(0); 34 | } 35 | export default run; 36 | -------------------------------------------------------------------------------- /packages/bin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | } -------------------------------------------------------------------------------- /packages/config/client.ts: -------------------------------------------------------------------------------- 1 | import { MB } from '../utils/const'; 2 | 3 | export default { 4 | server: 5 | process.env.Server || 6 | (process.env.NODE_ENV === 'development' ? '//localhost:9200' : '/'), 7 | 8 | maxImageSize: process.env.MaxImageSize 9 | ? parseInt(process.env.MaxImageSize, 10) 10 | : MB * 5, 11 | maxBackgroundImageSize: process.env.MaxBackgroundImageSize 12 | ? parseInt(process.env.MaxBackgroundImageSize, 10) 13 | : MB * 5, 14 | maxAvatarSize: process.env.MaxAvatarSize 15 | ? parseInt(process.env.MaxAvatarSize, 10) 16 | : MB * 1.5, 17 | maxFileSize: process.env.MaxFileSize 18 | ? parseInt(process.env.MaxFileSize, 10) 19 | : MB * 10, 20 | 21 | // client default system setting 22 | defaultTheme: process.env.DefaultTheme || 'cool', 23 | sound: process.env.Sound || 'default', 24 | tagColorMode: process.env.TagColorMode || 'fixedColor', 25 | 26 | /** 27 | * 前端监控: https://yueying.effirst.com/index 28 | * 值为监控应用id, 为空则不启用监控 29 | */ 30 | frontendMonitorAppId: process.env.FrontendMonitorAppId || '', 31 | 32 | // 禁止用户撤回消息, 不包括管理员, 管理员始终能撤回任何消息 33 | // 默认是禁止的 34 | disableDeleteMessage: process.env.DisableDeleteMessage 35 | ? process.env.DisableDeleteMessage === 'true' 36 | : false, 37 | }; 38 | -------------------------------------------------------------------------------- /packages/config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fiora/config", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "dependencies": { 7 | "ip": "^1.1.5" 8 | }, 9 | "devDependencies": { 10 | "@types/ip": "^1.1.0" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/config/server.ts: -------------------------------------------------------------------------------- 1 | import ip from 'ip'; 2 | 3 | const { env } = process; 4 | 5 | export default { 6 | /** 服务端host, 默认为本机ip地址(可能会是局域网地址) */ 7 | host: env.Host || ip.address(), 8 | 9 | // service port 10 | port: env.Port ? parseInt(env.Port, 10) : 9200, 11 | 12 | // mongodb address 13 | database: env.Database || 'mongodb://localhost:27017/fiora', 14 | 15 | redis: { 16 | host: env.RedisHost || 'localhost', 17 | port: env.RedisPort ? parseInt(env.RedisPort, 10) : 6379, 18 | }, 19 | 20 | // jwt encryption secret 21 | jwtSecret: env.JwtSecret || 'jwtSecret', 22 | 23 | // Maximize the number of groups 24 | maxGroupsCount: env.MaxGroupCount ? parseInt(env.MaxGroupCount, 10) : 3, 25 | 26 | allowOrigin: env.AllowOrigin ? env.AllowOrigin.split(',') : null, 27 | 28 | // token expires time 29 | tokenExpiresTime: env.TokenExpiresTime 30 | ? parseInt(env.TokenExpiresTime, 10) 31 | : 1000 * 60 * 60 * 24 * 30, 32 | 33 | // administrator user id 34 | administrator: env.Administrator ? env.Administrator.split(',') : [], 35 | 36 | /** 禁用注册功能 */ 37 | disableRegister: env.DisableRegister 38 | ? env.DisableRegister === 'true' 39 | : false, 40 | 41 | /** disable user create new group */ 42 | disableCreateGroup: env.DisableCreateGroup 43 | ? env.DisableCreateGroup === 'true' 44 | : false, 45 | 46 | /** Aliyun OSS */ 47 | aliyunOSS: { 48 | enable: env.ALIYUN_OSS ? env.ALIYUN_OSS === 'true' : false, 49 | accessKeyId: env.ACCESS_KEY_ID || '', 50 | accessKeySecret: env.ACCESS_KEY_SECRET || '', 51 | roleArn: env.ROLE_ARN || '', 52 | region: env.REGION || '', 53 | bucket: env.BUCKET || '', 54 | endpoint: env.ENDPOINT || '', 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /packages/config/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/ip@^1.1.0": 6 | version "1.1.0" 7 | resolved "https://registry.yarnpkg.com/@types/ip/-/ip-1.1.0.tgz#aec4f5bfd49e4a4c53b590d88c36eb078827a7c0" 8 | integrity sha512-dwNe8gOoF70VdL6WJBwVHtQmAX4RMd62M+mAB9HQFjG1/qiCLM/meRy95Pd14FYBbEDwCq7jgJs89cHpLBu4HQ== 9 | dependencies: 10 | "@types/node" "*" 11 | 12 | "@types/node@*": 13 | version "16.3.2" 14 | resolved "https://registry.yarnpkg.com/@types/node/-/node-16.3.2.tgz#655432817f83b51ac869c2d51dd8305fb8342e16" 15 | integrity sha512-jJs9ErFLP403I+hMLGnqDRWT0RYKSvArxuBVh2veudHV7ifEC1WAmjJADacZ7mRbA2nWgHtn8xyECMAot0SkAw== 16 | 17 | ip@^1.1.5: 18 | version "1.1.5" 19 | resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" 20 | integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= 21 | -------------------------------------------------------------------------------- /packages/database/mongoose/index.ts: -------------------------------------------------------------------------------- 1 | export * from 'mongoose'; 2 | -------------------------------------------------------------------------------- /packages/database/mongoose/initMongoDB.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 连接 MongoDB 3 | */ 4 | 5 | import mongoose from 'mongoose'; 6 | 7 | import config from '@fiora/config/server'; 8 | import logger from '@fiora/utils/logger'; 9 | 10 | mongoose.Promise = Promise; 11 | mongoose.set('useCreateIndex', true); 12 | 13 | export default function initMongoDB() { 14 | return new Promise((resolve) => { 15 | mongoose.connect( 16 | config.database, 17 | { useNewUrlParser: true, useUnifiedTopology: true }, 18 | async (err) => { 19 | if (err) { 20 | logger.error('[mongoDB]', err.message); 21 | process.exit(0); 22 | } else { 23 | resolve(null); 24 | } 25 | }, 26 | ); 27 | }); 28 | } 29 | 30 | export { mongoose }; 31 | -------------------------------------------------------------------------------- /packages/database/mongoose/models/friend.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Document } from 'mongoose'; 2 | 3 | const FriendSchema = new Schema({ 4 | createTime: { type: Date, default: Date.now }, 5 | 6 | from: { 7 | type: Schema.Types.ObjectId, 8 | ref: 'User', 9 | index: true, 10 | }, 11 | to: { 12 | type: Schema.Types.ObjectId, 13 | ref: 'User', 14 | }, 15 | }); 16 | 17 | export interface FriendDocument extends Document { 18 | /** 源用户id */ 19 | from: string; 20 | /** 目标用户id */ 21 | to: string; 22 | /** 创建时间 */ 23 | createTime: Date; 24 | } 25 | 26 | /** 27 | * Friend Model 28 | * 好友信息 29 | * 好友关系是单向的 30 | */ 31 | const Friend = model('Friend', FriendSchema); 32 | 33 | export default Friend; 34 | -------------------------------------------------------------------------------- /packages/database/mongoose/models/group.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Document } from 'mongoose'; 2 | import { NAME_REGEXP } from '@fiora/utils/const'; 3 | 4 | const GroupSchema = new Schema({ 5 | createTime: { type: Date, default: Date.now }, 6 | 7 | name: { 8 | type: String, 9 | trim: true, 10 | unique: true, 11 | match: NAME_REGEXP, 12 | index: true, 13 | }, 14 | avatar: String, 15 | announcement: { 16 | type: String, 17 | default: '', 18 | }, 19 | creator: { 20 | type: Schema.Types.ObjectId, 21 | ref: 'User', 22 | }, 23 | isDefault: { 24 | type: Boolean, 25 | default: false, 26 | }, 27 | members: [ 28 | { 29 | type: Schema.Types.ObjectId, 30 | ref: 'User', 31 | }, 32 | ], 33 | }); 34 | 35 | export interface GroupDocument extends Document { 36 | /** 群组名 */ 37 | name: string; 38 | /** 头像 */ 39 | avatar: string; 40 | /** 公告 */ 41 | announcement: string; 42 | /** 创建者 */ 43 | creator: string; 44 | /** 是否为默认群组 */ 45 | isDefault: boolean; 46 | /** 成员 */ 47 | members: string[]; 48 | /** 创建时间 */ 49 | createTime: Date; 50 | } 51 | 52 | /** 53 | * Group Model 54 | * 群组信息 55 | */ 56 | const Group = model('Group', GroupSchema); 57 | 58 | export default Group; 59 | -------------------------------------------------------------------------------- /packages/database/mongoose/models/history.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Document } from 'mongoose'; 2 | 3 | const HistoryScheme = new Schema({ 4 | user: { 5 | type: String, 6 | required: true, 7 | }, 8 | linkman: { 9 | type: String, 10 | required: true, 11 | }, 12 | message: { 13 | type: String, 14 | required: true, 15 | }, 16 | }); 17 | 18 | export interface HistoryDocument extends Document { 19 | /** user id */ 20 | user: string; 21 | 22 | /** linkman id */ 23 | linkman: string; 24 | 25 | /** last readed message id */ 26 | message: string; 27 | } 28 | 29 | const History = model('History', HistoryScheme); 30 | 31 | export default History; 32 | 33 | export async function createOrUpdateHistory( 34 | userId: string, 35 | linkmanId: string, 36 | messageId: string, 37 | ) { 38 | const history = await History.findOne({ user: userId, linkman: linkmanId }); 39 | if (history) { 40 | history.message = messageId; 41 | await history.save(); 42 | } else { 43 | await History.create({ 44 | user: userId, 45 | linkman: linkmanId, 46 | message: messageId, 47 | }); 48 | } 49 | return {}; 50 | } 51 | -------------------------------------------------------------------------------- /packages/database/mongoose/models/notification.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Document } from 'mongoose'; 2 | 3 | const NotificationSchema = new Schema({ 4 | createTime: { type: Date, default: Date.now }, 5 | 6 | user: { 7 | type: Schema.Types.ObjectId, 8 | ref: 'User', 9 | }, 10 | token: { 11 | type: String, 12 | unique: true, 13 | }, 14 | }); 15 | 16 | export interface NotificationDocument extends Document { 17 | user: any; 18 | token: string; 19 | } 20 | 21 | const Notification = model( 22 | 'Notification', 23 | NotificationSchema, 24 | ); 25 | 26 | export default Notification; 27 | -------------------------------------------------------------------------------- /packages/database/mongoose/models/socket.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Document } from 'mongoose'; 2 | 3 | const SocketSchema = new Schema({ 4 | createTime: { type: Date, default: Date.now }, 5 | 6 | id: { 7 | type: String, 8 | unique: true, 9 | index: true, 10 | }, 11 | user: { 12 | type: Schema.Types.ObjectId, 13 | ref: 'User', 14 | }, 15 | ip: String, 16 | os: { 17 | type: String, 18 | default: '', 19 | }, 20 | browser: { 21 | type: String, 22 | default: '', 23 | }, 24 | environment: { 25 | type: String, 26 | default: '', 27 | }, 28 | }); 29 | 30 | export interface SocketDocument extends Document { 31 | /** socket连接id */ 32 | id: string; 33 | /** 关联用户id */ 34 | user: any; 35 | /** ip地址 */ 36 | ip: string; 37 | /** 系统 */ 38 | os: string; 39 | /** 浏览器 */ 40 | browser: string; 41 | /** 详细环境信息 */ 42 | environment: string; 43 | /** 创建时间 */ 44 | createTime: Date; 45 | } 46 | 47 | /** 48 | * Socket Model 49 | * 客户端socket连接信息 50 | */ 51 | const Socket = model('Socket', SocketSchema); 52 | 53 | export default Socket; 54 | -------------------------------------------------------------------------------- /packages/database/mongoose/models/user.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Document } from 'mongoose'; 2 | import { NAME_REGEXP } from '@fiora/utils/const'; 3 | 4 | const UserSchema = new Schema({ 5 | createTime: { type: Date, default: Date.now }, 6 | lastLoginTime: { type: Date, default: Date.now }, 7 | 8 | username: { 9 | type: String, 10 | trim: true, 11 | unique: true, 12 | match: NAME_REGEXP, 13 | index: true, 14 | }, 15 | salt: String, 16 | password: String, 17 | avatar: String, 18 | tag: { 19 | type: String, 20 | default: '', 21 | trim: true, 22 | match: NAME_REGEXP, 23 | }, 24 | expressions: [ 25 | { 26 | type: String, 27 | }, 28 | ], 29 | lastLoginIp: String, 30 | }); 31 | 32 | export interface UserDocument extends Document { 33 | /** 用户名 */ 34 | username: string; 35 | /** 密码加密盐 */ 36 | salt: string; 37 | /** 加密的密码 */ 38 | password: string; 39 | /** 头像 */ 40 | avatar: string; 41 | /** 用户标签 */ 42 | tag: string; 43 | /** 表情收藏 */ 44 | expressions: string[]; 45 | /** 创建时间 */ 46 | createTime: Date; 47 | /** 最后登录时间 */ 48 | lastLoginTime: Date; 49 | /** 最后登录IP */ 50 | lastLoginIp: string; 51 | } 52 | 53 | /** 54 | * User Model 55 | * 用户信息 56 | */ 57 | const User = model('User', UserSchema); 58 | 59 | export default User; 60 | -------------------------------------------------------------------------------- /packages/database/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fiora/database", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "dependencies": { 7 | "@fiora/config": "^1.0.0", 8 | "@fiora/utils": "^1.0.0", 9 | "mongoose": "^5.13.3", 10 | "redis": "^3.1.2" 11 | }, 12 | "devDependencies": { 13 | "@types/mongoose": "^5.11.97", 14 | "@types/redis": "^2.8.31" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/database/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | } -------------------------------------------------------------------------------- /packages/docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /packages/docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /packages/docs/docs/API.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: api 3 | --- 4 | -------------------------------------------------------------------------------- /packages/docs/docs/App.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: app 3 | --- 4 | -------------------------------------------------------------------------------- /packages/docs/docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: changelog 3 | --- 4 | -------------------------------------------------------------------------------- /packages/docs/docs/Config.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: config 3 | --- 4 | -------------------------------------------------------------------------------- /packages/docs/docs/FAQ.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: faq 3 | --- 4 | -------------------------------------------------------------------------------- /packages/docs/docs/Getting-Start.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: getting-start 3 | --- 4 | -------------------------------------------------------------------------------- /packages/docs/docs/INSTALL.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: install 3 | --- 4 | -------------------------------------------------------------------------------- /packages/docs/docs/Script.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: script 3 | --- 4 | -------------------------------------------------------------------------------- /packages/docs/i18n/en/docusaurus-plugin-content-docs/current/App.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: app 3 | title: Fiora App 4 | sidebar_label: Fiora App 5 | --- 6 | 7 | Fiora app is developed with [expo](https://expo.io/) and [react-native](https://reactnative.dev/). Support Android and iOS systems 8 | 9 | ## Download App 10 | 11 | ### Android 12 | 13 | Click link or scan qrcode to download APK 14 | 15 | [https://cdn.suisuijiang.com/fiora.apk](https://cdn.suisuijiang.com/fiora.apk) 16 | 17 | ![](/img/android-download-qrcode.png) 18 | 19 | ### iOS 20 | 21 | The iOS app is being submitted to the app store for review. You can now install unreviewed apps through testflight. Please contact 碎碎酱 or send an email to . Please attach your apple ID 22 | 23 | ## Hot to run 24 | 25 | 1. Install expo `yarn global add expo-cli` 26 | 2. Install dependencies `yarn install` 27 | 3. Start compilation `expo start` 28 | 4. According to the console prompt, run the app in the simulator or real device 29 | 30 | For more information, please see [https://docs.expo.io/](https://docs.expo.io/) 31 | 32 | ## Build Standalone App 33 | 34 | Please refer to -------------------------------------------------------------------------------- /packages/docs/i18n/en/docusaurus-plugin-content-docs/current/Script.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: script 3 | title: Script 4 | sidebar_label: Script 5 | --- 6 | 7 | Fiora has a built-in command line tool to manage the server. Execute `fiora` to view the tool 8 | 9 | **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 10 | 11 | ## deleteMessages 12 | 13 | `fiora deleteMessages` 14 | 15 | Delete all historical message records, if the message pictures and files are stored on the server, they can also be deleted together 16 | 17 | ## deleteTodayRegisteredUsers 18 | 19 | `fiora deleteTodayRegisteredUsers` 20 | 21 | Delete all newly registered users on the day (based on server time) 22 | 23 | ## deleteUser 24 | 25 | `fiora deleteUser [userId]` 26 | 27 | Delete the specified user, delete its historical messages, exit the group that it has joined, and delete all its friends 28 | 29 | ## doctor 30 | 31 | `fiora doctor` 32 | 33 | Check the server configuration and status, which can be used to locate the cause of the server startup failure 34 | 35 | ## fixUsersAvatar 36 | 37 | `fiora fixUsersAvatar` 38 | 39 | Fix user error avatar path, please modify the script judgment logic according to your actual situation 40 | 41 | ## getUserId 42 | 43 | `fiora getUserId [username]` 44 | 45 | Get the userId of the specified user name 46 | 47 | ## register 48 | 49 | `fiora register [username] [password]` 50 | 51 | Register new users, when registration is prohibited, the administrator can register new users through it 52 | 53 | ## updateDefaultGroupName 54 | 55 | `fiora updateDefaultGroupName [newName]` 56 | 57 | Update default group name -------------------------------------------------------------------------------- /packages/docs/i18n/en/docusaurus-theme-classic/footer.json: -------------------------------------------------------------------------------- 1 | { 2 | "link.title.Docs": { 3 | "message": "Docs" 4 | }, 5 | "link.item.label.Overview": { 6 | "message": "Overview" 7 | }, 8 | 9 | "link.title.Community": { 10 | "message": "Community" 11 | }, 12 | "link.item.label.Feedback": { 13 | "message": "Join Chat" 14 | }, 15 | "link.item.label.Issues": { 16 | "message": "Submit Issue" 17 | }, 18 | 19 | "link.title.More": { 20 | "message": "More" 21 | }, 22 | "link.item.label.Author": { 23 | "message": "About Author" 24 | }, 25 | "link.item.label.GitHub": { 26 | "message": "GitHub" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/docs/i18n/en/docusaurus-theme-classic/navbar.json: -------------------------------------------------------------------------------- 1 | { 2 | "item.label.Docs": { 3 | "message": "Docs" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/docs/i18n/zh-Hans/code.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": { 3 | "message": "fiora" 4 | }, 5 | "TagLine": { 6 | "message": "一个有趣的开源聊天应用" 7 | }, 8 | "Keywords": { 9 | "message": "fiora, fiora 文档, node.js, 聊天室" 10 | }, 11 | "Description": { 12 | "message": "这是 fiora 文档网站, fiora 是一个有趣的开源聊天室应用" 13 | }, 14 | 15 | "Richness": { 16 | "message": "fiora 包括后端、前端、安卓和 iOS App" 17 | }, 18 | "Cross Platform": { 19 | "message": "fiora 基于 node.js 开发, 支持 Windows / Linux / macOS 等操作系统" 20 | }, 21 | "Open Source": { 22 | "message": "fiora 遵循 MIT 开源许可" 23 | }, 24 | 25 | "Join Chat Title": { 26 | "message": "加入聊天" 27 | }, 28 | "Join Chat Content": { 29 | "message": "注册一个账号加入聊天, 加入或者新的群组, 和有趣的陌生人私聊并加为好友, 你的账号和消息会永久保留" 30 | }, 31 | "Rich Feature Title": { 32 | "message": "丰富的功能" 33 | }, 34 | "Rich Feature Content": { 35 | "message": "你可以发送文本、表情、图片、代码和文件给其他人, 你还可以撤回已发送的消息, 另外你还可以修改用户名和头像, 最令人兴奋的是你可以选择或者自定义不同的主题" 36 | }, 37 | "Deploy By Yourself Title": { 38 | "message": "自己部署" 39 | }, 40 | "Deploy By Yourself Content": { 41 | "message": "fiora 是一个开源项目, 你可以克隆源码并部署到自己的服务器, 支持 windows / Linux and macOS 操作系统, 但是推荐您部署到 Linux 服务器上" 42 | }, 43 | 44 | "Interested": { 45 | "message": "你是否非常感兴趣?" 46 | }, 47 | "Getting Start": { 48 | "message": "查看文档" 49 | }, 50 | 51 | "Try It Now": { 52 | "message": "去体验看看" 53 | }, 54 | 55 | "View Docs": { 56 | "message": "查看文档" 57 | }, 58 | "DocsUrl": { 59 | "message": "/fiora/zh-Hans/docs/getting-start/" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/App.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: app 3 | title: Fiora App 4 | sidebar_label: Fiora App 5 | --- 6 | 7 | fiora app 是基于 [expo](https://expo.io/) he [react-native](https://reactnative.dev/) 开发的, 支持 Android 和 iOS 系统 8 | 9 | ## 下载 App 10 | 11 | ### Android 12 | 13 | 点击链接或者扫描二维码下载 APK 14 | 15 | [https://cdn.suisuijiang.com/fiora.apk](https://cdn.suisuijiang.com/fiora.apk) 16 | 17 | ![](/img/android-download-qrcode.png) 18 | 19 | ### iOS 20 | 21 | iOS app 已经提交给 App Store 审核了, 现在可以通过 testflight 来安装. 请联系碎碎酱或者发送邮件给 , 附上你的 Apple ID 22 | 23 | ## 如何运行 24 | 25 | 1. 安装 expo `yarn global add expo-cli` 26 | 2. 安装依赖 `yarn install` 27 | 3. 启动编译 `expo start` 28 | 4. 根据控制台输出的提示, 在模拟器或者真实设备上运行 app 29 | 30 | 想要了解更多信息, 请查看 [https://docs.expo.io/](https://docs.expo.io/) 31 | 32 | ## 构建 App 33 | 34 | 请参考 -------------------------------------------------------------------------------- /packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: changelog 3 | title: 更新日志 4 | sidebar_label: 更新日志 5 | --- 6 | 7 | ## 2021-6-24 8 | 9 | - 支持带有日文字符的用户名和用户标签 10 | 11 | ## 2021-5-11 12 | 13 | - 使用阿里云 OSS 替代七牛 CDN 14 | 15 | ## 2021-3-24 16 | 17 | - 修复搜索功能允许使用正则表达式匹配的问题 18 | 19 | ## 2021-3-14 20 | 21 | - 支持服务端计算未读消息数量 22 | 23 | ## 2021-3-2 24 | 25 | - 识别消息中的 URL 时, 支持 host 为 localhost 或者 ip 26 | 27 | ## 2021-3-1 28 | 29 | - 管理员创建群组时, 不再限制数量 30 | 31 | ## 2021-2-28 32 | 33 | - 多个用户可以使用相同的 notification token 34 | 35 | ## 2021-2-27 36 | 37 | - 修改 app 通知内容 38 | - 自己发送的消息不再推送通知给自己 39 | - webpack 构建生成环境版本时显示进度条 40 | 41 | ## 2021-2-25 42 | 43 | - 支持推送通知给 fiora app 44 | 45 | ## 2021-2-21 46 | 47 | - **重要** 修复错误的服务端判断管理员的逻辑, 会将所有人当做管理员. 但是前端并不会展示管理员面板 48 | 49 | ## 2021-2-17 50 | 51 | - 支持向 fiora 外部分享群组 52 | 53 | ## 2021-1-26 54 | 55 | - 文件消息的文件大小计算错误 56 | 57 | ## 2021-1-22 58 | 59 | - 一个 ip 在 24 小时内只允许创建三个账号 60 | 61 | ## 2020-12-17 62 | 63 | - 支持根据输入框内容自动搜索表情, 该功能默认是关闭的, 可以在用户设置中打开 64 | 65 | - 只限制发消息的频率 66 | 67 | ## 2020-12-08 68 | 69 | - **兼容性!!!** 使用 redis 缓存来替代内存缓存, 所以你需要在运行 fiora 之前配置并启动 redis 70 | 71 | ## 2020-11-15 72 | 73 | - 使用 webpack 插件来生成 service worker script 74 | - 重构并新增服务端脚本 75 | 76 | ## 2020-11-14 77 | 78 | - 适配 iOS 全面屏设备 79 | 80 | ## 2020-11-12 81 | 82 | - 支持设置多个管理员 83 | 84 | ## 2020-11-08 85 | 86 | - 支持撤回自己发的消息 87 | 88 | ## 2020-11-07 89 | 90 | - 支持发送文件 91 | - 支持展示实时(数据有 60s 缓存时间)的群组在线人数和用户在线状态 92 | 93 | - 重构 webpack 构建配置 94 | 95 | - 修复在图片查看大图时右键会关闭的问题 96 | 97 | ## 2020-11-04 98 | 99 | - **兼容性!!!** 修改 config 文件配置方法, 不再支持通过命令行参数来设置 100 | 101 | ## 2020-11-03 102 | 103 | - 重命名一部分 npm script 名 104 | -------------------------------------------------------------------------------- /packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/Script.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: script 3 | title: 脚本 4 | sidebar_label: 脚本 5 | --- 6 | 7 | fiora 内置了一个命令行工具, 用来管理服务器. 执行 `fiora` 查看工具 8 | 9 | > **注意!** 这些脚本大多会直接修改数据库, 推荐(但非必需)提前备份数据库并停止服务端后再执行 10 | 11 | ## deleteMessages 12 | 13 | `fiora deleteMessages` 14 | 15 | 删除所有历史消息记录, 如果消息图片和文件是存储在服务器上, 也可以一并删除 16 | 17 | ## deleteTodayRegisteredUsers 18 | 19 | `fiora deleteTodayRegisteredUsers` 20 | 21 | 删除当天(以服务器时间为准)新注册的所有用户 22 | 23 | ## deleteUser 24 | 25 | `fiora deleteUser [userId]` 26 | 27 | 删除指定用户, 同时删除其历史消息, 退出其已加入的群组并删除其所有好友关系 28 | 29 | ## doctor 30 | 31 | `fiora doctor` 32 | 33 | 检查服务端配置和状态, 可以用来定位服务端启动失败的原因 34 | 35 | ## fixUsersAvatar 36 | 37 | `fiora fixUsersAvatar` 38 | 39 | 修复用户错误头像路径, 请根据你的实际情况修改脚本判断逻辑 40 | 41 | ## getUserId 42 | 43 | `fiora getUserId [username]` 44 | 45 | 获取指定用户名的 userId 46 | 47 | ## register 48 | 49 | `fiora register [username] [password]` 50 | 51 | 注册新用户, 当禁止注册时可以由管理员通过其注册新用户 52 | 53 | ## updateDefaultGroupName 54 | 55 | `fiora updateDefaultGroupName [newName]` 56 | 57 | 更新默认群组名 -------------------------------------------------------------------------------- /packages/docs/i18n/zh-Hans/docusaurus-theme-classic/footer.json: -------------------------------------------------------------------------------- 1 | { 2 | "link.title.Docs": { 3 | "message": "文档" 4 | }, 5 | "link.item.label.Overview": { 6 | "message": "首页" 7 | }, 8 | "link.item.label.Getting Start": { 9 | "message": "入门指南" 10 | }, 11 | "link.item.label.Change Log": { 12 | "message": "更新日志" 13 | }, 14 | 15 | "link.title.Community": { 16 | "message": "社区" 17 | }, 18 | "link.item.label.Feedback": { 19 | "message": "加入聊天群" 20 | }, 21 | "link.item.label.Issues": { 22 | "message": "Bug 反馈" 23 | }, 24 | 25 | "link.title.More": { 26 | "message": "更多" 27 | }, 28 | "link.item.label.Author": { 29 | "message": "关于作者" 30 | }, 31 | "link.item.label.GitHub": { 32 | "message": "GitHub" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/docs/i18n/zh-Hans/docusaurus-theme-classic/navbar.json: -------------------------------------------------------------------------------- 1 | { 2 | "item.label.Docs": { 3 | "message": "文档" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fiora/docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "dev:docs": "docusaurus start", 8 | "build:docs": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy:docs": "docusaurus deploy", 11 | "serve": "docusaurus serve", 12 | "clear": "docusaurus clear", 13 | "write-translations": "docusaurus write-translations" 14 | }, 15 | "dependencies": { 16 | "@docusaurus/core": "^2.0.0-alpha.71", 17 | "@docusaurus/preset-classic": "^2.0.0-alpha.71", 18 | "@mdx-js/react": "^1.6.21", 19 | "clsx": "^1.1.1", 20 | "react": "^16.8.4", 21 | "react-dom": "^16.8.4" 22 | }, 23 | "browserslist": { 24 | "production": [ 25 | ">0.5%", 26 | "not dead", 27 | "not op_mini all" 28 | ], 29 | "development": [ 30 | "last 1 chrome version", 31 | "last 1 firefox version", 32 | "last 1 safari version" 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/docs/sidebars.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | docs: { 3 | fiora: ['getting-start', 'install', 'config', 'script', 'faq', 'changelog'], 4 | 'fiora-app': ['app'], 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | /** 3 | * Any CSS included here will be global. The classic template 4 | * bundles Infima by default. Infima is a CSS framework designed to 5 | * work well for content-centric websites. 6 | */ 7 | 8 | /* You can override the default Infima variables here. */ 9 | :root { 10 | --ifm-color-primary: #25c2a0; 11 | --ifm-color-primary-dark: rgb(33, 175, 144); 12 | --ifm-color-primary-darker: rgb(31, 165, 136); 13 | --ifm-color-primary-darkest: rgb(26, 136, 112); 14 | --ifm-color-primary-light: rgb(70, 203, 174); 15 | --ifm-color-primary-lighter: rgb(102, 212, 189); 16 | --ifm-color-primary-lightest: rgb(146, 224, 208); 17 | --ifm-code-font-size: 95%; 18 | } 19 | 20 | .docusaurus-highlight-code-line { 21 | background-color: rgb(72, 77, 91); 22 | display: block; 23 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 24 | padding: 0 var(--ifm-pre-padding); 25 | } 26 | -------------------------------------------------------------------------------- /packages/docs/src/pages/styles.module.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | 3 | /** 4 | * CSS files with the .module.css suffix will be treated as CSS modules 5 | * and scoped locally. 6 | */ 7 | 8 | .heroBanner { 9 | padding: 4rem 0; 10 | text-align: center; 11 | position: relative; 12 | overflow: hidden; 13 | } 14 | 15 | @media screen and (max-width: 966px) { 16 | .heroBanner { 17 | padding: 2rem; 18 | } 19 | } 20 | 21 | .buttons { 22 | display: flex; 23 | align-items: center; 24 | justify-content: center; 25 | } 26 | 27 | .features { 28 | display: flex; 29 | align-items: center; 30 | padding: 2rem 0; 31 | width: 100%; 32 | } 33 | 34 | .featureImage { 35 | height: 120px; 36 | width: 200px; 37 | background-color: white; 38 | } 39 | 40 | .featureDescription { 41 | max-width: 300px; 42 | margin: 0 auto; 43 | } 44 | 45 | .starIframe { 46 | border: none; 47 | margin-bottom: 30px; 48 | } 49 | 50 | .descriptions { 51 | } 52 | 53 | .descriptionRow { 54 | width: 100%; 55 | margin: 0; 56 | } 57 | 58 | .description { 59 | width: 100%; 60 | min-height: 300px; 61 | } 62 | 63 | .descriptionContent { 64 | display: flex; 65 | align-items: center; 66 | max-width: var(--ifm-container-width); 67 | margin: 0 auto; 68 | padding: 0 var(--ifm-spacing-horizontal); 69 | width: 100%; 70 | height: 100%; 71 | } 72 | 73 | .descriptionImage { 74 | height: auto; 75 | width: 280px; 76 | } 77 | 78 | .lightBackground { 79 | background-color: #f6f6f6; 80 | } 81 | 82 | .rightImage { 83 | flex-direction: row-reverse; 84 | } 85 | 86 | .deployByYourself { 87 | width: 100%; 88 | min-height: 200px; 89 | display: flex; 90 | flex-direction: column; 91 | align-items: center; 92 | justify-content: center; 93 | } 94 | 95 | .deployTitle { 96 | color: var(--ifm-color-primary); 97 | } 98 | 99 | .heroButton { 100 | margin: 0 10px; 101 | } 102 | -------------------------------------------------------------------------------- /packages/docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/docs/static/.nojekyll -------------------------------------------------------------------------------- /packages/docs/static/img/android-download-qrcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/docs/static/img/android-download-qrcode.png -------------------------------------------------------------------------------- /packages/docs/static/img/cross-platform.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/docs/static/img/cross-platform.png -------------------------------------------------------------------------------- /packages/docs/static/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/docs/static/img/favicon.png -------------------------------------------------------------------------------- /packages/docs/static/img/open-source.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/docs/static/img/open-source.png -------------------------------------------------------------------------------- /packages/docs/static/img/screenshots/screenshot-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/docs/static/img/screenshots/screenshot-app.png -------------------------------------------------------------------------------- /packages/docs/static/img/screenshots/screenshot-pc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/docs/static/img/screenshots/screenshot-pc.png -------------------------------------------------------------------------------- /packages/docs/static/img/screenshots/screenshot-phone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/docs/static/img/screenshots/screenshot-phone.png -------------------------------------------------------------------------------- /packages/docs/static/img/website-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/docs/static/img/website-app.png -------------------------------------------------------------------------------- /packages/i18n/en-US/bin.ts: -------------------------------------------------------------------------------- 1 | export const getUserIdDescription = 'Get user id by username'; 2 | 3 | export const registerDescription = 'Register a new user'; 4 | 5 | export const deleteUserDescription = 'Delete a user'; 6 | 7 | export const fixUsersAvatarDescription = "Fix user's wrong avatar"; 8 | 9 | export const deleteTodayRegisteredUsersDescription = 10 | 'Delete all newly created users today'; 11 | 12 | export const deleteMessagesDescription = 'Delete all messages'; 13 | 14 | export const updateDefaultGroupNameDescription = 15 | 'Modify the name of the default group'; 16 | 17 | export const doctorDescription = 18 | 'Run doctor to diagnose environment and configuration issues'; 19 | -------------------------------------------------------------------------------- /packages/i18n/en-US/index.ts: -------------------------------------------------------------------------------- 1 | export * from './bin'; 2 | -------------------------------------------------------------------------------- /packages/i18n/node.index.ts: -------------------------------------------------------------------------------- 1 | import osLocale from 'os-locale'; 2 | 3 | import * as zhCN from './zh-CN'; 4 | import * as enUS from './en-US'; 5 | 6 | const languages = { 7 | 'zh-CN': zhCN, 8 | 'en-US': enUS, 9 | }; 10 | 11 | const locale = osLocale.sync() || 'en-US'; 12 | 13 | export default function i18n(key: keyof typeof enUS | keyof typeof zhCN) { 14 | // @ts-ignore 15 | return languages[locale][key] || enUS[key] || key; 16 | } 17 | -------------------------------------------------------------------------------- /packages/i18n/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fiora/i18n", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "dependencies": { 7 | "os-locale": "^5.0.0" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/i18n/zh-CN/bin.ts: -------------------------------------------------------------------------------- 1 | export const getUserIdDescription = '通过用户名获取 user id'; 2 | 3 | export const registerDescription = '注册新用户'; 4 | 5 | export const deleteUserDescription = '删除用户'; 6 | 7 | export const fixUsersAvatarDescription = '修复用户错误的头像'; 8 | 9 | export const deleteTodayRegisteredUsersDescription = '删除所有今天创建的新用户'; 10 | 11 | export const deleteMessagesDescription = '删除所有消息'; 12 | 13 | export const updateDefaultGroupNameDescription = '修改默认群组名称'; 14 | 15 | export const doctorDescription = '运行诊断工具检查环境和配置问题'; 16 | -------------------------------------------------------------------------------- /packages/i18n/zh-CN/index.ts: -------------------------------------------------------------------------------- 1 | export * from './bin'; 2 | -------------------------------------------------------------------------------- /packages/server/.nodemonrc: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "ignore": [ 4 | "test/**/*", 5 | "public/**/*" 6 | ] 7 | } -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fiora/server", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "scripts": { 7 | "start": "cross-env NODE_ENV=production DOTENV_CONFIG_PATH=../../.env ts-node -r dotenv/config --transpile-only src/main.ts", 8 | "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 ../" 9 | }, 10 | "dependencies": { 11 | "@fiora/bin": "^1.0.0", 12 | "@fiora/config": "^1.0.0", 13 | "@fiora/database": "^1.0.0", 14 | "@fiora/utils": "^1.0.0", 15 | "ali-oss": "^6.16.0", 16 | "axios": "^0.21.1", 17 | "bcryptjs": "^2.4.3", 18 | "expo-server-sdk": "^3.6.0", 19 | "jwt-simple": "^0.5.6", 20 | "koa": "^2.13.1", 21 | "koa-router": "^10.0.0", 22 | "koa-send": "^5.0.1", 23 | "koa-static": "^5.0.0", 24 | "regex-escape": "^3.4.10", 25 | "socket.io": "^4.1.3", 26 | "string-hash": "^1.1.3", 27 | "ts-jest": "^27.0.3" 28 | }, 29 | "devDependencies": { 30 | "@types/ali-oss": "^6.0.10", 31 | "@types/bcryptjs": "^2.4.2", 32 | "@types/koa": "^2.13.4", 33 | "@types/koa-router": "^7.4.4", 34 | "@types/koa-send": "^4.1.3", 35 | "@types/koa-static": "^4.0.2", 36 | "@types/string-hash": "^1.1.1", 37 | "cross-env": "^7.0.3", 38 | "dotenv": "^10.0.0", 39 | "nodemon": "^2.0.12" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/server/public/avatar/0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/server/public/avatar/0.jpg -------------------------------------------------------------------------------- /packages/server/public/avatar/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/server/public/avatar/1.jpg -------------------------------------------------------------------------------- /packages/server/public/avatar/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/server/public/avatar/10.jpg -------------------------------------------------------------------------------- /packages/server/public/avatar/11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/server/public/avatar/11.jpg -------------------------------------------------------------------------------- /packages/server/public/avatar/12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/server/public/avatar/12.jpg -------------------------------------------------------------------------------- /packages/server/public/avatar/13.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/server/public/avatar/13.jpg -------------------------------------------------------------------------------- /packages/server/public/avatar/14.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/server/public/avatar/14.jpg -------------------------------------------------------------------------------- /packages/server/public/avatar/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/server/public/avatar/2.jpg -------------------------------------------------------------------------------- /packages/server/public/avatar/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/server/public/avatar/3.jpg -------------------------------------------------------------------------------- /packages/server/public/avatar/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/server/public/avatar/4.jpg -------------------------------------------------------------------------------- /packages/server/public/avatar/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/server/public/avatar/5.jpg -------------------------------------------------------------------------------- /packages/server/public/avatar/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/server/public/avatar/6.jpg -------------------------------------------------------------------------------- /packages/server/public/avatar/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/server/public/avatar/7.jpg -------------------------------------------------------------------------------- /packages/server/public/avatar/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/server/public/avatar/8.jpg -------------------------------------------------------------------------------- /packages/server/public/avatar/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/server/public/avatar/9.jpg -------------------------------------------------------------------------------- /packages/server/public/favicon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/server/public/favicon-192.png -------------------------------------------------------------------------------- /packages/server/public/favicon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/server/public/favicon-512.png -------------------------------------------------------------------------------- /packages/server/public/favicon-96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinxin630/fiora/d741c006c5a0a5b904dec742ac09dbc51bd7860d/packages/server/public/favicon-96.png -------------------------------------------------------------------------------- /packages/server/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 默认首页 8 | 9 | 10 |

请执行 "yarn build:web" 构建前端页面

11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/server/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fiora", 3 | "short_name": "fiora", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "background_color": "#FAFAFA", 7 | "description": "一个神奇的聊天室", 8 | "orientation": "portrait-primary", 9 | "theme_color": "#4a90e2", 10 | "icons": [ 11 | { 12 | "src": "/favicon-96.png", 13 | "sizes": "96x96", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "/favicon-192.png", 18 | "sizes": "192x192", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "/favicon-512.png", 23 | "sizes": "512x512", 24 | "type": "image/png" 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /packages/server/src/main.ts: -------------------------------------------------------------------------------- 1 | import config from '@fiora/config/server'; 2 | import getRandomAvatar from '@fiora/utils/getRandomAvatar'; 3 | import { doctor } from '@fiora/bin/scripts/doctor'; 4 | import logger from '@fiora/utils/logger'; 5 | import initMongoDB from '@fiora/database/mongoose/initMongoDB'; 6 | import Socket from '@fiora/database/mongoose/models/socket'; 7 | import Group, { GroupDocument } from '@fiora/database/mongoose/models/group'; 8 | import app from './app'; 9 | 10 | (async () => { 11 | if (process.argv.find((argv) => argv === '--doctor')) { 12 | await doctor(); 13 | } 14 | 15 | await initMongoDB(); 16 | 17 | // 判断默认群是否存在, 不存在就创建一个 18 | const group = await Group.findOne({ isDefault: true }); 19 | if (!group) { 20 | const defaultGroup = await Group.create({ 21 | name: 'fiora', 22 | avatar: getRandomAvatar(), 23 | isDefault: true, 24 | } as GroupDocument); 25 | 26 | if (!defaultGroup) { 27 | logger.error('[defaultGroup]', 'create default group fail'); 28 | return process.exit(1); 29 | } 30 | } 31 | 32 | app.listen(config.port, async () => { 33 | await Socket.deleteMany({}); // 删除Socket表所有历史数据 34 | logger.info(`>>> server listen on http://localhost:${config.port}`); 35 | }); 36 | 37 | return null; 38 | })(); 39 | -------------------------------------------------------------------------------- /packages/server/src/middlewares/isAdmin.ts: -------------------------------------------------------------------------------- 1 | import config from '@fiora/config/server'; 2 | import { Socket } from 'socket.io'; 3 | 4 | export const YOU_ARE_NOT_ADMINISTRATOR = '你不是管理员'; 5 | 6 | /** 7 | * 拦截非管理员用户请求需要管理员权限的接口 8 | */ 9 | export default function isAdmin(socket: Socket) { 10 | const requireAdminEvent = new Set([ 11 | 'sealUser', 12 | 'getSealList', 13 | 'resetUserPassword', 14 | 'setUserTag', 15 | 'getUserIps', 16 | 'sealIp', 17 | 'getSealIpList', 18 | 'toggleSendMessage', 19 | 'toggleNewUserSendMessage', 20 | 'getSystemConfig', 21 | ]); 22 | return async ([event, , cb]: MiddlewareArgs, next: MiddlewareNext) => { 23 | socket.data.isAdmin = 24 | !!socket.data.user && 25 | config.administrator.includes(socket.data.user); 26 | const isAdminEvent = requireAdminEvent.has(event); 27 | if (!socket.data.isAdmin && isAdminEvent) { 28 | cb(YOU_ARE_NOT_ADMINISTRATOR); 29 | } else { 30 | next(); 31 | } 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /packages/server/src/middlewares/isLogin.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io'; 2 | 3 | export const PLEASE_LOGIN = '请登录后再试'; 4 | 5 | /** 6 | * 拦截未登录用户请求需要登录态的接口 7 | */ 8 | export default function isLogin(socket: Socket) { 9 | const noRequireLoginEvent = new Set([ 10 | 'register', 11 | 'login', 12 | 'loginByToken', 13 | 'guest', 14 | 'getDefaultGroupHistoryMessages', 15 | 'getDefaultGroupOnlineMembers', 16 | 'getBaiduToken', 17 | 'getGroupBasicInfo', 18 | 'getSTS', 19 | ]); 20 | return async ([event, , cb]: MiddlewareArgs, next: MiddlewareNext) => { 21 | if (!noRequireLoginEvent.has(event) && !socket.data.user) { 22 | cb(PLEASE_LOGIN); 23 | } else { 24 | next(); 25 | } 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /packages/server/src/middlewares/seal.ts: -------------------------------------------------------------------------------- 1 | import { SEAL_TEXT } from '@fiora/utils/const'; 2 | import { getSocketIp } from '@fiora/utils/socket'; 3 | import { Socket } from 'socket.io'; 4 | import { 5 | getSealIpKey, 6 | getSealUserKey, 7 | Redis, 8 | } from '@fiora/database/redis/initRedis'; 9 | 10 | /** 11 | * 拦截被封禁用户的请求 12 | */ 13 | export default function seal(socket: Socket) { 14 | return async ([, , cb]: MiddlewareArgs, next: MiddlewareNext) => { 15 | const ip = getSocketIp(socket); 16 | const isSealIp = await Redis.has(getSealIpKey(ip)); 17 | const isSealUser = 18 | socket.data.user && 19 | (await Redis.has(getSealUserKey(socket.data.user))); 20 | 21 | if (isSealUser || isSealIp) { 22 | cb(SEAL_TEXT); 23 | } else { 24 | next(); 25 | } 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /packages/server/src/routes/history.ts: -------------------------------------------------------------------------------- 1 | import { isValidObjectId, Types } from '@fiora/database/mongoose'; 2 | import assert from 'assert'; 3 | import User from '@fiora/database/mongoose/models/user'; 4 | import Group from '@fiora/database/mongoose/models/group'; 5 | import Message from '@fiora/database/mongoose/models/message'; 6 | import { createOrUpdateHistory } from '@fiora/database/mongoose/models/history'; 7 | 8 | export async function updateHistory( 9 | ctx: Context<{ userId: string; linkmanId: string; messageId: string }>, 10 | ) { 11 | const { linkmanId, messageId } = ctx.data; 12 | const self = ctx.socket.user.toString(); 13 | if (!Types.ObjectId.isValid(messageId)) { 14 | return { 15 | msg: `not update with invalid messageId:${messageId}`, 16 | }; 17 | } 18 | 19 | // @ts-ignore 20 | const [user, linkman, message] = await Promise.all([ 21 | User.findOne({ _id: self }), 22 | isValidObjectId(linkmanId) 23 | ? Group.findOne({ _id: linkmanId }) 24 | : User.findOne({ _id: linkmanId.replace(self, '') }), 25 | Message.findOne({ _id: messageId }), 26 | ]); 27 | assert(user, '用户不存在'); 28 | assert(linkman, '联系人不存在'); 29 | assert(message, '消息不存在'); 30 | 31 | await createOrUpdateHistory(self, linkmanId, messageId); 32 | 33 | return { 34 | msg: 'ok', 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /packages/server/src/routes/notification.ts: -------------------------------------------------------------------------------- 1 | import { AssertionError } from 'assert'; 2 | import User from '@fiora/database/mongoose/models/user'; 3 | import Notification from '@fiora/database/mongoose/models/notification'; 4 | 5 | export async function setNotificationToken(ctx: Context<{ token: string }>) { 6 | const { token } = ctx.data; 7 | 8 | const user = await User.findOne({ _id: ctx.socket.user }); 9 | if (!user) { 10 | throw new AssertionError({ message: '用户不存在' }); 11 | } 12 | 13 | const notification = await Notification.findOne({ token: ctx.data.token }); 14 | if (notification) { 15 | notification.user = user; 16 | await notification.save(); 17 | } else { 18 | await Notification.create({ 19 | user, 20 | token, 21 | }); 22 | 23 | const existNotifications = await Notification.find({ user }); 24 | if (existNotifications.length > 3) { 25 | await Notification.deleteOne({ _id: existNotifications[0]._id }); 26 | } 27 | } 28 | 29 | return { 30 | isOK: true, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /packages/server/src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'regex-escape'; 2 | -------------------------------------------------------------------------------- /packages/server/src/types/server.d.ts: -------------------------------------------------------------------------------- 1 | declare interface Context { 2 | data: T; 3 | socket: { 4 | id: string; 5 | ip: string; 6 | user: string; 7 | isAdmin: boolean; 8 | join: (room: string) => void; 9 | leave: (room: string) => void; 10 | emit: (target: string[] | string, event: string, data: any) => void; 11 | }; 12 | } 13 | 14 | declare interface RouteHandler { 15 | (ctx: Context): string | any; 16 | } 17 | 18 | declare type Routes = Record; 19 | 20 | declare type MiddlewareArgs = Array; 21 | 22 | declare type MiddlewareNext = () => void; 23 | 24 | declare interface SendMessageData { 25 | /** 消息目标 */ 26 | to: string; 27 | /** 消息类型 */ 28 | type: string; 29 | /** 消息内容 */ 30 | content: string; 31 | } 32 | -------------------------------------------------------------------------------- /packages/server/test/helpers/middleware.ts: -------------------------------------------------------------------------------- 1 | export function getMiddlewareParams(event = 'login', data = {}) { 2 | const cb = jest.fn(); 3 | const next = jest.fn(); 4 | 5 | return { 6 | args: [event, data, cb], 7 | cb, 8 | next, 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /packages/server/test/middlewares/isAdmin.spec.ts: -------------------------------------------------------------------------------- 1 | import { mocked } from 'ts-jest/utils'; 2 | import config from '@fiora/config/server'; 3 | import { Socket } from 'socket.io'; 4 | import isAdmin, { 5 | YOU_ARE_NOT_ADMINISTRATOR, 6 | } from '../../src/middlewares/isAdmin'; 7 | import { getMiddlewareParams } from '../helpers/middleware'; 8 | 9 | jest.mock('@fiora/config/server'); 10 | 11 | describe('server/middlewares/isAdmin', () => { 12 | it('should call service fail when user not administrator', async () => { 13 | const socket = { 14 | id: 'id', 15 | data: { 16 | user: 'user', 17 | }, 18 | } as Socket; 19 | const middleware = isAdmin(socket); 20 | 21 | const { args, cb, next } = getMiddlewareParams('sealUser'); 22 | 23 | await middleware(args, next); 24 | expect(cb).toBeCalledWith(YOU_ARE_NOT_ADMINISTRATOR); 25 | }); 26 | 27 | it('should call service success when user is administrator', async () => { 28 | mocked(config).administrator = ['administrator']; 29 | const socket = { 30 | id: 'id', 31 | data: { 32 | user: 'administrator', 33 | }, 34 | } as Socket; 35 | const middleware = isAdmin(socket); 36 | 37 | const { args, next } = getMiddlewareParams('sealUser'); 38 | 39 | await middleware(args, next); 40 | expect(next).toBeCalled(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /packages/server/test/middlewares/isLogin.spec.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io'; 2 | import isLogin, { PLEASE_LOGIN } from '../../src/middlewares/isLogin'; 3 | import { getMiddlewareParams } from '../helpers/middleware'; 4 | 5 | describe('server/middlewares/isLogin', () => { 6 | it('should call service fail when user not login', async () => { 7 | const socket = { 8 | id: 'id', 9 | data: {}, 10 | } as Socket; 11 | const middleware = isLogin(socket); 12 | 13 | const { args, cb, next } = getMiddlewareParams('sendMessage'); 14 | 15 | await middleware(args, next); 16 | expect(cb).toBeCalledWith(PLEASE_LOGIN); 17 | }); 18 | 19 | it('should call service success when user is login', async () => { 20 | const socket = { 21 | id: 'id', 22 | data: { 23 | user: 'user', 24 | }, 25 | } as Socket; 26 | const middleware = isLogin(socket); 27 | 28 | const { args, next } = getMiddlewareParams('sendMessage'); 29 | 30 | await middleware(args, next); 31 | expect(next).toBeCalled(); 32 | }); 33 | 34 | it('should call service success when it not need login ', async () => { 35 | const socket = { 36 | id: 'id', 37 | data: { 38 | user: 'user', 39 | }, 40 | } as Socket; 41 | const middleware = isLogin(socket); 42 | 43 | const { args, next } = getMiddlewareParams('register'); 44 | 45 | await middleware(args, next); 46 | expect(next).toBeCalled(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /packages/server/test/middlewares/seal.spec.ts: -------------------------------------------------------------------------------- 1 | import { mocked } from 'ts-jest/utils'; 2 | import { SEAL_TEXT } from '@fiora/utils/const'; 3 | import { Socket } from 'socket.io'; 4 | import { Redis } from '@fiora/database/redis/initRedis'; 5 | import seal from '../../src/middlewares/seal'; 6 | import { getMiddlewareParams } from '../helpers/middleware'; 7 | 8 | jest.mock('@fiora/database/redis/initRedis'); 9 | 10 | describe('server/middlewares/seal', () => { 11 | it('should call service success', async () => { 12 | const socket = ({ 13 | id: 'id', 14 | data: { 15 | user: 'user', 16 | }, 17 | handshake: { 18 | headers: { 19 | 'x-real-ip': '127.0.0.1', 20 | }, 21 | }, 22 | } as unknown) as Socket; 23 | const middleware = seal(socket); 24 | 25 | const { args, next } = getMiddlewareParams(); 26 | 27 | await middleware(args, next); 28 | expect(next).toBeCalled(); 29 | }); 30 | 31 | it('should call service fail when user has been sealed', async () => { 32 | mocked(Redis.has).mockReturnValue(Promise.resolve(true)); 33 | const socket = ({ 34 | id: 'id', 35 | data: { 36 | user: 'user', 37 | }, 38 | handshake: { 39 | headers: { 40 | 'x-real-ip': '127.0.0.1', 41 | }, 42 | }, 43 | } as unknown) as Socket; 44 | const middleware = seal(socket); 45 | 46 | const { args, cb, next } = getMiddlewareParams(); 47 | 48 | await middleware(args, next); 49 | expect(cb).toBeCalledWith(SEAL_TEXT); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | } -------------------------------------------------------------------------------- /packages/utils/compressImage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 压缩图片 3 | * @param image 要压缩的图片 4 | * @param mimeType mime类型 5 | * @param quality 质量 6 | */ 7 | export default function compressImage( 8 | image: HTMLImageElement, 9 | mimeType: string, 10 | quality = 1, 11 | ): Promise { 12 | return new Promise((resolve) => { 13 | const canvas = document.createElement('canvas'); 14 | canvas.width = image.width; 15 | canvas.height = image.height; 16 | 17 | const ctx = canvas.getContext('2d'); 18 | if (ctx) { 19 | ctx.drawImage(image, 0, 0); 20 | canvas.toBlob(resolve, mimeType, quality); 21 | } else { 22 | resolve(null); 23 | } 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /packages/utils/const.ts: -------------------------------------------------------------------------------- 1 | /** 封禁后提示文案 */ 2 | export const SEAL_TEXT = '你已经被关进小黑屋中, 请反思后再试'; 3 | 4 | /** 封禁用户释放时间 */ 5 | export const SEAL_USER_TIMEOUT = 1000 * 60 * 10; // 10分钟 6 | 7 | /** 封禁ip释放时间 */ 8 | export const SEAL_IP_TIMEOUT = 1000 * 60 * 60 * 6; // 6小时 9 | 10 | /** 透明图 */ 11 | export const TRANSPARENT_IMAGE = 12 | 'data:image/png;base64,R0lGODlhFAAUAIAAAP///wAAACH5BAEAAAAALAAAAAAUABQAAAIRhI+py+0Po5y02ouz3rz7rxUAOw=='; 13 | 14 | /** 加密salt位数 */ 15 | export const SALT_ROUNDS = 10; 16 | 17 | export const MB = 1024 * 1024; 18 | 19 | export const NAME_REGEXP = /^([0-9a-zA-Z]{1,2}|[\u4e00-\u9eff]|[\u3040-\u309Fー]|[\u30A0-\u30FF]){1,8}$/; 20 | -------------------------------------------------------------------------------- /packages/utils/convertMessage.ts: -------------------------------------------------------------------------------- 1 | import WuZeiNiangImage from '@fiora/assets/images/wuzeiniang.gif'; 2 | 3 | // function convertRobot10Message(message) { 4 | // if (message.from._id === '5adad39555703565e7903f79') { 5 | // try { 6 | // const parseMessage = JSON.parse(message.content); 7 | // message.from.tag = parseMessage.source; 8 | // message.from.avatar = parseMessage.avatar; 9 | // message.from.username = parseMessage.username; 10 | // message.type = parseMessage.type; 11 | // message.content = parseMessage.content; 12 | // } catch (err) { 13 | // console.warn('解析robot10消息失败', err); 14 | // } 15 | // } 16 | // } 17 | 18 | function convertSystemMessage(message: any) { 19 | if (message.type === 'system') { 20 | message.from._id = 'system'; 21 | message.from.originUsername = message.from.username; 22 | message.from.username = '乌贼娘殿下'; 23 | message.from.avatar = WuZeiNiangImage; 24 | message.from.tag = 'system'; 25 | 26 | const content = JSON.parse(message.content); 27 | switch (content.command) { 28 | case 'roll': { 29 | message.content = `掷出了${content.value}点 (上限${content.top}点)`; 30 | break; 31 | } 32 | case 'rps': { 33 | message.content = `使出了 ${content.value}`; 34 | break; 35 | } 36 | default: { 37 | message.content = '不支持的指令'; 38 | } 39 | } 40 | } else if (message.deleted) { 41 | message.type = 'system'; 42 | message.from._id = 'system'; 43 | message.from.originUsername = message.from.username; 44 | message.from.username = '乌贼娘殿下'; 45 | message.from.avatar = WuZeiNiangImage; 46 | message.from.tag = 'system'; 47 | message.content = `撤回了消息`; 48 | } 49 | } 50 | 51 | export default function convertMessage(message: any) { 52 | convertSystemMessage(message); 53 | return message; 54 | } 55 | -------------------------------------------------------------------------------- /packages/utils/expressions.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | default: [ 3 | '呵呵', 4 | '哈哈', 5 | '吐舌', 6 | '啊', 7 | '酷', 8 | '怒', 9 | '开心', 10 | '汗', 11 | '泪', 12 | '黑线', 13 | '鄙视', 14 | '不高兴', 15 | '真棒', 16 | '钱', 17 | '疑问', 18 | '阴险', 19 | '吐', 20 | '咦', 21 | '委屈', 22 | '花心', 23 | '呼', 24 | '笑眼', 25 | '冷', 26 | '太开心', 27 | '滑稽', 28 | '勉强', 29 | '狂汗', 30 | '乖', 31 | '睡觉', 32 | '惊哭', 33 | '升起', 34 | '惊讶', 35 | '喷', 36 | '爱心', 37 | '心碎', 38 | '玫瑰', 39 | '礼物', 40 | '星星月亮', 41 | '太阳', 42 | '音乐', 43 | '灯泡', 44 | '蛋糕', 45 | '彩虹', 46 | '钱币', 47 | '咖啡', 48 | 'haha', 49 | '胜利', 50 | '大拇指', 51 | '弱', 52 | 'ok', 53 | ], 54 | }; 55 | -------------------------------------------------------------------------------- /packages/utils/getFriendId.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Combina two users id as frind id 3 | * The result has nothing to do with the order of the parameters 4 | * @param userId1 user id 5 | * @param userId2 user id 6 | */ 7 | export default function getFriendId(userId1: string, userId2: string) { 8 | if (userId1 < userId2) { 9 | return userId1 + userId2; 10 | } 11 | return userId2 + userId1; 12 | } 13 | -------------------------------------------------------------------------------- /packages/utils/getRandomAvatar.ts: -------------------------------------------------------------------------------- 1 | const AvatarCount = 15; 2 | const publicPath = process.env.PublicPath || '/'; 3 | 4 | /** 5 | * 获取随机头像 6 | */ 7 | export default function getRandomAvatar() { 8 | const number = Math.floor(Math.random() * AvatarCount); 9 | return `${publicPath}avatar/${number}.jpg`; 10 | } 11 | 12 | /** 13 | * 获取默认头像 14 | */ 15 | export function getDefaultAvatar() { 16 | return `${publicPath}avatar/0.jpg`; 17 | } 18 | -------------------------------------------------------------------------------- /packages/utils/getRandomColor.ts: -------------------------------------------------------------------------------- 1 | import randomColor from 'randomcolor'; 2 | 3 | type ColorMode = 'dark' | 'bright' | 'light' | 'random'; 4 | 5 | /** 6 | * 获取随机颜色, 刷新页面不变 7 | * @param seed when passed will cause randomColor to return the same color each time 8 | */ 9 | export function getRandomColor(seed: string, luminosity: ColorMode = 'dark') { 10 | return randomColor({ 11 | luminosity, 12 | seed, 13 | }); 14 | } 15 | 16 | type Cache = { 17 | [key: string]: string; 18 | }; 19 | 20 | const cache: Cache = {}; 21 | 22 | /** 23 | * 获取随机颜色, 刷新页面后重新随机 24 | * @param seed 随机种子 25 | * @param luminosity 亮度 26 | */ 27 | export function getPerRandomColor( 28 | seed: string, 29 | luminosity: ColorMode = 'dark', 30 | ) { 31 | if (cache[seed]) { 32 | return cache[seed]; 33 | } 34 | cache[seed] = randomColor({ luminosity }); 35 | return cache[seed]; 36 | } 37 | -------------------------------------------------------------------------------- /packages/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { getLogger } from 'log4js'; 2 | 3 | const logger = getLogger(); 4 | logger.level = process.env.NODE_ENV === 'development' ? 'trace' : 'info'; 5 | 6 | export default logger; 7 | -------------------------------------------------------------------------------- /packages/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fiora/utils", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "dependencies": { 7 | "@fiora/assets": "^1.0.0", 8 | "ali-oss": "^6.16.0", 9 | "axios": "^0.21.1", 10 | "log4js": "^6.3.0", 11 | "randomcolor": "^0.6.2", 12 | "socket.io": "^4.1.3", 13 | "xss": "^1.0.9" 14 | }, 15 | "devDependencies": { 16 | "@types/randomcolor": "^0.5.6" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export default function sleep(duration = 200) { 2 | return new Promise((resolve) => { 3 | setTimeout(resolve, duration); 4 | }); 5 | } 6 | -------------------------------------------------------------------------------- /packages/utils/socket.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io'; 2 | 3 | export function getSocketIp(socket: Socket) { 4 | return ( 5 | (socket.handshake.headers['x-real-ip'] as string) || 6 | socket.request.connection.remoteAddress || 7 | '' 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /packages/utils/test/getFriendId.spec.ts: -------------------------------------------------------------------------------- 1 | import getFriendId from '../getFriendId'; 2 | 3 | describe('utils/getFriendId.ts', () => { 4 | it('should combina two users id as friend id', () => { 5 | const user1 = '111'; 6 | const user2 = '222'; 7 | expect(getFriendId(user1, user2)).toBe('111222'); 8 | expect(getFriendId(user2, user1)).toBe('111222'); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/utils/test/url.spec.ts: -------------------------------------------------------------------------------- 1 | import { addParam } from '../url'; 2 | 3 | describe('utils/url.ts', () => { 4 | it('should add ?key=value into url', () => { 5 | const url = 'https://fiora.suisuijiang.com'; 6 | const key = 'key'; 7 | const value = 'value'; 8 | const params = { 9 | [key]: value, 10 | }; 11 | expect(addParam(url, params)).toBe(`${url}?${key}=${value}`); 12 | }); 13 | 14 | it('should add &key=value into url', () => { 15 | const url = 'https://fiora.suisuijiang.com?a=a'; 16 | const key = 'key'; 17 | const value = 'value'; 18 | const params = { 19 | [key]: value, 20 | }; 21 | expect(addParam(url, params)).toBe(`${url}&${key}=${value}`); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/utils/time.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | isToday(time1: Date, time2: Date) { 3 | return ( 4 | time1.getFullYear() === time2.getFullYear() && 5 | time1.getMonth() === time2.getMonth() && 6 | time1.getDate() === time2.getDate() 7 | ); 8 | }, 9 | isYesterday(time1: Date, time2: Date) { 10 | const prevDate = new Date(time1); 11 | prevDate.setDate(time1.getDate() - 1); 12 | return ( 13 | prevDate.getFullYear() === time2.getFullYear() && 14 | prevDate.getMonth() === time2.getMonth() && 15 | prevDate.getDate() === time2.getDate() 16 | ); 17 | }, 18 | getHourMinute(time: Date) { 19 | const hours = time.getHours(); 20 | const minutes = time.getMinutes(); 21 | return `${hours < 10 ? `0${hours}` : hours}:${ 22 | minutes < 10 ? `0${minutes}` : minutes 23 | }`; 24 | }, 25 | getMonthDate(time: Date) { 26 | return `${time.getMonth() + 1}/${time.getDate()}`; 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /packages/utils/ua.ts: -------------------------------------------------------------------------------- 1 | const UA = window.navigator.userAgent; 2 | 3 | export const isiOS = /iPhone/i.test(UA); 4 | 5 | export const isAndroid = /android/i.test(UA); 6 | 7 | export const isMobile = isiOS || isAndroid; 8 | -------------------------------------------------------------------------------- /packages/utils/url.ts: -------------------------------------------------------------------------------- 1 | interface UrlParams { 2 | [key: string]: string; 3 | } 4 | 5 | // eslint-disable-next-line import/prefer-default-export 6 | export function addParam(url: string, params: UrlParams) { 7 | let result = url; 8 | Object.keys(params).forEach((key) => { 9 | if (result.indexOf('?') === -1) { 10 | result += `?${key}=${params[key]}`; 11 | } else { 12 | result += `&${key}=${params[key]}`; 13 | } 14 | }); 15 | return result; 16 | } 17 | -------------------------------------------------------------------------------- /packages/utils/xss.ts: -------------------------------------------------------------------------------- 1 | import xss from 'xss'; 2 | 3 | /** 4 | * xss防护 5 | * @param text 要处理的文字 6 | */ 7 | export default function processXss(text: string) { 8 | return xss(text); 9 | } 10 | -------------------------------------------------------------------------------- /packages/web/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": "> 0.25%, not dead", 7 | "useBuiltIns": "entry", 8 | "corejs": 3, 9 | "modules": false 10 | } 11 | ], 12 | "@babel/preset-react", 13 | "linaria/babel" 14 | ], 15 | "plugins": [ 16 | [ 17 | "prismjs", 18 | { 19 | "languages": [ 20 | "clike", 21 | "javascript", 22 | "typescript", 23 | "java", 24 | "c", 25 | "cpp", 26 | "python", 27 | "ruby", 28 | "markup", 29 | "markup-templating", 30 | "php", 31 | "go", 32 | "csharp", 33 | "css", 34 | "sql", 35 | "json" 36 | ], 37 | "plugins": ["line-numbers", "copy-to-clipboard", "show-language"], 38 | "theme": "default", 39 | "css": true 40 | } 41 | ] 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /packages/web/build/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { merge } = require('webpack-merge'); 3 | const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); 4 | const common = require('./webpack.common.js'); 5 | 6 | module.exports = merge(common, { 7 | mode: 'development', 8 | output: { 9 | publicPath: '/', 10 | }, 11 | devtool: 'inline-source-map', 12 | devServer: { 13 | hot: true, 14 | contentBase: ['./dist'], 15 | historyApiFallback: { 16 | rewrites: [{ from: /\/invite\/group\/[\w\d]+/, to: '/index.html' }], 17 | }, 18 | proxy: { 19 | '/avatar': 'http://localhost:9200', 20 | '/GroupAvatar': 'http://localhost:9200', 21 | '/Avatar': { 22 | target: 'http://localhost:9200', 23 | pathRewrite: { '^/Avatar': '/avatar' }, 24 | }, 25 | '/favicon-*.png': 'http://localhost:9200', 26 | }, 27 | }, 28 | plugins: [new ReactRefreshWebpackPlugin()], 29 | }); 30 | -------------------------------------------------------------------------------- /packages/web/build/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const TerserPlugin = require('terser-webpack-plugin'); 3 | const ScriptExtHtmlPlugin = require('script-ext-html-webpack-plugin'); 4 | const WorkboxPlugin = require('workbox-webpack-plugin'); 5 | const WebpackBar = require('webpackbar'); 6 | const common = require('./webpack.common.js'); 7 | 8 | module.exports = merge(common, { 9 | mode: 'production', 10 | output: { 11 | publicPath: process.env.PublicPath || '/', 12 | }, 13 | devtool: false, 14 | optimization: { 15 | minimize: true, 16 | minimizer: [ 17 | new TerserPlugin({ 18 | terserOptions: { 19 | format: { 20 | comments: false, 21 | }, 22 | }, 23 | extractComments: false, 24 | }), 25 | ], 26 | }, 27 | plugins: [ 28 | new ScriptExtHtmlPlugin({ 29 | custom: [ 30 | { 31 | test: /\.js$/, 32 | attribute: 'crossorigin', 33 | value: 'anonymous', 34 | }, 35 | ], 36 | }), 37 | new WorkboxPlugin.GenerateSW({ 38 | clientsClaim: true, 39 | skipWaiting: true, 40 | }), 41 | new WebpackBar(), 42 | ], 43 | }); 44 | -------------------------------------------------------------------------------- /packages/web/src/App.less: -------------------------------------------------------------------------------- 1 | @import "./styles/variable.less"; 2 | 3 | .app { 4 | width: 100%; 5 | height: 100%; 6 | overflow: hidden; 7 | } 8 | 9 | .blur, .child { 10 | position: absolute; 11 | } 12 | 13 | .blur { 14 | filter: blur(10px); 15 | } 16 | 17 | .child { 18 | display: flex; 19 | border-radius: 10px; 20 | box-shadow: 0px 0px 60px rgba(0, 0, 0, 0.5); 21 | 22 | @media @mobile { 23 | border-radius: 0; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/web/src/components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import React, { SyntheticEvent, useState, useMemo } from 'react'; 2 | import { getOSSFileUrl } from '../utils/uploadFile'; 3 | 4 | export const avatarFailback = '/avatar/0.jpg'; 5 | 6 | type Props = { 7 | /** 头像链接 */ 8 | src: string; 9 | /** 展示大小 */ 10 | size?: number; 11 | /** 额外类名 */ 12 | className?: string; 13 | /** 点击事件 */ 14 | onClick?: () => void; 15 | onMouseEnter?: () => void; 16 | onMouseLeave?: () => void; 17 | }; 18 | 19 | function Avatar({ 20 | src, 21 | size = 60, 22 | className = '', 23 | onClick, 24 | onMouseEnter, 25 | onMouseLeave, 26 | }: Props) { 27 | const [failTimes, updateFailTimes] = useState(0); 28 | 29 | /** 30 | * Handle avatar load fail event. Use faillback avatar instead 31 | * If still fail then ignore error event 32 | */ 33 | function handleError(e: SyntheticEvent) { 34 | if (failTimes >= 2) { 35 | return; 36 | } 37 | e.currentTarget.src = avatarFailback; 38 | updateFailTimes(failTimes + 1); 39 | } 40 | 41 | const url = useMemo(() => { 42 | if (/^(blob|data):/.test(src)) { 43 | return src; 44 | } 45 | return getOSSFileUrl( 46 | src, 47 | `image/resize,w_${size * 2},h_${size * 2}/quality,q_90`, 48 | ); 49 | }, [src]); 50 | 51 | return ( 52 | 62 | ); 63 | } 64 | 65 | export default Avatar; 66 | -------------------------------------------------------------------------------- /packages/web/src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { css } from 'linaria'; 4 | 5 | const button = css` 6 | border: none; 7 | background-color: var(--primary-color-8_5); 8 | color: var(--primary-text-color-10); 9 | border-radius: 4px; 10 | font-size: 14px; 11 | transition: background-color 0.4s; 12 | user-select: none !important; 13 | 14 | &:hover { 15 | background-color: var(--primary-color-10); 16 | } 17 | `; 18 | 19 | type Props = { 20 | /** 类型: primary / danger */ 21 | type?: string; 22 | /** 按钮文本 */ 23 | children: string; 24 | className?: string; 25 | /** 点击事件 */ 26 | onClick?: () => void; 27 | }; 28 | 29 | function Button({ 30 | type = 'primary', 31 | children, 32 | className = '', 33 | onClick, 34 | }: Props) { 35 | return ( 36 | 43 | ); 44 | } 45 | 46 | export default Button; 47 | -------------------------------------------------------------------------------- /packages/web/src/components/Dialog.less: -------------------------------------------------------------------------------- 1 | @import '../styles/variable.less'; 2 | 3 | :global { 4 | .rc-dialog { 5 | width: 450px; 6 | top: 45% !important; 7 | transform: translateY(-50%) !important; 8 | } 9 | .rc-dialog-content { 10 | width: 100%; 11 | } 12 | .rc-dialog-title { 13 | font-size: 16px !important; 14 | } 15 | .rc-dialog-wrap { 16 | overflow: hidden !important; 17 | } 18 | .rc-dialog-close { 19 | top: 0 !important; 20 | right: 10px !important; 21 | z-index: 9999; 22 | 23 | .rc-dialog-close-x { 24 | font-size: 32px !important; 25 | } 26 | } 27 | 28 | .rc-dialog-body { 29 | overflow-y: auto; 30 | -webkit-overflow-scrolling: touch; 31 | max-height: 60vh; 32 | } 33 | 34 | @media @mobile { 35 | .rc-dialog { 36 | width: 94% !important; 37 | margin: 0 auto; 38 | } 39 | .rc-dialog-body { 40 | padding: 10px 10px; 41 | // max-height: 88%; 42 | } 43 | .rc-dialog-content { 44 | max-height: 80vh; 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /packages/web/src/components/Dialog.tsx: -------------------------------------------------------------------------------- 1 | import Dialog from 'rc-dialog'; 2 | import 'rc-dialog/assets/index.css'; 3 | 4 | import './Dialog.less'; 5 | 6 | export default Dialog; 7 | -------------------------------------------------------------------------------- /packages/web/src/components/Dropdown.less: -------------------------------------------------------------------------------- 1 | :global { 2 | .rc-dropdown { 3 | max-width: 100%; 4 | } 5 | 6 | .rc-select-dropdown { 7 | z-index: 1500 !important; 8 | } 9 | } -------------------------------------------------------------------------------- /packages/web/src/components/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import Dropdown from 'rc-dropdown'; 2 | import 'rc-dropdown/assets/index.css'; 3 | 4 | import './Dropdown.less'; 5 | 6 | export default Dropdown; 7 | -------------------------------------------------------------------------------- /packages/web/src/components/IconButton.less: -------------------------------------------------------------------------------- 1 | .iconButton { 2 | text-align: center; 3 | color: rgba(165, 181, 192, 1); 4 | cursor: pointer; 5 | 6 | &:hover { 7 | color: rgba(247, 247, 247, 1); 8 | } 9 | } -------------------------------------------------------------------------------- /packages/web/src/components/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Style from './IconButton.less'; 4 | 5 | type Props = { 6 | width: number; 7 | height: number; 8 | icon: string; 9 | iconSize: number; 10 | className?: string; 11 | style?: Object; 12 | onClick?: () => void; 13 | }; 14 | 15 | function IconButton({ 16 | width, 17 | height, 18 | icon, 19 | iconSize, 20 | onClick = () => {}, 21 | className = '', 22 | style = {}, 23 | }: Props) { 24 | return ( 25 |
31 | 35 |
36 | ); 37 | } 38 | 39 | export default IconButton; 40 | -------------------------------------------------------------------------------- /packages/web/src/components/Input.less: -------------------------------------------------------------------------------- 1 | .inputContainer { 2 | position: relative; 3 | } 4 | 5 | .input { 6 | width: 100%; 7 | height: 100%; 8 | border-radius: 6px; 9 | border: 1px solid rgba(0, 0, 0, 0.2); 10 | padding: 0 34px 0 8px; 11 | font-size: 14px; 12 | color: #333; 13 | box-sizing: border-box; 14 | user-select: auto; 15 | 16 | &:focus { 17 | border-color: var(--primary-color-10); 18 | } 19 | } 20 | 21 | .inputIconButton { 22 | position: absolute; 23 | top: 0; 24 | bottom: 0; 25 | margin: auto; 26 | right: 5px; 27 | 28 | &:hover { 29 | color: var(--primary-color-10); 30 | } 31 | } -------------------------------------------------------------------------------- /packages/web/src/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import Loading from 'react-loading'; 2 | 3 | export default Loading; 4 | -------------------------------------------------------------------------------- /packages/web/src/components/Menu.tsx: -------------------------------------------------------------------------------- 1 | import Menu, { SubMenu, MenuItem } from 'rc-menu'; 2 | import 'rc-menu/assets/index.css'; 3 | 4 | export { Menu, MenuItem, SubMenu }; 5 | -------------------------------------------------------------------------------- /packages/web/src/components/Message.less: -------------------------------------------------------------------------------- 1 | :global { 2 | .rc-notification { 3 | top: 5px !important; 4 | z-index: 1100 !important; 5 | } 6 | } 7 | 8 | .componentMessage { 9 | height: 100%; 10 | display: flex; 11 | align-items: center; 12 | margin-top: 1px; 13 | 14 | :global { 15 | .iconfont { 16 | font-size: 22px; 17 | 18 | &.icon-success { 19 | color: green; 20 | } 21 | &.icon-error { 22 | color: #d82e2e; 23 | } 24 | &.icon-warning { 25 | color: orange; 26 | } 27 | &.icon-info { 28 | color: #2773ef; 29 | } 30 | } 31 | } 32 | } 33 | 34 | .messageText { 35 | margin-left: 8px; 36 | font-size: 16px; 37 | } -------------------------------------------------------------------------------- /packages/web/src/components/Message.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Notification from 'rc-notification'; 3 | 4 | import 'rc-notification/dist/rc-notification.min.css'; 5 | import Style from './Message.less'; 6 | 7 | function showMessage(text: string, duration = 1500, type = 'success') { 8 | Notification.newInstance({}, (notification: any) => { 9 | notification.notice({ 10 | content: ( 11 |
12 | 13 | {text} 14 |
15 | ), 16 | duration, 17 | }); 18 | }); 19 | } 20 | 21 | export default { 22 | success(text: string, duration = 1.5) { 23 | showMessage(text, duration, 'success'); 24 | }, 25 | error(text: string, duration = 1.5) { 26 | showMessage(text, duration, 'error'); 27 | }, 28 | warning(text: string, duration = 1.5) { 29 | showMessage(text, duration, 'warning'); 30 | }, 31 | info(text: string, duration = 1.5) { 32 | showMessage(text, duration, 'info'); 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /packages/web/src/components/Progress.tsx: -------------------------------------------------------------------------------- 1 | import { Line as LineProgress, Circle as CircleProgress } from 'rc-progress'; 2 | import 'rc-progress/assets/index.css'; 3 | 4 | export { LineProgress, CircleProgress }; 5 | -------------------------------------------------------------------------------- /packages/web/src/components/Select.tsx: -------------------------------------------------------------------------------- 1 | import Select, { Option, OptGroup } from 'rc-select'; 2 | import 'rc-select/assets/index.css'; 3 | 4 | export { Select, Option, OptGroup }; 5 | -------------------------------------------------------------------------------- /packages/web/src/components/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import Tabs, { TabPane } from 'rc-tabs'; 2 | import TabContent from 'rc-tabs/lib/TabContent'; 3 | import ScrollableInkTabBar from 'rc-tabs/lib/ScrollableInkTabBar'; 4 | import 'rc-tabs/assets/index.css'; 5 | 6 | export { Tabs, TabPane, TabContent, ScrollableInkTabBar }; 7 | -------------------------------------------------------------------------------- /packages/web/src/components/Tooltip.less: -------------------------------------------------------------------------------- 1 | :global { 2 | .rc-tooltip { 3 | display: inline-block !important; 4 | } 5 | .rc-tooltip-hidden { 6 | display: none !important; 7 | } 8 | .rc-tooltip-inner { 9 | span { 10 | color: #f1f1f1 !important; 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /packages/web/src/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import Tooltip from 'rc-tooltip'; 2 | import 'rc-tooltip/assets/bootstrap.css'; 3 | 4 | import './Tooltip.less'; 5 | 6 | export default Tooltip; 7 | -------------------------------------------------------------------------------- /packages/web/src/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | // eslint-disable-next-line import/prefer-default-export 4 | export const ShowUserOrGroupInfoContext = createContext(null); 5 | -------------------------------------------------------------------------------- /packages/web/src/globalStyles.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { css } from 'linaria'; 3 | 4 | const globalStyles = css` 5 | :global() { 6 | .danger { 7 | background-color: #dd514c !important; 8 | 9 | &:hover { 10 | background-color: #d7342e !important; 11 | } 12 | } 13 | } 14 | `; 15 | 16 | export default globalStyles; 17 | -------------------------------------------------------------------------------- /packages/web/src/hooks/useAero.ts: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux'; 2 | import { State } from '../state/reducer'; 3 | 4 | /** 5 | * 获取毛玻璃状态属性 6 | */ 7 | export default function useAero() { 8 | const aero = useSelector((state: State) => state.status.aero); 9 | return { 10 | 'data-aero': aero, 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /packages/web/src/hooks/useIsLogin.ts: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux'; 2 | import { State } from '../state/reducer'; 3 | 4 | /** 5 | * 获取登录态 6 | */ 7 | export default function useIsLogin() { 8 | const isLogin = useSelector( 9 | (state: State) => state.user && state.user._id !== '', 10 | ); 11 | return isLogin; 12 | } 13 | -------------------------------------------------------------------------------- /packages/web/src/hooks/useStore.ts: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux'; 2 | import { State, Linkman } from '../state/reducer'; 3 | 4 | export function useStore() { 5 | return useSelector((state: State) => state); 6 | } 7 | 8 | export function useFocusLinkman(): Linkman | null { 9 | const store = useStore(); 10 | const { focus } = store; 11 | if (focus) { 12 | return store.linkmans?.[focus]; 13 | } 14 | return null; 15 | } 16 | 17 | export function useSelfId() { 18 | const store = useStore(); 19 | return store.user?._id || ''; 20 | } 21 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Chat.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/variable.less'; 2 | 3 | .chat { 4 | flex: 1; 5 | display: flex; 6 | flex-direction: column; 7 | background-color: rgba(241, 241, 241, 0.6); 8 | border-top-right-radius: 10px; 9 | border-bottom-right-radius: 10px; 10 | overflow: hidden; 11 | position: relative; 12 | 13 | &[data-aero=true] { 14 | background-color: rgba(241, 241, 241, 0.15); 15 | } 16 | 17 | @media @mobile { 18 | border-top-right-radius: 0; 19 | border-bottom-right-radius: 0; 20 | } 21 | } 22 | 23 | .noLinkman { 24 | flex: 1; 25 | display: flex; 26 | align-items: center; 27 | justify-content: center; 28 | flex-direction: column; 29 | } 30 | 31 | .noLinkmanImage { 32 | border-radius: 8px; 33 | width: 170px; 34 | height: 180px; 35 | background-image: url('~@fiora/assets/images/no-linkman.jpeg'); 36 | background-position-y: 180px; 37 | } 38 | 39 | .noLinkmanText { 40 | margin-top: 16px; 41 | font-size: 14px; 42 | color: #666; 43 | } 44 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/CodeEditor.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/variable.less'; 2 | 3 | .codeEditor { 4 | width: 600px; 5 | 6 | @media @mobile { 7 | width: 100%; 8 | height: 410px; 9 | } 10 | 11 | :global { 12 | .rc-dialog-body { 13 | overflow-y: hidden; 14 | } 15 | } 16 | } 17 | 18 | .container { 19 | height: 55vh; 20 | display: flex; 21 | flex-direction: column; 22 | } 23 | 24 | .selectContainer { 25 | display: flex; 26 | align-items: center; 27 | } 28 | 29 | .languageSelect { 30 | width: 150px; 31 | margin-left: 10px; 32 | } 33 | 34 | .title { 35 | font-size: 14px; 36 | font-weight: normal; 37 | color: #666; 38 | } 39 | 40 | .editorContainer { 41 | flex: 1; 42 | margin-top: 10px; 43 | border: 1px solid #f1f1f1; 44 | border-radius: 2px; 45 | } 46 | 47 | .sendButton { 48 | width: 100px; 49 | height: 32px; 50 | margin-top: 8px; 51 | align-self: flex-end; 52 | } -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/HeaderBar.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/variable.less'; 2 | 3 | .headerBar { 4 | height: 70px; 5 | border-bottom: 1px solid rgba(208, 208, 208, 0.6); 6 | display: flex; 7 | align-items: center; 8 | padding: 0px 18px; 9 | justify-content: space-between; 10 | position: relative; 11 | 12 | &[data-aero=true] { 13 | border-bottom: 1px solid rgba(208, 208, 208, 0.3); 14 | } 15 | 16 | @media @mobile { 17 | height: 50px; 18 | padding: 0 6px; 19 | } 20 | 21 | :global { 22 | .iconfont { 23 | color: var(--primary-color-10); 24 | &:hover { 25 | color: var(--primary-color-8); 26 | } 27 | } 28 | 29 | .online, .offline { 30 | display: inline-block; 31 | width: 10px; 32 | height: 10px; 33 | border-radius: 50%; 34 | margin-right: 4px; 35 | transform: translateY(1px); 36 | } 37 | } 38 | } 39 | 40 | .buttonContainer { 41 | display: flex; 42 | width: 80px; 43 | } 44 | 45 | .rightButtonContainer { 46 | justify-content: flex-end; 47 | } 48 | 49 | .name { 50 | font-size: 16px; 51 | color: #333; 52 | 53 | @media @mobile { 54 | font-size: 14px; 55 | height: 100%; 56 | display: flex; 57 | flex-direction: column; 58 | align-items: center; 59 | justify-content: center; 60 | flex: 1; 61 | transform: translateY(2px); 62 | } 63 | } 64 | 65 | .status { 66 | color: #999; 67 | font-size: 12px; 68 | transform: scale(0.6); 69 | } 70 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Message/CodeDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Prism from 'prismjs'; 3 | 4 | import xss from '@fiora/utils/xss'; 5 | import Style from './CodeMessage.less'; 6 | import Dialog from '../../../components/Dialog'; 7 | 8 | interface CodeDialogProps { 9 | visible: boolean; 10 | onClose: () => void; 11 | language: string; 12 | code: string; 13 | } 14 | 15 | function CodeDialog(props: CodeDialogProps) { 16 | const { visible, onClose, language, code } = props; 17 | const html = 18 | language === 'text' 19 | ? xss(code) 20 | : // @ts-ignore 21 | Prism.highlight(code, Prism.languages[language]); 22 | setTimeout(Prism.highlightAll.bind(Prism), 0); // TODO: https://github.com/PrismJS/prism/issues/1487 23 | 24 | return ( 25 | 31 |
32 |                 
37 |             
38 |
39 | ); 40 | } 41 | 42 | export default CodeDialog; 43 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Message/FileMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { css } from 'linaria'; 3 | import filesize from 'filesize'; 4 | import { getOSSFileUrl } from '../../../utils/uploadFile'; 5 | 6 | const styles = { 7 | container: css` 8 | display: block; 9 | min-width: 160px; 10 | max-width: 240px; 11 | padding: 0 4px; 12 | text-align: center; 13 | cursor: pointer; 14 | color: var(--primary-text-color-10); 15 | text-decoration: none; 16 | `, 17 | fileInfo: css` 18 | display: flex; 19 | flex-direction: column; 20 | align-items: center; 21 | justify-content: center; 22 | border-bottom: 1px solid #eee; 23 | `, 24 | fileInfoText: css` 25 | word-break: break-all; 26 | `, 27 | button: css` 28 | display: inline-block; 29 | font-size: 12px; 30 | text-align: center; 31 | margin-top: 6px; 32 | `, 33 | }; 34 | 35 | type Props = { 36 | file: string; 37 | percent: number; 38 | }; 39 | 40 | function FileMessage({ file, percent }: Props) { 41 | const { fileUrl, filename, size } = JSON.parse(file); 42 | const url = fileUrl && getOSSFileUrl(fileUrl); 43 | 44 | return ( 45 | 51 |
52 | {filename} 53 | {filesize(size)} 54 |
55 |

56 | {percent === undefined || percent >= 100 57 | ? '下载' 58 | : `上传中... ${percent.toFixed(0)}%`} 59 |

60 |
61 | ); 62 | } 63 | 64 | export default React.memo(FileMessage); 65 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Message/InviteMessage.less: -------------------------------------------------------------------------------- 1 | .inviteMessage { 2 | width: 160px; 3 | padding: 0 4px; 4 | text-align: center; 5 | cursor: pointer; 6 | color: var(--primary-text-color-10); 7 | } 8 | 9 | .info { 10 | display: flex; 11 | border-bottom: 1px solid #eee; 12 | align-items: center; 13 | } 14 | 15 | .infoText { 16 | line-height: 18px; 17 | } 18 | 19 | .join { 20 | display: inline-block; 21 | font-size: 12px; 22 | text-align: center; 23 | margin-top: 6px; 24 | } -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Message/InviteMessageV2.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Style from './InviteMessage.less'; 4 | import { joinGroup, getLinkmanHistoryMessages } from '../../../service'; 5 | import useAction from '../../../hooks/useAction'; 6 | import Message from '../../../components/Message'; 7 | 8 | interface InviteMessageProps { 9 | inviteInfo: string; 10 | } 11 | 12 | function InviteMessage(props: InviteMessageProps) { 13 | const { inviteInfo } = props; 14 | const invite = JSON.parse(inviteInfo); 15 | 16 | const action = useAction(); 17 | 18 | async function handleJoinGroup() { 19 | const group = await joinGroup(invite.group); 20 | if (group) { 21 | group.type = 'group'; 22 | action.addLinkman(group, true); 23 | Message.success('加入群组成功'); 24 | const messages = await getLinkmanHistoryMessages(invite.group, 0); 25 | if (messages) { 26 | action.addLinkmanHistoryMessages(invite.group, messages); 27 | } 28 | } 29 | } 30 | 31 | return ( 32 |
37 |
38 | 39 | "{invite.inviterName}" 邀请你加入群组「 40 | {invite.groupName}」 41 | 42 |
43 |

加入

44 |
45 | ); 46 | } 47 | 48 | export default InviteMessage; 49 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Message/SystemMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { getPerRandomColor } from '@fiora/utils/getRandomColor'; 3 | 4 | interface SystemMessageProps { 5 | message: string; 6 | username: string; 7 | } 8 | 9 | function SystemMessage(props: SystemMessageProps) { 10 | const { message, username } = props; 11 | return ( 12 |
13 | 14 | {username} 15 | 16 |   17 | {message} 18 |
19 | ); 20 | } 21 | 22 | export default SystemMessage; 23 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Message/TextMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import expressions from '@fiora/utils/expressions'; 4 | import { TRANSPARENT_IMAGE } from '@fiora/utils/const'; 5 | import Style from './Message.less'; 6 | 7 | interface TextMessageProps { 8 | content: string; 9 | } 10 | 11 | function TextMessage(props: TextMessageProps) { 12 | // eslint-disable-next-line react/destructuring-assignment 13 | const content = props.content 14 | .replace( 15 | /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}(\.[a-z]{2,6})?\b(:[0-9]{2,5})?([-a-zA-Z0-9@:%_+.~#?&//=]*)/g, 16 | (r) => 17 | `${r}`, 18 | ) 19 | .replace(/#\(([\u4e00-\u9fa5a-z]+)\)/g, (r, e) => { 20 | const index = expressions.default.indexOf(e); 21 | if (index !== -1) { 22 | return `${r}`; 26 | } 27 | return r; 28 | }); 29 | 30 | return ( 31 |
36 | ); 37 | } 38 | 39 | export default TextMessage; 40 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/Message/UrlMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface UrlMessageProps { 4 | url: string; 5 | } 6 | 7 | function UrlMessage(props: UrlMessageProps) { 8 | const { url } = props; 9 | return ( 10 | 11 | {url} 12 | 13 | ); 14 | } 15 | 16 | export default UrlMessage; 17 | -------------------------------------------------------------------------------- /packages/web/src/modules/Chat/MessageList.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/variable.less'; 2 | 3 | .messageList { 4 | width: 100%; 5 | height: 100%; 6 | padding: 8px 10px 0 10px; 7 | overflow-y: auto; 8 | overflow-x: hidden; 9 | -webkit-overflow-scrolling: touch; 10 | position: relative; 11 | 12 | @media @mobile { 13 | padding: 8px 6px 0 6px; 14 | } 15 | } -------------------------------------------------------------------------------- /packages/web/src/modules/FunctionBarAndLinkmanList/CreateGroup.less: -------------------------------------------------------------------------------- 1 | .createGroup { 2 | 3 | } 4 | 5 | .container { 6 | display: flex; 7 | flex-direction: column; 8 | } 9 | 10 | .text { 11 | font-size: 14px; 12 | font-weight:normal; 13 | line-height: 31px; 14 | color: #333; 15 | } 16 | 17 | .input { 18 | height: 40px; 19 | } 20 | 21 | .button { 22 | width: 66px; 23 | height: 36px; 24 | border-radius: 6px; 25 | margin-top: 12px; 26 | background-color: var(--primary-color-10); 27 | color: var(--primary-text-color-10); 28 | align-self: flex-end; 29 | font-size: 14px; 30 | border: none; 31 | 32 | &:hover { 33 | background-color: var(--primary-color-8); 34 | } 35 | } -------------------------------------------------------------------------------- /packages/web/src/modules/FunctionBarAndLinkmanList/CreateGroup.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import Style from './CreateGroup.less'; 4 | import Dialog from '../../components/Dialog'; 5 | import Input from '../../components/Input'; 6 | import Message from '../../components/Message'; 7 | import { createGroup } from '../../service'; 8 | import useAction from '../../hooks/useAction'; 9 | 10 | interface CreateGroupProps { 11 | visible: boolean; 12 | onClose: () => void; 13 | } 14 | 15 | function CreateGroup(props: CreateGroupProps) { 16 | const { visible, onClose } = props; 17 | const action = useAction(); 18 | const [groupName, setGroupName] = useState(''); 19 | 20 | async function handleCreateGroup() { 21 | const group = await createGroup(groupName); 22 | if (group) { 23 | group.type = 'group'; 24 | action.addLinkman(group, true); 25 | setGroupName(''); 26 | onClose(); 27 | Message.success('创建群组成功'); 28 | } 29 | } 30 | 31 | return ( 32 | 33 |
34 |

请输入群组名

35 | 40 | 47 |
48 |
49 | ); 50 | } 51 | 52 | export default CreateGroup; 53 | -------------------------------------------------------------------------------- /packages/web/src/modules/FunctionBarAndLinkmanList/FunctionBarAndLinkmanList.less: -------------------------------------------------------------------------------- 1 | @import "../../styles/variable.less"; 2 | 3 | .functionBarAndLinkmanList { 4 | width: 300px; 5 | height: 100%; 6 | position: relative; 7 | display: flex; 8 | background-color: unset; 9 | 10 | @media @mobile { 11 | position: absolute; 12 | left: 0; 13 | z-index: 1; 14 | width: 100%; 15 | background-color: rgba(37, 37, 37, 0.5); 16 | 17 | .container { 18 | background-color: var(--primary-color-7); 19 | } 20 | } 21 | } 22 | 23 | .container { 24 | background-color: var(--primary-color-5); 25 | flex: 1; 26 | max-width: 300px; 27 | display: flex; 28 | flex-direction: column; 29 | 30 | &[data-aero=true] { 31 | background-color: var(--primary-color-0); 32 | } 33 | 34 | @media @mobile { 35 | background-color: var(--primary-color-7); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/web/src/modules/FunctionBarAndLinkmanList/FunctionBarAndLinkmanList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import useIsLogin from '../../hooks/useIsLogin'; 5 | import useAction from '../../hooks/useAction'; 6 | import FunctionBar from './FunctionBar'; 7 | import LinkmanList from './LinkmanList'; 8 | 9 | import Style from './FunctionBarAndLinkmanList.less'; 10 | import { State } from '../../state/reducer'; 11 | import useAero from '../../hooks/useAero'; 12 | 13 | function FunctionBarAndLinkmanList() { 14 | const isLogin = useIsLogin(); 15 | const action = useAction(); 16 | const functionBarAndLinkmanListVisible = useSelector( 17 | (state: State) => state.status.functionBarAndLinkmanListVisible, 18 | ); 19 | const aero = useAero(); 20 | 21 | if (!functionBarAndLinkmanListVisible) { 22 | return null; 23 | } 24 | 25 | function handleClick(e: any) { 26 | if (e.target === e.currentTarget) { 27 | action.setStatus('functionBarAndLinkmanListVisible', false); 28 | } 29 | } 30 | 31 | return ( 32 |
37 |
38 | {isLogin && } 39 | 40 |
41 |
42 | ); 43 | } 44 | 45 | export default FunctionBarAndLinkmanList; 46 | -------------------------------------------------------------------------------- /packages/web/src/modules/FunctionBarAndLinkmanList/Linkman.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/variable.less'; 2 | 3 | .linkman { 4 | height: 90px; 5 | display: flex; 6 | align-items: center; 7 | padding: 10px 16px; 8 | cursor: default; 9 | transition: background-color 0.2s; 10 | } 11 | 12 | .focus { 13 | background-color: var(--primary-color-4); 14 | 15 | &[data-aero=true] { 16 | background-color: rgba(255, 255, 255, 0.15); 17 | } 18 | 19 | @media @mobile { 20 | background-color: var(--primary-color-9); 21 | } 22 | } 23 | 24 | .container { 25 | flex: 1; 26 | margin-left: 12px; 27 | } 28 | 29 | .rowContainer { 30 | display: flex; 31 | justify-content: space-between; 32 | } 33 | 34 | .nameTimeBlock { 35 | margin-top: 4px; 36 | } 37 | 38 | .name { 39 | color: var(--primary-text-color-10); 40 | font-size: 14px; 41 | } 42 | 43 | .time { 44 | color: var(--primary-text-color-7); 45 | font-size: 12px; 46 | position: relative; 47 | top: 4px; 48 | } 49 | 50 | .previewUnreadBlock { 51 | margin-top: 6px; 52 | } 53 | 54 | .preview { 55 | color: var(--primary-text-color-7); 56 | font-size: 12px; 57 | width: 188px; 58 | height: 20px; 59 | line-height: 20px; 60 | overflow: hidden; 61 | white-space: nowrap; 62 | text-overflow: ellipsis; 63 | } 64 | 65 | .unread { 66 | @size: 18px; 67 | background-color: var(--primary-color-10); 68 | width: @size; 69 | height: @size; 70 | border-radius: 50%; 71 | text-align: center; 72 | & > span { 73 | color: #e9e9e9; 74 | font-size: 10px; 75 | line-height: @size; 76 | position: relative; 77 | top: -2px; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /packages/web/src/modules/FunctionBarAndLinkmanList/LinkmanList.less: -------------------------------------------------------------------------------- 1 | .linkmanList { 2 | flex: 1; 3 | overflow-y: auto; 4 | -webkit-overflow-scrolling: touch; 5 | } -------------------------------------------------------------------------------- /packages/web/src/modules/InfoDialog.less: -------------------------------------------------------------------------------- 1 | @import '../styles/variable.less'; 2 | 3 | .infoDialog { 4 | width: 300px !important; 5 | @media @mobile { 6 | width: 80% !important; 7 | } 8 | 9 | :global { 10 | .rc-dialog-body { 11 | padding: 0; 12 | & > div { 13 | width: 100%; 14 | } 15 | } 16 | } 17 | } 18 | 19 | .coantainer { 20 | text-align: center; 21 | } 22 | 23 | .header { 24 | background-color: rgb(240, 242, 245); 25 | padding: 20px 0 14px 0; 26 | border-top-left-radius: 6px; 27 | border-top-right-radius: 6px; 28 | } 29 | 30 | .ip { 31 | span { 32 | font-size: 13px; 33 | color: #888; 34 | margin: 0 2px; 35 | cursor: pointer; 36 | } 37 | } 38 | 39 | .largeAvatar { 40 | position: absolute; 41 | top: -100px; 42 | left: 220px; 43 | width: 300px; 44 | height: 300px; 45 | z-index: 9999; 46 | 47 | @media @mobile { 48 | width: 180px; 49 | height: 180px; 50 | left: 60px; 51 | top: -165px; 52 | } 53 | } 54 | 55 | .info { 56 | padding: 10px 20px 20px 20px; 57 | & > button { 58 | width: 100%; 59 | height: 34px; 60 | margin-top: 10px; 61 | } 62 | } 63 | 64 | .onlineStatus { 65 | text-align: left; 66 | display: flex; 67 | height: 30px; 68 | align-items: center; 69 | font-size: 14px; 70 | } 71 | 72 | .onlineText { 73 | width: 50px; 74 | color: #666; 75 | } 76 | -------------------------------------------------------------------------------- /packages/web/src/modules/LoginAndRegister/LoginAndRegister.less: -------------------------------------------------------------------------------- 1 | .login { 2 | border-bottom: none; 3 | width: 100%; 4 | 5 | :global { 6 | .rc-tabs-nav-wrap { 7 | width: 166px; 8 | margin: 0 auto; 9 | } 10 | .rc-tabs-tab { 11 | font-size: 16px; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/web/src/modules/LoginAndRegister/LoginAndRegister.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { 5 | Tabs, 6 | TabPane, 7 | TabContent, 8 | ScrollableInkTabBar, 9 | } from '../../components/Tabs'; 10 | import Style from './LoginAndRegister.less'; 11 | import Login from './Login'; 12 | import Register from './Register'; 13 | import Dialog from '../../components/Dialog'; 14 | import { State } from '../../state/reducer'; 15 | import useAction from '../../hooks/useAction'; 16 | 17 | function LoginAndRegister() { 18 | const action = useAction(); 19 | const loginRegisterDialogVisible = useSelector( 20 | (state: State) => state.status.loginRegisterDialogVisible, 21 | ); 22 | 23 | return ( 24 | action.toggleLoginRegisterDialog(false)} 28 | > 29 | } 33 | renderTabContent={() => } 34 | > 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | ); 44 | } 45 | 46 | export default LoginAndRegister; 47 | -------------------------------------------------------------------------------- /packages/web/src/modules/LoginAndRegister/LoginRegister.less: -------------------------------------------------------------------------------- 1 | .loginRegister { 2 | height: 260px; 3 | display: flex; 4 | flex-direction: column; 5 | padding: 0 30px; 6 | } 7 | 8 | .title { 9 | font-size: 14px; 10 | font-weight: normal; 11 | line-height: 27px; 12 | margin-top: 20px; 13 | color: #666; 14 | } 15 | 16 | .input { 17 | height: 40px; 18 | & > input { 19 | line-height: 40px; 20 | } 21 | } 22 | 23 | .button { 24 | background-color: var(--primary-color-10); 25 | color: var(--primary-text-color-10); 26 | height: 40px; 27 | border: none; 28 | margin-top: 30px; 29 | transition: background-color 0.2s; 30 | border-radius: 6px; 31 | 32 | &:hover { 33 | background-color: var(--primary-color-9); 34 | } 35 | } -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/About.less: -------------------------------------------------------------------------------- 1 | @import "../../styles/variable.less"; 2 | 3 | .about { 4 | width: 600px !important; 5 | 6 | @media @mobile { 7 | width: 94vw !important; 8 | } 9 | 10 | :global { 11 | p, li { 12 | user-select: text; 13 | } 14 | a { 15 | word-break: break-all; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/Admin.less: -------------------------------------------------------------------------------- 1 | .admin { 2 | // height: 50% !important; 3 | } 4 | 5 | .inputBlock { 6 | display: flex; 7 | } 8 | 9 | .input { 10 | flex: 1; 11 | height: 36px; 12 | } 13 | 14 | .tagUsernameInput { 15 | flex: 3; 16 | } 17 | 18 | .tagInput { 19 | flex: 2; 20 | margin-left: 6px; 21 | } 22 | 23 | .button { 24 | width: 100px; 25 | height: 36px; 26 | margin-left: 10px; 27 | } 28 | 29 | .sealList { 30 | min-height: 22px; 31 | } 32 | 33 | .sealUsername { 34 | margin-right: 10px; 35 | line-height: 22px; 36 | font-size: 14px; 37 | } -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/Common.less: -------------------------------------------------------------------------------- 1 | .container { 2 | 3 | } 4 | 5 | .block { 6 | margin-bottom: 14px; 7 | 8 | a, p, li { 9 | line-height: 22px; 10 | font-size: 14px; 11 | } 12 | ul { 13 | margin: 6px 0; 14 | padding-left: 26px; 15 | } 16 | } 17 | 18 | .title { 19 | line-height: 33px; 20 | font-size: 14px; 21 | color: #333; 22 | font-weight: bold; 23 | } -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/Download.less: -------------------------------------------------------------------------------- 1 | .download { 2 | 3 | } 4 | 5 | .android { 6 | p, a { 7 | font-size: 14px; 8 | } 9 | img { 10 | margin-top: 8px; 11 | } 12 | } 13 | 14 | .ios { 15 | p { 16 | font-size: 14px; 17 | line-height: 20px; 18 | } 19 | img { 20 | width: 400px; 21 | height: auto; 22 | margin-top: 8px; 23 | } 24 | } -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/Download.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import QRCode from 'qrcode.react'; 3 | 4 | import Dialog from '../../components/Dialog'; 5 | import Style from './Download.less'; 6 | import Common from './Common.less'; 7 | 8 | interface DownloadProps { 9 | visible: boolean; 10 | onClose: () => void; 11 | } 12 | 13 | function Download(props: DownloadProps) { 14 | const { visible, onClose } = props; 15 | const androidDownloadUrl = `${window.location.origin}/fiora.apk`; 16 | const iOSDownloadUrl = 'https://apps.apple.com/cn/app/fiora/id1554719127'; 17 | 18 | return ( 19 | 25 |
26 |
27 |

Android

28 |
29 |

30 | 链接:{' '} 31 | 32 | {androidDownloadUrl} 33 | 34 |

35 | 36 |
37 |
38 |
39 |

iOS

40 |
41 |

42 | 链接: {iOSDownloadUrl} 43 |

44 | 45 |
46 |
47 |
48 |
49 | ); 50 | } 51 | 52 | export default Download; 53 | -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/OnlineStatus.less: -------------------------------------------------------------------------------- 1 | .onlineStatus { 2 | width: 16px; 3 | height: 16px; 4 | background-color: var(--primary-color-8); 5 | border-radius: 50%; 6 | } 7 | 8 | .status { 9 | width: 12px; 10 | height: 12px; 11 | margin-top: 2px; 12 | margin-left: 2px; 13 | border-radius: 50%; 14 | } -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/OnlineStatus.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Style from './OnlineStatus.less'; 4 | 5 | interface OnlineStatusProps { 6 | /** 状态, online / offline */ 7 | status: string; 8 | className?: string; 9 | } 10 | 11 | function OnlineStatus(props: OnlineStatusProps) { 12 | const { status, className } = props; 13 | 14 | return ( 15 |
16 |
17 |
18 | ); 19 | } 20 | 21 | export default OnlineStatus; 22 | -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/Reward.less: -------------------------------------------------------------------------------- 1 | @import "../../styles/variable.less"; 2 | 3 | .reward { 4 | width: 700px !important; 5 | 6 | @media @mobile { 7 | width: 94vw !important; 8 | } 9 | } 10 | 11 | .text { 12 | color: #333; 13 | text-align: center; 14 | line-height: 34px; 15 | } 16 | 17 | .imageContainer { 18 | display: flex; 19 | align-items: center; 20 | justify-content: space-around; 21 | padding: 0 30px; 22 | height: 375px; 23 | 24 | @media @mobile { 25 | flex-wrap: wrap; 26 | & > img { 27 | margin-bottom: 10px; 28 | } 29 | } 30 | } 31 | 32 | .image { 33 | width: 250px; 34 | height: auto; 35 | 36 | @media @mobile { 37 | margin-bottom: 10px; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/Reward.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import AlipayImage from '@fiora/assets/images/alipay.png'; 4 | import WxpayImage from '@fiora/assets/images/wxpay.png'; 5 | import Dialog from '../../components/Dialog'; 6 | import Style from './Reward.less'; 7 | 8 | interface RewardProps { 9 | visible: boolean; 10 | onClose: () => void; 11 | } 12 | 13 | function Reward(props: RewardProps) { 14 | const { visible, onClose } = props; 15 | return ( 16 | 22 |
23 |

24 | 如果你觉得这个聊天室代码对你有帮助, 希望打赏下给个鼓励~~ 25 |
26 | 作者大多数时间在线, 欢迎提问, 有问必答 27 |

28 |
29 | 支付宝二维码 34 | 微信二维码 39 |
40 |
41 |
42 | ); 43 | } 44 | 45 | export default Reward; 46 | -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/SelfInfo.less: -------------------------------------------------------------------------------- 1 | .selfInfo { 2 | height: initial !important; 3 | width: 500px; 4 | } 5 | 6 | .changeAvatar { 7 | position: relative; 8 | .blur { 9 | filter: blur(2px); 10 | } 11 | } 12 | 13 | .cropper { 14 | & > div { 15 | margin-bottom: 8px; 16 | } 17 | .loading { 18 | top: 170px; 19 | left: 170px; 20 | } 21 | } 22 | 23 | .preview { 24 | & > img { 25 | width: 100px; 26 | height: 100px; 27 | cursor: pointer; 28 | 29 | &:hover { 30 | filter: blur(3px); 31 | } 32 | } 33 | .loading { 34 | top: 10px; 35 | left: 10px; 36 | } 37 | } 38 | 39 | .loading { 40 | position: absolute; 41 | pointer-events: none; 42 | } 43 | 44 | .input { 45 | height: 36px; 46 | margin-bottom: 8px; 47 | } 48 | 49 | .button { 50 | width: 100px; 51 | height: 36px; 52 | } -------------------------------------------------------------------------------- /packages/web/src/modules/Sidebar/Sidebar.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/variable.less'; 2 | 3 | .sidebar { 4 | width: 80px; 5 | min-width: min-content; 6 | background-color: var(--primary-color-8); 7 | display: flex; 8 | flex-direction: column; 9 | align-items: center; 10 | position: relative; 11 | border-top-left-radius: 10px; 12 | border-bottom-left-radius: 10px; 13 | 14 | &[data-aero=true] { 15 | background-color: var(--primary-color-0); 16 | } 17 | 18 | @media @mobile { 19 | width: 60px; 20 | padding: 0 3px; 21 | border-top-left-radius: 0; 22 | border-bottom-left-radius: 0; 23 | } 24 | } 25 | 26 | .avatar { 27 | margin-top: 50px; 28 | cursor: pointer; 29 | } 30 | 31 | .status { 32 | position: absolute; 33 | top: 96px; 34 | left: 48px; 35 | } 36 | 37 | .tabs { 38 | margin-top: 50px; 39 | } 40 | 41 | .buttons { 42 | position: absolute; 43 | bottom: 40px; 44 | display: flex; 45 | flex-direction: column; 46 | align-items: center; 47 | 48 | :global { 49 | .iconfont { 50 | color: var(--primary-text-color-7); 51 | transition: color 0.2s; 52 | } 53 | } 54 | 55 | & > div, .linkButton { 56 | margin-top: 10px; 57 | &:hover { 58 | .iconfont { 59 | color: var(--primary-text-color-10); 60 | } 61 | } 62 | } 63 | } 64 | 65 | .linkButton { 66 | text-decoration: none; 67 | } 68 | -------------------------------------------------------------------------------- /packages/web/src/state/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from 'redux'; 2 | import reducer from './reducer'; 3 | 4 | const store = createStore( 5 | reducer, 6 | window.__REDUX_DEVTOOLS_EXTENSION__ && 7 | window.__REDUX_DEVTOOLS_EXTENSION__(), 8 | ); 9 | export default store; 10 | -------------------------------------------------------------------------------- /packages/web/src/styles/normalize.less: -------------------------------------------------------------------------------- 1 | @import '~normalize.css/normalize.css'; 2 | 3 | @font-face { 4 | font-family: 'local-file'; 5 | src: url('~@fiora/assets/fonts/font.woff'); 6 | } 7 | 8 | :global { 9 | * { 10 | user-select: none; 11 | } 12 | 13 | html, body, #app { 14 | width: 100%; 15 | height: 100%; 16 | overflow: hidden; 17 | } 18 | 19 | html { 20 | font-family: 'local-file', 'Arial', 'Verdana', '微软雅黑', '黑体', 'serif'; 21 | } 22 | 23 | p, h1, h2, h3, h4 { 24 | margin: 0; 25 | } 26 | 27 | div { 28 | box-sizing: border-box; 29 | } 30 | 31 | button, input, textarea { 32 | outline: none; 33 | } 34 | 35 | button { 36 | cursor: pointer; 37 | } 38 | 39 | div, p, span { 40 | color: #333; 41 | } 42 | 43 | body {overflow-y:hidden;} 44 | body:hover {overflow-y:scroll;} 45 | 46 | ::-webkit-scrollbar { 47 | display: none; 48 | } 49 | .show-scrollbar::-webkit-scrollbar { 50 | display: block; 51 | width: 6px; 52 | height: 6px; 53 | } 54 | ::-webkit-scrollbar-thumb { 55 | background-color: rgba(0, 0, 0, 0.2); 56 | } 57 | ::-webkit-scrollbar-track { 58 | background: rgba(255, 255, 255, 0.1); 59 | } 60 | 61 | .online { 62 | background-color: rgba(94, 212, 92, 1); 63 | } 64 | .offline { 65 | background-color: rgba(206, 12, 35, 1); 66 | } 67 | 68 | .show { 69 | display: block; 70 | } 71 | .hide { 72 | display: none !important; 73 | } 74 | } -------------------------------------------------------------------------------- /packages/web/src/styles/variable.less: -------------------------------------------------------------------------------- 1 | @mobile: ~"only screen and (max-width: 500px)"; -------------------------------------------------------------------------------- /packages/web/src/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | fiora 6 | 7 | 8 | 9 | 10 | 11 | <% if (process.env.NODE_ENV === 'production') { %> 12 | 13 | <% } %> 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /packages/web/src/themes.ts: -------------------------------------------------------------------------------- 1 | import BackgroundImage from '@fiora/assets/images/background.jpg'; 2 | import BackgroundCoolImage from '@fiora/assets/images/background-cool.jpg'; 3 | 4 | type Themes = { 5 | [theme: string]: { 6 | primaryColor: string; 7 | primaryTextColor: string; 8 | backgroundImage: string; 9 | aero: boolean; 10 | }; 11 | }; 12 | 13 | const themes: Themes = { 14 | default: { 15 | primaryColor: '74, 144, 226', 16 | primaryTextColor: '247, 247, 247', 17 | backgroundImage: BackgroundImage, 18 | aero: false, 19 | }, 20 | cool: { 21 | primaryColor: '5,159,149', 22 | primaryTextColor: '255, 255, 255', 23 | backgroundImage: BackgroundCoolImage, 24 | aero: false, 25 | }, 26 | }; 27 | 28 | export default themes; 29 | -------------------------------------------------------------------------------- /packages/web/src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'opn'; 2 | declare module 'webpack'; 3 | declare module 'http-proxy-middleware'; 4 | declare module 'webpack-dev-middleware'; 5 | declare module 'webpack-hot-middleware'; 6 | declare module 'connect-history-api-fallback'; 7 | declare module 'ora'; 8 | declare module 'rimraf'; 9 | declare module 'less-plugin-autoprefix'; 10 | declare module 'extract-text-webpack-plugin'; 11 | declare module 'webpack-merge'; 12 | declare module 'html-webpack-plugin'; 13 | declare module 'friendly-errors-webpack-plugin'; 14 | declare module 'webpack-dashboard/plugin'; 15 | declare module 'copy-webpack-plugin'; 16 | declare module 'optimize-css-assets-webpack-plugin'; 17 | declare module 'script-ext-html-webpack-plugin'; 18 | declare module 'webpack-bundle-analyzer'; 19 | declare module 'react-radio-buttons'; 20 | declare module 'rc-tabs'; 21 | declare module 'rc-tabs/lib/TabContent'; 22 | declare module 'rc-tabs/lib/ScrollableInkTabBar'; 23 | declare module 'rc-menu'; 24 | declare module 'rc-dropdown'; 25 | declare module 'rc-notification'; 26 | declare module 'brace/mode/javascript'; 27 | declare module 'brace/mode/typescript'; 28 | declare module 'brace/mode/java'; 29 | declare module 'brace/mode/c_cpp'; 30 | declare module 'brace/mode/python'; 31 | declare module 'brace/mode/ruby'; 32 | declare module 'brace/mode/php'; 33 | declare module 'brace/mode/golang'; 34 | declare module 'brace/mode/csharp'; 35 | declare module 'brace/mode/html'; 36 | declare module 'brace/mode/css'; 37 | declare module 'brace/mode/markdown'; 38 | declare module 'brace/mode/sql'; 39 | declare module 'brace/mode/json'; 40 | 41 | declare module '*.less'; 42 | declare module '*.json'; 43 | declare module '*.png'; 44 | declare module '*.jpg'; 45 | declare module '*.jpeg'; 46 | declare module '*.gif'; 47 | declare module '*.mp3'; 48 | 49 | declare var __TEST__: false; 50 | 51 | declare interface Window { 52 | Notification: any; 53 | __REDUX_DEVTOOLS_EXTENSION__: any; 54 | } 55 | -------------------------------------------------------------------------------- /packages/web/src/utils/fetch.ts: -------------------------------------------------------------------------------- 1 | import Message from '../components/Message'; 2 | import socket from '../socket'; 3 | 4 | import { SEAL_TEXT, SEAL_USER_TIMEOUT } from '../../../utils/const'; 5 | 6 | /** 用户是否被封禁 */ 7 | let isSeal = false; 8 | 9 | export default function fetch( 10 | event: string, 11 | data = {}, 12 | { toast = true } = {}, 13 | ): Promise<[string | null, T | null]> { 14 | if (isSeal) { 15 | Message.error(SEAL_TEXT); 16 | return Promise.resolve([SEAL_TEXT, null]); 17 | } 18 | return new Promise((resolve) => { 19 | socket.emit(event, data, (res: any) => { 20 | if (typeof res === 'string') { 21 | if (toast) { 22 | Message.error(res); 23 | } 24 | /** 25 | * 服务端返回封禁状态后, 本地存储该状态 26 | * 用户再触发接口请求时, 直接拒绝 27 | */ 28 | if (res === SEAL_TEXT) { 29 | isSeal = true; 30 | // 用户封禁和ip封禁时效不同, 这里用的短时间 31 | setTimeout(() => { 32 | isSeal = false; 33 | }, SEAL_USER_TIMEOUT); 34 | } 35 | resolve([res, null]); 36 | } else { 37 | resolve([null, res]); 38 | } 39 | }); 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /packages/web/src/utils/notification.ts: -------------------------------------------------------------------------------- 1 | export default function notification( 2 | title: string, 3 | icon: string, 4 | body: string, 5 | tag = 'tag', 6 | duration = 3000, 7 | ) { 8 | if (window.Notification && window.Notification.permission === 'granted') { 9 | const n = new window.Notification(title, { 10 | icon, 11 | body, 12 | tag, 13 | }); 14 | n.onclick = function handleClick() { 15 | window.focus(); 16 | this.close(); 17 | }; 18 | setTimeout(n.close.bind(n), duration); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/web/src/utils/playSound.ts: -------------------------------------------------------------------------------- 1 | import DefaultSound from '@fiora/assets/audios/default.mp3'; 2 | import AppleSound from '@fiora/assets/audios/apple.mp3'; 3 | import PcQQSound from '@fiora/assets/audios/pcqq.mp3'; 4 | import MobileQQSound from '@fiora/assets/audios/mobileqq.mp3'; 5 | import MoMoSound from '@fiora/assets/audios/momo.mp3'; 6 | import HuaJiSound from '@fiora/assets/audios/huaji.mp3'; 7 | 8 | type Sounds = { 9 | [key: string]: string; 10 | }; 11 | 12 | const sounds: Sounds = { 13 | default: DefaultSound, 14 | apple: AppleSound, 15 | pcqq: PcQQSound, 16 | mobileqq: MobileQQSound, 17 | momo: MoMoSound, 18 | huaji: HuaJiSound, 19 | }; 20 | 21 | let prevType = 'default'; 22 | const $audio = document.createElement('audio'); 23 | const $source = document.createElement('source'); 24 | $audio.volume = 0.6; 25 | $source.setAttribute('type', 'audio/mp3'); 26 | $source.setAttribute('src', sounds[prevType]); 27 | $audio.appendChild($source); 28 | document.body.appendChild($audio); 29 | 30 | let isPlaying = false; 31 | 32 | async function play() { 33 | if (!isPlaying) { 34 | isPlaying = true; 35 | 36 | try { 37 | await $audio.play(); 38 | } catch (err) { 39 | console.warn('播放新消息提示音失败', err.message); 40 | } finally { 41 | isPlaying = false; 42 | } 43 | } 44 | } 45 | 46 | export default function playSound(type = 'default') { 47 | if (type !== prevType) { 48 | $source.setAttribute('src', sounds[type]); 49 | $audio.load(); 50 | prevType = type; 51 | } 52 | play(); 53 | } 54 | -------------------------------------------------------------------------------- /packages/web/src/utils/setCssVariable.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * set global css variable 3 | * @param color primary color, three numbers split with comma, like 255,255,255 4 | * @param textColor text colore, format like color 5 | */ 6 | export default function setCssVariable(color: string, textColor: string) { 7 | let cssText = ''; 8 | for (let i = 0; i <= 10; i++) { 9 | cssText += `--primary-color-${i}:rgba(${color}, ${i / 10 | 10});--primary-color-${i}_5:rgba(${color}, ${(i + 0.5) / 11 | 10});--primary-text-color-${i}:rgba(${textColor}, ${i / 10});`; 12 | } 13 | document.documentElement.style.cssText += cssText; 14 | } 15 | -------------------------------------------------------------------------------- /packages/web/src/utils/voice.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import fetch from './fetch'; 3 | 4 | const $audio = document.createElement('audio'); 5 | const $source = document.createElement('source'); 6 | $audio.volume = 0.6; 7 | $source.setAttribute('type', 'audio/mp3'); 8 | $source.setAttribute('src', ''); 9 | $audio.appendChild($source); 10 | document.body.appendChild($audio); 11 | 12 | let baiduToken = ''; 13 | async function read(text: string, cuid: string) { 14 | if (!baiduToken) { 15 | const [err, result] = await fetch('getBaiduToken'); 16 | if (err) { 17 | return; 18 | } 19 | baiduToken = result.token; 20 | } 21 | 22 | const res = await axios.get( 23 | `https://tsn.baidu.com/text2audio?tex=${text}&tok=${baiduToken}&cuid=${cuid}&ctp=1&lan=zh&per=4`, 24 | { responseType: 'blob' }, 25 | ); 26 | const blob = res.data; 27 | if (res.status !== 200 || blob.type === 'application/json') { 28 | console.warn('合成语言失败'); 29 | } else { 30 | $source.setAttribute('src', URL.createObjectURL(blob)); 31 | $audio.load(); 32 | 33 | try { 34 | const playEndPromise = new Promise((resolve) => { 35 | $audio.onended = resolve; 36 | }); 37 | await $audio.play(); 38 | // eslint-disable-next-line consistent-return 39 | return playEndPromise; 40 | } catch (err) { 41 | console.warn('语言朗读消息失败', err.message); 42 | } 43 | } 44 | } 45 | 46 | type Task = { 47 | text: string; 48 | cuid: string; 49 | }; 50 | 51 | const taskQueue: Task[] = []; 52 | let isWorking = false; 53 | async function handleTaskQueue() { 54 | isWorking = true; 55 | const task = taskQueue.shift(); 56 | if (task) { 57 | await read(task.text, task.cuid); 58 | await handleTaskQueue(); 59 | } else { 60 | isWorking = false; 61 | } 62 | } 63 | 64 | const voice = { 65 | push(text: string, cuid: string) { 66 | taskQueue.push({ text, cuid }); 67 | if (!isWorking) { 68 | handleTaskQueue(); 69 | } 70 | }, 71 | }; 72 | 73 | export default voice; 74 | -------------------------------------------------------------------------------- /packages/web/test/components/Avatar.spec.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import React from 'react'; 6 | import { render, screen, fireEvent } from '@testing-library/react'; 7 | import '@testing-library/jest-dom/extend-expect'; 8 | import Avatar, { avatarFailback } from '../../src/components/Avatar'; 9 | 10 | describe('Avatar', () => { 11 | it('shoule render without error', async () => { 12 | render(); 13 | const $img = await screen.findByRole('img'); 14 | expect($img).toBeInTheDocument(); 15 | }); 16 | 17 | it('should call props handler function when fire event', async () => { 18 | const handleClick = jest.fn(); 19 | const handleMouseEnter = jest.fn(); 20 | const handleMouseLeave = jest.fn(); 21 | render( 22 | , 28 | ); 29 | const $img = await screen.findByRole('img'); 30 | fireEvent.click($img); 31 | expect(handleClick).toBeCalled(); 32 | fireEvent.mouseEnter($img); 33 | expect(handleMouseEnter).toBeCalled(); 34 | fireEvent.mouseLeave($img); 35 | expect(handleMouseLeave).toBeCalled(); 36 | }); 37 | 38 | it('shoule use failback avatar when fetch error', async () => { 39 | const src = 'origin.jpg'; 40 | render(); 41 | const $img = (await screen.findByRole('img')) as HTMLImageElement; 42 | expect($img.src).toEqual(expect.stringContaining(src)); 43 | fireEvent.error($img); 44 | expect($img.src).toEqual(expect.stringContaining(avatarFailback)); 45 | fireEvent.error($img); 46 | fireEvent.error($img); 47 | }); 48 | 49 | it('shoule not add CDN query params', async () => { 50 | const src = 'data:base64/png;xxx'; 51 | render(); 52 | const $img = (await screen.findByRole('img')) as HTMLImageElement; 53 | expect($img.src).not.toEqual(expect.stringContaining('x-oss-process=')); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /packages/web/test/components/Button.spec.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import React from 'react'; 6 | import { render, screen, fireEvent } from '@testing-library/react'; 7 | import '@testing-library/jest-dom/extend-expect'; 8 | import Button from '../../src/components/Button'; 9 | 10 | describe('Button', () => { 11 | it('shoule render without error', async () => { 12 | render(); 13 | const $button = screen.getByRole('button'); 14 | expect($button).toBeInTheDocument(); 15 | }); 16 | 17 | it('shoule support set custom class name and type', async () => { 18 | render( 19 | , 22 | ); 23 | const $button = screen.getByRole('button'); 24 | expect($button.classList).toContain('custom'); 25 | expect($button.classList).toContain('danger'); 26 | }); 27 | 28 | it('shoule call props handler function when fire event', async () => { 29 | const handleClick = jest.fn(); 30 | render(); 31 | const $button = screen.getByRole('button'); 32 | fireEvent.click($button); 33 | expect(handleClick).toHaveBeenCalled(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /packages/web/test/localStorage.spec.ts: -------------------------------------------------------------------------------- 1 | import getData, { LocalStorageKey } from '../src/localStorage'; 2 | import config from '../../config/client'; 3 | import themes from '../src/themes'; 4 | 5 | describe('client/localStorage.ts', () => { 6 | it('should return localStorage value, or default value if not exists', () => { 7 | expect(getData().sound).toBe(config.sound); 8 | localStorage.setItem(LocalStorageKey.Sound, 'huaji'); 9 | expect(getData().sound).toBe('huaji'); 10 | }); 11 | 12 | it('should return default theme config when them not exists', () => { 13 | localStorage.setItem(LocalStorageKey.Theme, 'xxx'); 14 | expect(getData().primaryColor).toBe(themes.cool.primaryColor); 15 | }); 16 | 17 | it('should return boolean type value when value is true / false', () => { 18 | localStorage.setItem(LocalStorageKey.SoundSwitch, 'true'); 19 | expect(getData().soundSwitch).toBe(true); 20 | localStorage.setItem(LocalStorageKey.SoundSwitch, 'false'); 21 | expect(getData().soundSwitch).toBe(false); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | "module": "esnext" 5 | } 6 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "strict": true, 5 | "moduleResolution": "node", 6 | "experimentalDecorators": true, 7 | "jsx": "react", 8 | "allowSyntheticDefaultImports": true, 9 | "esModuleInterop": true, 10 | "lib": ["es2015", "es2016", "es2017", "dom"], 11 | "resolveJsonModule": true, 12 | "skipLibCheck": true 13 | }, 14 | "exclude": ["node_modules"] 15 | } 16 | --------------------------------------------------------------------------------