chore: import upstream snapshot with attribution
@@ -0,0 +1,6 @@
|
||||
**/node_module
|
||||
packages/docs/
|
||||
packages/web/.linaria-cache/
|
||||
packages/web/dist/
|
||||
|
||||
yarn-error.log
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
public/
|
||||
build/
|
||||
docs/
|
||||
*.d.ts
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"extends": ["eslint-config-airbnb", "prettier"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json",
|
||||
"createDefaultProgram": true
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"jest/globals": true
|
||||
},
|
||||
"plugins": ["@typescript-eslint", "react", "react-hooks", "jsx-a11y", "import", "jest"],
|
||||
"globals": {
|
||||
"importScripts": true,
|
||||
"workbox": true,
|
||||
"__TEST__": true
|
||||
},
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"node": {
|
||||
"extensions": [".js", ".jsx", ".ts", ".tsx"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": 2,
|
||||
"global-require": 0,
|
||||
"implicit-arrow-linebreak": 0,
|
||||
"import/extensions": [
|
||||
2,
|
||||
{
|
||||
"ts": "never",
|
||||
"tsx": "never",
|
||||
"js": "never",
|
||||
"jsx": "never"
|
||||
}
|
||||
],
|
||||
"indent": [
|
||||
2,
|
||||
4,
|
||||
{
|
||||
"SwitchCase": 1
|
||||
}
|
||||
],
|
||||
"jsx-a11y/click-events-have-key-events": 0,
|
||||
"jsx-a11y/interactive-supports-focus": 0,
|
||||
"jsx-a11y/no-noninteractive-element-interactions": 0,
|
||||
"jsx-a11y/no-noninteractive-element-to-interactive-role": 0,
|
||||
"no-param-reassign": 0,
|
||||
"no-plusplus": 0,
|
||||
"no-script-url": 0,
|
||||
"no-underscore-dangle": 0,
|
||||
"object-curly-newline": 0,
|
||||
"react/jsx-filename-extension": [
|
||||
2,
|
||||
{
|
||||
"extensions": [".js", ".jsx", ".tsx"]
|
||||
}
|
||||
],
|
||||
"react/jsx-indent": [2, 4],
|
||||
"react/jsx-indent-props": [2, 4],
|
||||
"react/jsx-props-no-spreading": 0,
|
||||
"react/jsx-one-expression-per-line": 0,
|
||||
"react/static-property-placement": 0,
|
||||
"react-hooks/rules-of-hooks": 2,
|
||||
"react-hooks/exhaustive-deps": 0,
|
||||
"react/require-default-props": [2, { "ignoreFunctionalComponents": true }],
|
||||
"import/prefer-default-export": 0,
|
||||
"prefer-promise-reject-errors": "off"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ master ]
|
||||
schedule:
|
||||
- cron: '32 14 * * 5'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'typescript', 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
||||
# Learn more:
|
||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
@@ -0,0 +1,21 @@
|
||||
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: Lint Code Style
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [10.x]
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- uses: bahmutov/npm-install@v1.4.5
|
||||
- run: yarn lint
|
||||
@@ -0,0 +1,18 @@
|
||||
name: Unit Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [10.x]
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- uses: bahmutov/npm-install@v1.4.5
|
||||
- run: yarn test
|
||||
@@ -0,0 +1,21 @@
|
||||
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: Typescript Type Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
ts:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [10.x]
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- uses: bahmutov/npm-install@v1.4.5
|
||||
- run: yarn ts-check
|
||||
@@ -0,0 +1,23 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.idea/
|
||||
.linaria-cache/
|
||||
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
.eslintcache
|
||||
lerna-debug.log
|
||||
|
||||
.env
|
||||
|
||||
packages/server/public/*
|
||||
!packages/server/public/avatar/
|
||||
packages/server/public/avatar/*_*.*
|
||||
!packages/server/public/favicon-96.png
|
||||
!packages/server/public/favicon-192.png
|
||||
!packages/server/public/favicon-512.png
|
||||
!packages/server/public/manifest.json
|
||||
!packages/server/public/index.html
|
||||
!packages/server/public/PrivacyPolicy.html
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"arrowParens": "always",
|
||||
"printWidth": 80
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM node:14
|
||||
|
||||
WORKDIR /usr/app/fiora
|
||||
|
||||
COPY packages ./packages
|
||||
COPY package.json tsconfig.json yarn.lock lerna.json ./
|
||||
RUN touch .env
|
||||
|
||||
RUN yarn install
|
||||
|
||||
RUN yarn build:web
|
||||
|
||||
CMD yarn start
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2015-2021 碎碎酱
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,57 @@
|
||||
# [Fiora](https://fiora.suisuijiang.com/) · [](https://github.com/yinxin630/fiora/blob/master/LICENSE) [](http://suisuijiang.com) [](http://nodejs.org/download) [](https://github.com/yinxin630/fiora/actions?query=workflow%3A%22Unit+Test%22) [](https://github.com/yinxin630/fiora/actions?query=workflow%3A%22Typescript+Type+Check%22)
|
||||
|
||||
|
||||
Fiora is an interesting open source chat application. It is developed based on [node.js](https://nodejs.org/), [react](https://reactjs.org/) and [socket.io](https://socket.io/) technologies
|
||||
|
||||
- **Richness:** Fiora contains backend, frontend, Android and iOS apps
|
||||
- **Cross Platform:** Fiora is developed with node.js. Supports Windows / Linux / macOS systems
|
||||
- **Open Source:** Fiora follows the MIT open source license
|
||||
|
||||
Online Example: [https://fiora.suisuijiang.com/](https://fiora.suisuijiang.com/)
|
||||
Documentation: [https://yinxin630.github.io/fiora/](https://yinxin630.github.io/fiora/)
|
||||
|
||||
**Other Client**
|
||||
Vscode Extension: [https://github.com/moonrailgun/fiora-for-vscode](https://github.com/moonrailgun/fiora-for-vscode)
|
||||
|
||||
If you are seek for other open-source IM Application which like discord or slack, maybe try out `Tailchat`: https://tailchat.msgbyte.com/
|
||||
|
||||
## Features
|
||||
|
||||
1. Register an account and log in, it can save your data for a long time
|
||||
2. Join an existing group or create your own group to communicate with everyone
|
||||
3. Chat privately with anyone and add them as friends
|
||||
4. Multiple message types, including text / emoticons / pictures / codes / files / commands, you can also search for emoticons
|
||||
5. Push notification when you receive a new message, you can customize the notification ringtone, and it can also read the message out
|
||||
6. Choose the theme you like, and you can set it as any wallpaper and theme color you like
|
||||
7. Set up an administrator to manage users
|
||||
|
||||
## Screenshot
|
||||
|
||||
<img src="https://github.com/yinxin630/fiora/raw/master/packages/docs/static/img/screenshots/screenshot-pc.png" alt="PC" style="max-width:800px" />
|
||||
<img src="https://github.com/yinxin630/fiora/raw/master/packages/docs/static/img/screenshots/screenshot-phone.png" alt="Phone" height="667" style="max-height:667px" />
|
||||
<img src="https://github.com/yinxin630/fiora/raw/master/packages/docs/static/img/screenshots/screenshot-app.png" alt="App" height="896" style="max-height:896px" />
|
||||
|
||||
## Install
|
||||
|
||||
Fiora provides two ways to install
|
||||
|
||||
- [Install by source code](https://yinxin630.github.io/fiora/docs/install#how-to-run)
|
||||
- [Install by docker](https://yinxin630.github.io/fiora/docs/install#running-on-the-docker)
|
||||
|
||||
## Change Log
|
||||
|
||||
You can find the Fiora changelog [on the website](https://yinxin630.github.io/fiora/docs/changelog)
|
||||
|
||||
## Contribution
|
||||
|
||||
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. Please make sure to update tests as appropriate
|
||||
|
||||
1. Fork it (<https://github.com/yinxin630/fiora/fork>)
|
||||
2. Create your feature branch (`git checkout -b some-feature`)
|
||||
3. Commit your changes (`git commit -am 'Add some some features'`)
|
||||
4. Push to the branch (`git push origin some-feature`)
|
||||
5. Create a new Pull Request
|
||||
|
||||
## License
|
||||
|
||||
Fiora is [MIT licensed](./LICENSE)
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`yinxin630/fiora`
|
||||
- 原始仓库:https://github.com/yinxin630/fiora
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,17 @@
|
||||
version: '3.2'
|
||||
|
||||
services:
|
||||
mongodb:
|
||||
image: mongo
|
||||
restart: always
|
||||
redis:
|
||||
image: redis
|
||||
restart: always
|
||||
fiora:
|
||||
build: .
|
||||
restart: always
|
||||
ports:
|
||||
- "9200:9200"
|
||||
environment:
|
||||
- Database=mongodb://mongodb/fiora
|
||||
- RedisHost=redis
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env ./node_modules/.bin/ts-node
|
||||
|
||||
import { program } from 'commander';
|
||||
import cp from 'child_process';
|
||||
import i18n from './packages/i18n/node.index';
|
||||
|
||||
function exec(commandStr: string) {
|
||||
const [command, ...args] = commandStr.split(' ');
|
||||
cp.execFileSync(command, args, { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
program
|
||||
.command('getUserId <username>')
|
||||
.description(i18n('getUserIdDescription'))
|
||||
.action((username: string) => {
|
||||
exec(
|
||||
`npx ts-node --transpile-only packages/bin/index.ts getUserId ${username}`,
|
||||
);
|
||||
});
|
||||
|
||||
program
|
||||
.command('register <username> <password>')
|
||||
.description(i18n('registerDescription'))
|
||||
.action((username: string, password: string) => {
|
||||
exec(
|
||||
`npx ts-node --transpile-only packages/bin/index.ts register ${username} ${password}`,
|
||||
);
|
||||
});
|
||||
|
||||
program
|
||||
.command('deleteUser <userId>')
|
||||
.description(i18n('deleteUserDescription'))
|
||||
.action((userId: string) => {
|
||||
exec(
|
||||
`npx ts-node --transpile-only packages/bin/index.ts deleteUser ${userId}`,
|
||||
);
|
||||
});
|
||||
|
||||
program
|
||||
.command('fixUsersAvatar [searchValue] [replaceValue]')
|
||||
.description(i18n('fixUsersAvatarDescription'))
|
||||
.action((searchValue = '', replaceValue = '') => {
|
||||
exec(
|
||||
`npx ts-node --transpile-only packages/bin/index.ts fixUsersAvatar ${searchValue} ${replaceValue}`,
|
||||
);
|
||||
});
|
||||
|
||||
program
|
||||
.command('deleteTodayRegisteredUsers')
|
||||
.description(i18n('deleteTodayRegisteredUsersDescription'))
|
||||
.action(() => {
|
||||
exec(
|
||||
`npx ts-node --transpile-only packages/bin/index.ts deleteTodayRegisteredUsers`,
|
||||
);
|
||||
});
|
||||
|
||||
program
|
||||
.command('deleteMessages')
|
||||
.description(i18n('deleteMessagesDescription'))
|
||||
.action(() => {
|
||||
exec(
|
||||
`npx ts-node --transpile-only packages/bin/index.ts deleteMessages`,
|
||||
);
|
||||
});
|
||||
|
||||
program
|
||||
.command('updateDefaultGroupName <newName>')
|
||||
.description(i18n('updateDefaultGroupNameDescription'))
|
||||
.action((newName: string) => {
|
||||
exec(
|
||||
`npx ts-node --transpile-only packages/bin/index.ts updateDefaultGroupName ${newName}`,
|
||||
);
|
||||
});
|
||||
|
||||
program
|
||||
.command('doctor')
|
||||
.description(i18n('doctorDescription'))
|
||||
.action(() => {
|
||||
exec(`npx ts-node --transpile-only packages/bin/index.ts doctor`);
|
||||
});
|
||||
|
||||
program.usage('[command]');
|
||||
|
||||
program.parse(process.argv);
|
||||
@@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
moduleNameMapper: {
|
||||
'^.+\\.(css|less|jpg|png|gif|mp3)$': '<rootDir>/jest.transformer.js',
|
||||
},
|
||||
collectCoverage: true,
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
isolatedModules: true,
|
||||
},
|
||||
__TEST__: true,
|
||||
},
|
||||
setupFilesAfterEnv: ['./jest.setup.js'],
|
||||
collectCoverageFrom: [
|
||||
'**/*.{ts,tsx}',
|
||||
'!**/node_modules/**',
|
||||
'!**/config/**',
|
||||
'!**/test/helpers/**',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
jest.mock('./packages/web/node_modules/linaria', () => ({
|
||||
css: jest.fn(() => ''),
|
||||
}));
|
||||
|
||||
jest.mock('./packages/database/node_modules/redis', () => jest.requireActual('redis-mock'));
|
||||
@@ -0,0 +1,7 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
process(src, filename) {
|
||||
return `module.exports = ${JSON.stringify(path.basename(filename))};`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"packages": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "independent",
|
||||
"npmClient": "yarn"
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "fiora",
|
||||
"version": "1.0.0",
|
||||
"description": "An interesting chat application power by socket.io, koa, mongodb and react",
|
||||
"license": "MIT",
|
||||
"bin": "index.ts",
|
||||
"scripts": {
|
||||
"start": "npx lerna run start --stream",
|
||||
"dev:server": "npx lerna run dev:server --stream",
|
||||
"dev:web": "npx lerna run dev:web --stream",
|
||||
"build:web": "npx lerna run build:web --stream",
|
||||
"dev:app": "cd packages/app && yarn dev:app && cd ../../",
|
||||
"build:android": "cd packages/app && yarn build:android && cd ../../",
|
||||
"build:ios": "cd packages/app && yarn build:ios && cd ../../",
|
||||
"script": "npx lerna run script --stream",
|
||||
"dev:docs": "npx lerna run dev:docs --stream",
|
||||
"build:docs": "npx lerna run build:docs --stream",
|
||||
"deploy:docs": "npx lerna run deploy:docs --stream",
|
||||
"lint": "eslint ./ --ext js,jsx,ts,tsx --ignore-pattern .eslintignore --cache --fix",
|
||||
"test": "jest",
|
||||
"ts-check": "tsc --noEmit",
|
||||
"install": "npx lerna bootstrap && yarn link"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
},
|
||||
"author": {
|
||||
"name": "碎碎酱",
|
||||
"email": "yinxin630@gmail.com"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/yinxin630/fiora"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@types/jest": "^24.0.18",
|
||||
"@types/node": "^15.14.1",
|
||||
"@typescript-eslint/eslint-plugin": "^2.0.0",
|
||||
"@typescript-eslint/parser": "^2.0.0",
|
||||
"eslint": "^7.30.0",
|
||||
"eslint-config-airbnb": "^18.2.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-import": "^2.23.4",
|
||||
"eslint-plugin-jest": "^24.3.6",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-react": "^7.24.0",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"jest": "^26.1.0",
|
||||
"lerna": "^4.0.0",
|
||||
"redis-mock": "^0.56.3",
|
||||
"ts-jest": "^26.1.3",
|
||||
"ts-node": "^10.1.0",
|
||||
"typescript": "^3.8.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"presets": ["babel-preset-expo"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../.eslintrc",
|
||||
"rules": {
|
||||
"no-use-before-define": "off",
|
||||
"consistent-return": "off"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# See https://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# expo
|
||||
.expo/
|
||||
.expo-shared/
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# misc
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,13 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import App from './src/App';
|
||||
import store from './src/state/store';
|
||||
|
||||
export default function Main(props: any) {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<App {...props} />
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"expo": {
|
||||
"privacy": "public",
|
||||
"name": "fiora",
|
||||
"icon": "./icon.png",
|
||||
"version": "1.1.4",
|
||||
"description": "App for fiora. An online chatroom",
|
||||
"slug": "fiora",
|
||||
"scheme": "fiora",
|
||||
"ios": {
|
||||
"bundleIdentifier": "com.suisuijiang.fiora",
|
||||
"buildNumber": "1.1.4",
|
||||
"infoPlist": {
|
||||
"LSApplicationQueriesSchemes": ["wxp"],
|
||||
"CFBundleLocalizations" : ["zh_CN"],
|
||||
"CFBundleDevelopmentRegion": "zh_CN"
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"package": "com.suisuijiang.fiora",
|
||||
"versionCode": 10,
|
||||
"useNextNotificationsApi": true
|
||||
},
|
||||
"updates": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 88 KiB |
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@fiora/app",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"main": "./node_modules/expo/AppEntry.js",
|
||||
"scripts": {
|
||||
"dev:app": "expo start",
|
||||
"eject": "expo eject",
|
||||
"build:android": "expo build:android -t apk",
|
||||
"build:ios": "expo build:ios -t archive"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^12.0.5",
|
||||
"@react-native-async-storage/async-storage": "~1.15.0",
|
||||
"@react-native-community/masked-view": "0.1.10",
|
||||
"@react-native-toolkit/triangle": "^0.0.1",
|
||||
"autobind-decorator": "^2.1.0",
|
||||
"deepmerge": "^4.2.2",
|
||||
"expo": "^42.0.0",
|
||||
"expo-constants": "~11.0.1",
|
||||
"expo-image-picker": "~10.2.2",
|
||||
"expo-notifications": "~0.12.3",
|
||||
"expo-web-browser": "~9.2.0",
|
||||
"immer": "^9.0.6",
|
||||
"native-base": "^2.4.5",
|
||||
"prop-types": "^15.6.1",
|
||||
"randomcolor": "^0.6.2",
|
||||
"react": "16.13.1",
|
||||
"react-native": "https://github.com/expo/react-native/archive/sdk-42.0.0.tar.gz",
|
||||
"react-native-dialog": "^6.2.0",
|
||||
"react-native-gesture-handler": "~1.10.2",
|
||||
"react-native-image-zoom-viewer": "^3.0.1",
|
||||
"react-native-reanimated": "~2.2.0",
|
||||
"react-native-router-flux": "^4.3.1",
|
||||
"react-native-safe-area-context": "3.2.0",
|
||||
"react-native-screens": "~3.4.0",
|
||||
"react-redux": "^7.2.2",
|
||||
"redux": "^4.0.0",
|
||||
"socket.io-client": "^4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/randomcolor": "^0.5.5",
|
||||
"@types/react": "^17.0.14",
|
||||
"@types/react-native": "^0.64.12",
|
||||
"@types/react-redux": "^7.1.16",
|
||||
"@types/redux": "^3.6.0",
|
||||
"@types/socket.io-client": "^3.0.0",
|
||||
"expo-cli": "^4.7.3",
|
||||
"jest-expo": "^42.0.0",
|
||||
"react-test-renderer": "16.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { Scene, Router, Stack, Tabs, Lightbox } from 'react-native-router-flux';
|
||||
import { Icon, Root } from 'native-base';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import ChatList from './pages/ChatList/ChatList';
|
||||
import Chat from './pages/Chat/Chat';
|
||||
import Login from './pages/LoginSignup/Login';
|
||||
import Signup from './pages/LoginSignup/Signup';
|
||||
|
||||
import Loading from './components/Loading';
|
||||
import Other from './pages/Other/Other';
|
||||
import Notification from './components/Nofitication';
|
||||
import { State, User } from './types/redux';
|
||||
import SelfInfo from './pages/ChatList/SelfInfo';
|
||||
import ChatBackButton from './pages/Chat/ChatBackButton';
|
||||
import GroupProfile from './pages/GroupProfile/GroupProfile';
|
||||
import ChatRightButton from './pages/Chat/ChatRightButton';
|
||||
import UserInfo from './pages/UserInfo/UserInfo';
|
||||
import ChatListRightButton from './pages/ChatList/ChatListRightButton';
|
||||
import SearchResult from './pages/SearchResult/SearchResult';
|
||||
import GroupInfo from './pages/GroupInfo/GroupInfo';
|
||||
import BackButton from './components/BackButton';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
primaryColor: string;
|
||||
isLogin: boolean;
|
||||
};
|
||||
|
||||
function App({ title, primaryColor, isLogin }: Props) {
|
||||
const primaryColor10 = `rgba(${primaryColor}, 1)`;
|
||||
const primaryColor8 = `rgba(${primaryColor}, 0.8)`;
|
||||
|
||||
const sceneCommonProps = {
|
||||
hideNavBar: false,
|
||||
navigationBarStyle: {
|
||||
backgroundColor: primaryColor10,
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
navBarButtonColor: '#f9f9f9',
|
||||
renderLeftButton: () => <BackButton />,
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Root>
|
||||
<Router>
|
||||
<Stack hideNavBar>
|
||||
<Lightbox>
|
||||
<Tabs
|
||||
key="tabs"
|
||||
hideNavBar
|
||||
tabBarStyle={{
|
||||
backgroundColor: primaryColor8,
|
||||
borderTopWidth: 0,
|
||||
}}
|
||||
showLabel={false}
|
||||
>
|
||||
<Scene
|
||||
key="chatlist"
|
||||
navBarButtonColor="transparent"
|
||||
component={ChatList}
|
||||
initial
|
||||
hideNavBar={!isLogin}
|
||||
icon={({ focused }) => (
|
||||
<Icon
|
||||
name="chatbubble-ellipses-outline"
|
||||
style={{
|
||||
fontSize: 24,
|
||||
color: focused
|
||||
? 'white'
|
||||
: '#bbb',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
renderLeftButton={() => <SelfInfo />}
|
||||
renderRightButton={() => (
|
||||
<ChatListRightButton />
|
||||
)}
|
||||
navigationBarStyle={{
|
||||
backgroundColor: primaryColor10,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
/>
|
||||
<Scene
|
||||
key="other"
|
||||
component={Other}
|
||||
hideNavBar
|
||||
title="其它"
|
||||
icon={({ focused }) => (
|
||||
<Icon
|
||||
name="aperture-outline"
|
||||
style={{
|
||||
fontSize: 24,
|
||||
color: focused
|
||||
? 'white'
|
||||
: '#bbb',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Tabs>
|
||||
</Lightbox>
|
||||
<Scene
|
||||
key="chat"
|
||||
component={Chat}
|
||||
title="聊天"
|
||||
getTitle={title}
|
||||
hideNavBar={false}
|
||||
navigationBarStyle={{
|
||||
backgroundColor: primaryColor10,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
navBarButtonColor="#f9f9f9"
|
||||
renderLeftButton={() => <ChatBackButton />}
|
||||
renderRightButton={() => <ChatRightButton />}
|
||||
/>
|
||||
<Scene
|
||||
key="login"
|
||||
component={Login}
|
||||
title="登录"
|
||||
{...sceneCommonProps}
|
||||
/>
|
||||
<Scene
|
||||
key="signup"
|
||||
component={Signup}
|
||||
title="注册"
|
||||
{...sceneCommonProps}
|
||||
/>
|
||||
<Scene
|
||||
key="groupProfile"
|
||||
component={GroupProfile}
|
||||
title="群组资料"
|
||||
{...sceneCommonProps}
|
||||
/>
|
||||
<Scene
|
||||
key="userInfo"
|
||||
component={UserInfo}
|
||||
title="个人信息"
|
||||
{...sceneCommonProps}
|
||||
/>
|
||||
<Scene
|
||||
key="groupInfo"
|
||||
component={GroupInfo}
|
||||
title="群组信息"
|
||||
{...sceneCommonProps}
|
||||
/>
|
||||
<Scene
|
||||
key="searchResult"
|
||||
component={SearchResult}
|
||||
title="搜索结果"
|
||||
{...sceneCommonProps}
|
||||
/>
|
||||
</Stack>
|
||||
</Router>
|
||||
</Root>
|
||||
|
||||
<Loading />
|
||||
<Notification />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect((state: State) => ({
|
||||
primaryColor: state.ui.primaryColor,
|
||||
isLogin: !!(state.user as User)?._id,
|
||||
}))(App);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 488 KiB |
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { getOSSFileUrl } from '../utils/uploadFile';
|
||||
|
||||
import Image from './Image';
|
||||
|
||||
type Props = {
|
||||
src: string;
|
||||
size: number;
|
||||
};
|
||||
export default function Avatar({ src, size }: Props) {
|
||||
const targetUrl = getOSSFileUrl(
|
||||
src,
|
||||
`image/resize,w_${size * 2},h_${size * 2}/quality,q_90`,
|
||||
) as string;
|
||||
return (
|
||||
<Image
|
||||
src={targetUrl}
|
||||
width={size}
|
||||
height={size}
|
||||
style={{ borderRadius: size / 2 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { View, Icon, Text } from 'native-base';
|
||||
import React from 'react';
|
||||
import { TouchableOpacity } from 'react-native';
|
||||
import { Actions } from 'react-native-router-flux';
|
||||
|
||||
type Props = {
|
||||
text?: string;
|
||||
};
|
||||
|
||||
function BackButton({ text = '' }: Props) {
|
||||
return (
|
||||
<TouchableOpacity onPress={() => Actions.pop()}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Icon
|
||||
name="chevron-back-outline"
|
||||
style={{ color: 'white', fontSize: 28 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
export default BackButton;
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import Image from './Image';
|
||||
import uri from '../assets/images/baidu.png';
|
||||
|
||||
type Props = {
|
||||
size: number;
|
||||
index: number;
|
||||
style?: any;
|
||||
};
|
||||
|
||||
export default function Expression({ size, index, style }: Props) {
|
||||
return (
|
||||
<View
|
||||
style={[{ width: size, height: size, overflow: 'hidden' }, style]}
|
||||
>
|
||||
<Image
|
||||
src={uri}
|
||||
width={size}
|
||||
height={(size * 3200) / 64}
|
||||
style={{ marginTop: -size * index }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Image as BaseImage, ImageSourcePropType } from 'react-native';
|
||||
import { getOSSFileUrl } from '../utils/uploadFile';
|
||||
import { referer } from '../utils/constant';
|
||||
|
||||
type Props = {
|
||||
src: string;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
style?: any;
|
||||
};
|
||||
|
||||
export default function Image({
|
||||
src,
|
||||
width = '100%',
|
||||
height = '100%',
|
||||
style,
|
||||
}: Props) {
|
||||
// @ts-ignore
|
||||
let source: ImageSourcePropType = src;
|
||||
if (typeof src === 'string') {
|
||||
let uri = getOSSFileUrl(src, `image/quality,q_80`);
|
||||
if (width !== '100%' && height !== '100%') {
|
||||
uri = getOSSFileUrl(
|
||||
src,
|
||||
`image/resize,w_${Math.ceil(width as number)},h_${Math.ceil(
|
||||
height as number,
|
||||
)}/quality,q_80`,
|
||||
);
|
||||
}
|
||||
source = {
|
||||
uri: uri as string,
|
||||
cache: 'force-cache',
|
||||
headers: {
|
||||
Referer: referer,
|
||||
},
|
||||
};
|
||||
}
|
||||
return <BaseImage source={source} style={[style, { width, height }]} />;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Dimensions, StyleSheet } from 'react-native';
|
||||
import { Spinner } from 'native-base';
|
||||
import { useStore } from '../hooks/useStore';
|
||||
|
||||
const { width: ScreenWidth, height: ScreenHeight } = Dimensions.get('window');
|
||||
|
||||
export default function Loading() {
|
||||
const { loading } = useStore().ui;
|
||||
if (!loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.loadingView}>
|
||||
<View style={styles.loadingBox}>
|
||||
<Spinner color="white" />
|
||||
<Text style={styles.loadingText}>{loading}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
loadingView: {
|
||||
width: ScreenWidth,
|
||||
height: ScreenHeight,
|
||||
position: 'absolute',
|
||||
backgroundColor: 'rgba(0,0,0,0.15)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loadingBox: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
color: 'white',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import Constants from 'expo-constants';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Platform, AppState } from 'react-native';
|
||||
import { Actions } from 'react-native-router-flux';
|
||||
import { setNotificationToken } from '../service';
|
||||
import action from '../state/action';
|
||||
import { State, User } from '../types/redux';
|
||||
import { isiOS } from '../utils/platform';
|
||||
import { useIsLogin, useStore } from '../hooks/useStore';
|
||||
import store from '../state/store';
|
||||
|
||||
function enableNotification() {
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
function disableNotification() {
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: false,
|
||||
shouldPlaySound: false,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function Nofitication() {
|
||||
const isLogin = useIsLogin();
|
||||
const state = useStore();
|
||||
const notificationTokens = (state.user as User)?.notificationTokens || [];
|
||||
const { connect } = state;
|
||||
|
||||
const [notificationToken, updateNotificationToken] = useState('');
|
||||
|
||||
async function registerForPushNotificationsAsync() {
|
||||
// Push notification to Android device need google service
|
||||
// Not supported in China
|
||||
if (Constants.isDevice && isiOS) {
|
||||
const {
|
||||
status: existingStatus,
|
||||
} = await Notifications.getPermissionsAsync();
|
||||
let finalStatus = existingStatus;
|
||||
if (existingStatus !== 'granted') {
|
||||
const {
|
||||
status,
|
||||
} = await Notifications.requestPermissionsAsync();
|
||||
finalStatus = status;
|
||||
}
|
||||
if (finalStatus !== 'granted') {
|
||||
return;
|
||||
}
|
||||
const token = (await Notifications.getExpoPushTokenAsync()).data;
|
||||
updateNotificationToken(token);
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
Notifications.setNotificationChannelAsync('default', {
|
||||
name: 'default',
|
||||
importance: Notifications.AndroidImportance.MAX,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
lightColor: '#FF231F7C',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
function handleClickNotification(response: any) {
|
||||
const { focus } = response.notification.request.content.data;
|
||||
setTimeout(() => {
|
||||
const currentState = store.getState() as State;
|
||||
const linkmans = currentState.linkmans || [];
|
||||
if (linkmans.find((linkman) => linkman._id === focus)) {
|
||||
action.setFocus(focus);
|
||||
if (Actions.currentScene !== 'chat') {
|
||||
Actions.chat();
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
useEffect(() => {
|
||||
disableNotification();
|
||||
registerForPushNotificationsAsync();
|
||||
|
||||
Notifications.addNotificationResponseReceivedListener(
|
||||
handleClickNotification,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
connect &&
|
||||
isLogin &&
|
||||
notificationToken &&
|
||||
!notificationTokens.includes(notificationToken)
|
||||
) {
|
||||
setNotificationToken(notificationToken);
|
||||
}
|
||||
}, [connect, isLogin, notificationToken]);
|
||||
|
||||
function handleAppStateChange(nextAppState: string) {
|
||||
if (nextAppState === 'active') {
|
||||
disableNotification();
|
||||
} else if (nextAppState === 'background') {
|
||||
enableNotification();
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
AppState.addEventListener('change', handleAppStateChange);
|
||||
return () => {
|
||||
AppState.removeEventListener('change', handleAppStateChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default Nofitication;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { View } from 'native-base';
|
||||
import React from 'react';
|
||||
import { ImageBackground, SafeAreaView, StyleSheet } from 'react-native';
|
||||
|
||||
type Props = {
|
||||
children: any;
|
||||
disableSafeAreaView?: boolean;
|
||||
};
|
||||
|
||||
function PageContainer({ children, disableSafeAreaView = false }: Props) {
|
||||
return (
|
||||
<ImageBackground
|
||||
source={require('../assets/images/background-cool.jpg')}
|
||||
style={styles.backgroundImage}
|
||||
blurRadius={10}
|
||||
>
|
||||
<View style={styles.children}>
|
||||
{disableSafeAreaView ? (
|
||||
children
|
||||
) : (
|
||||
<SafeAreaView style={[styles.container]}>
|
||||
{children}
|
||||
</SafeAreaView>
|
||||
)}
|
||||
</View>
|
||||
</ImageBackground>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageContainer;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
backgroundImage: {
|
||||
flex: 1,
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
children: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(241, 241, 241, 0.6)',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Toast } from 'native-base';
|
||||
|
||||
export default {
|
||||
success(message: string) {
|
||||
Toast.show({
|
||||
text: message,
|
||||
type: 'success',
|
||||
position: 'top',
|
||||
});
|
||||
},
|
||||
warning(message: string) {
|
||||
Toast.show({
|
||||
text: message,
|
||||
type: 'warning',
|
||||
position: 'top',
|
||||
});
|
||||
},
|
||||
danger(message: string) {
|
||||
Toast.show({
|
||||
text: message,
|
||||
type: 'danger',
|
||||
position: 'top',
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import { State, User } from '../types/redux';
|
||||
|
||||
export function useStore() {
|
||||
return useSelector((state: State) => state);
|
||||
}
|
||||
|
||||
export function useUser() {
|
||||
return useStore().user as User;
|
||||
}
|
||||
|
||||
export function useSelfId() {
|
||||
const user = useUser();
|
||||
return (user && user._id) || '';
|
||||
}
|
||||
|
||||
export function useIsLogin() {
|
||||
return !!useSelfId();
|
||||
}
|
||||
|
||||
export function useIsAdmin() {
|
||||
const user = useUser();
|
||||
return (user && user.isAdmin) || false;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const { ui } = useStore();
|
||||
const { primaryColor, primaryTextColor } = ui;
|
||||
return {
|
||||
primaryColor8: `rgba(${primaryColor}, 0.8)`,
|
||||
primaryColor10: `rgba(${primaryColor}, 1)`,
|
||||
primaryTextColor10: `rgba(${primaryTextColor}, 1)`,
|
||||
};
|
||||
}
|
||||
|
||||
export function useLinkmans() {
|
||||
const data = useStore();
|
||||
return data.linkmans || [];
|
||||
}
|
||||
|
||||
export function useFocusLinkman() {
|
||||
const data = useStore();
|
||||
const { linkmans, focus = '' } = data;
|
||||
if (linkmans) {
|
||||
return linkmans.find((linkman) => linkman._id === focus);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useFocus() {
|
||||
const data = useStore();
|
||||
return data.focus || '';
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
KeyboardAvoidingView,
|
||||
ScrollView,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import Constants from 'expo-constants';
|
||||
import { Actions } from 'react-native-router-flux';
|
||||
|
||||
import { isiOS } from '../../utils/platform';
|
||||
|
||||
import MessageList from './MessageList';
|
||||
import Input from './Input';
|
||||
import PageContainer from '../../components/PageContainer';
|
||||
import { Friend, Group, Linkman } from '../../types/redux';
|
||||
import {
|
||||
useFocusLinkman,
|
||||
useIsLogin,
|
||||
useSelfId,
|
||||
useStore,
|
||||
} from '../../hooks/useStore';
|
||||
import {
|
||||
getDefaultGroupOnlineMembers,
|
||||
getGroupOnlineMembers,
|
||||
getUserOnlineStatus,
|
||||
} from '../../service';
|
||||
import action from '../../state/action';
|
||||
import { formatLinkmanName } from '../../utils/linkman';
|
||||
import fetch from '../../utils/fetch';
|
||||
|
||||
let lastMessageIdCache = '';
|
||||
|
||||
const keyboardOffset = (() => {
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const screenRatio = height / width;
|
||||
if (screenRatio === 667 / 375) {
|
||||
// iPhone 6 / 7 / 8
|
||||
return 64;
|
||||
}
|
||||
if (screenRatio === 736 / 414) {
|
||||
// iPhone 6 / 7 / 8 PLUS
|
||||
return 64;
|
||||
}
|
||||
if (screenRatio === 812 / 375) {
|
||||
// iPhone X / 12mini
|
||||
return 86;
|
||||
}
|
||||
if (screenRatio === 896 / 414) {
|
||||
// iPhone Xr / 11 / 11 Pro Max
|
||||
return 86;
|
||||
}
|
||||
if (screenRatio === 844 / 390) {
|
||||
// iPhone 12 / 12 Prop
|
||||
return 64;
|
||||
}
|
||||
if (screenRatio === 926 / 428) {
|
||||
// iPhone 12 Pro Max
|
||||
return 64;
|
||||
}
|
||||
return Constants.statusBarHeight + 44;
|
||||
})();
|
||||
|
||||
export default function Chat() {
|
||||
const isLogin = useIsLogin();
|
||||
const self = useSelfId();
|
||||
const { focus } = useStore();
|
||||
const linkman = useFocusLinkman();
|
||||
const $messageList = useRef<ScrollView>();
|
||||
|
||||
async function fetchGroupOnlineMembers() {
|
||||
let onlineMembers: Group['members'] = [];
|
||||
if (isLogin) {
|
||||
onlineMembers = await getGroupOnlineMembers(focus);
|
||||
} else {
|
||||
onlineMembers = await getDefaultGroupOnlineMembers();
|
||||
}
|
||||
if (onlineMembers) {
|
||||
action.updateGroupProperty(focus, 'members', onlineMembers);
|
||||
}
|
||||
}
|
||||
async function fetchUserOnlineStatus() {
|
||||
const isOnline = await getUserOnlineStatus(focus.replace(self, ''));
|
||||
action.updateFriendProperty(focus, 'isOnline', isOnline);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!linkman || !isLogin) {
|
||||
return;
|
||||
}
|
||||
const request =
|
||||
linkman.type === 'group'
|
||||
? fetchGroupOnlineMembers
|
||||
: fetchUserOnlineStatus;
|
||||
request();
|
||||
const timer = setInterval(() => request(), 1000 * 60);
|
||||
return () => clearInterval(timer);
|
||||
}, [focus, isLogin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Actions.currentScene !== 'chat') {
|
||||
return;
|
||||
}
|
||||
Actions.refresh({
|
||||
title: formatLinkmanName(linkman as Linkman),
|
||||
});
|
||||
}, [(linkman as Group).members, (linkman as Friend).isOnline]);
|
||||
|
||||
async function intervalUpdateHistory() {
|
||||
if (isLogin && linkman) {
|
||||
if (linkman.messages.length > 0) {
|
||||
const lastMessageId =
|
||||
linkman.messages[linkman.messages.length - 1]._id;
|
||||
if (lastMessageId !== lastMessageIdCache) {
|
||||
lastMessageIdCache = lastMessageId;
|
||||
await fetch('updateHistory', {
|
||||
linkmanId: focus,
|
||||
messageId: lastMessageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
const timer = setInterval(intervalUpdateHistory, 1000 * 5);
|
||||
return () => clearInterval(timer);
|
||||
}, [focus]);
|
||||
|
||||
function scrollToEnd(time = 0) {
|
||||
if (time > 200) {
|
||||
return;
|
||||
}
|
||||
if ($messageList.current) {
|
||||
$messageList.current!.scrollToEnd({ animated: false });
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToEnd(time + 50);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function handleInputHeightChange() {
|
||||
if ($messageList.current) {
|
||||
scrollToEnd();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer disableSafeAreaView>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={isiOS ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={keyboardOffset}
|
||||
>
|
||||
{/*
|
||||
// @ts-ignore */}
|
||||
<MessageList $scrollView={$messageList} />
|
||||
<Input onHeightChange={handleInputHeightChange} />
|
||||
</KeyboardAvoidingView>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import BackButton from '../../components/BackButton';
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
|
||||
function ChatBackButton() {
|
||||
const store = useStore();
|
||||
const unread = store.linkmans.reduce((result, linkman) => {
|
||||
result += linkman.unread;
|
||||
return result;
|
||||
}, 0);
|
||||
|
||||
return <BackButton text={unread.toString()} />;
|
||||
}
|
||||
|
||||
export default ChatBackButton;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { View, Icon } from 'native-base';
|
||||
import React from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { Actions } from 'react-native-router-flux';
|
||||
import { useFocusLinkman } from '../../hooks/useStore';
|
||||
|
||||
function ChatRightButton() {
|
||||
const linkman = useFocusLinkman();
|
||||
|
||||
function handleClick() {
|
||||
if (linkman?.type === 'group') {
|
||||
Actions.push('groupProfile');
|
||||
} else {
|
||||
Actions.push('userInfo', { user: linkman });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handleClick}>
|
||||
<View style={styles.container}>
|
||||
<Icon name="ellipsis-horizontal" style={styles.icon} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatRightButton;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
icon: {
|
||||
color: 'white',
|
||||
fontSize: 26,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { View } from 'native-base';
|
||||
import React from 'react';
|
||||
import { Dimensions, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import Image from '../../components/Image';
|
||||
import { Message } from '../../types/redux';
|
||||
|
||||
const { width: ScreenWidth } = Dimensions.get('window');
|
||||
|
||||
type Props = {
|
||||
message: Message;
|
||||
openImageViewer: (imageUrl: string) => void;
|
||||
couldDelete: boolean;
|
||||
onLongPress: () => void;
|
||||
};
|
||||
|
||||
function ImageMessage({
|
||||
message,
|
||||
openImageViewer,
|
||||
couldDelete,
|
||||
onLongPress,
|
||||
}: Props) {
|
||||
const maxWidth = ScreenWidth - 130 - 16;
|
||||
const maxHeight = 200;
|
||||
let scale = 1;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
const parseResult = /width=([0-9]+)&height=([0-9]+)/.exec(message.content);
|
||||
if (parseResult) {
|
||||
width = parseInt(parseResult[1], 10);
|
||||
height = parseInt(parseResult[2], 10);
|
||||
if (width * scale > maxWidth) {
|
||||
scale = maxWidth / width;
|
||||
}
|
||||
if (height * scale > maxHeight) {
|
||||
scale = maxHeight / height;
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageClick() {
|
||||
const imageUrl = message.content;
|
||||
openImageViewer(imageUrl);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{ width: width * scale, height: height * scale },
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={handleImageClick}
|
||||
{...(couldDelete ? { onLongPress } : {})}
|
||||
>
|
||||
<Image
|
||||
src={message.content}
|
||||
style={{ width: width * scale, height: height * scale }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageMessage;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
height: 200,
|
||||
width: ScreenWidth - 130 - 16,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,384 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
TextInput,
|
||||
Text,
|
||||
Dimensions,
|
||||
TouchableOpacity,
|
||||
SafeAreaView,
|
||||
} from 'react-native';
|
||||
import { Button } from 'native-base';
|
||||
import { Actions } from 'react-native-router-flux';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
|
||||
import action from '../../state/action';
|
||||
import fetch from '../../utils/fetch';
|
||||
import { isiOS } from '../../utils/platform';
|
||||
import expressions from '../../utils/expressions';
|
||||
|
||||
import Expression from '../../components/Expression';
|
||||
import { useIsLogin, useStore, useUser } from '../../hooks/useStore';
|
||||
import { Message } from '../../types/redux';
|
||||
import uploadFile from '../../utils/uploadFile';
|
||||
|
||||
const { width: ScreenWidth } = Dimensions.get('window');
|
||||
const ExpressionSize = (ScreenWidth - 16) / 10;
|
||||
|
||||
type Props = {
|
||||
onHeightChange: () => void;
|
||||
};
|
||||
|
||||
export default function Input({ onHeightChange }: Props) {
|
||||
const isLogin = useIsLogin();
|
||||
const user = useUser();
|
||||
const { focus } = useStore();
|
||||
|
||||
const [message, setMessage] = useState('');
|
||||
const [showFunctionList, toggleShowFunctionList] = useState(true);
|
||||
const [showExpression, toggleShowExpression] = useState(false);
|
||||
const [cursorPosition, setCursorPosition] = useState({ start: 0, end: 0 });
|
||||
|
||||
const $input = useRef<TextInput>();
|
||||
|
||||
function setInputText(text = '') {
|
||||
// iossetNativeProps无效, 解决办法参考:https://github.com/facebook/react-native/issues/18272
|
||||
if (isiOS) {
|
||||
$input.current!.setNativeProps({ text: text || ' ' });
|
||||
}
|
||||
setTimeout(() => {
|
||||
$input.current!.setNativeProps({ text: text || '' });
|
||||
});
|
||||
}
|
||||
|
||||
function addSelfMessage(type: string, content: string) {
|
||||
const _id = focus + Date.now();
|
||||
const newMessage: Message = {
|
||||
_id,
|
||||
type,
|
||||
content,
|
||||
createTime: Date.now(),
|
||||
from: {
|
||||
_id: user._id,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
tag: user.tag,
|
||||
},
|
||||
to: '',
|
||||
loading: true,
|
||||
};
|
||||
|
||||
if (type === 'image') {
|
||||
newMessage.percent = 0;
|
||||
}
|
||||
action.addLinkmanMessage(focus, newMessage);
|
||||
|
||||
return _id;
|
||||
}
|
||||
|
||||
async function sendMessage(localId: string, type: string, content: string) {
|
||||
const [err, res] = await fetch('sendMessage', {
|
||||
to: focus,
|
||||
type,
|
||||
content,
|
||||
});
|
||||
if (!err) {
|
||||
res.loading = false;
|
||||
action.updateSelfMessage(focus, localId, res);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (message === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = addSelfMessage('text', message);
|
||||
sendMessage(id, 'text', message);
|
||||
|
||||
setMessage('');
|
||||
toggleShowFunctionList(true);
|
||||
toggleShowExpression(false);
|
||||
setInputText();
|
||||
}
|
||||
|
||||
function handleSelectionChange(event: any) {
|
||||
const { start, end } = event.nativeEvent.selection;
|
||||
setCursorPosition({
|
||||
start,
|
||||
end,
|
||||
});
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
toggleShowFunctionList(true);
|
||||
toggleShowExpression(false);
|
||||
}
|
||||
|
||||
function openExpression() {
|
||||
$input.current!.blur();
|
||||
|
||||
toggleShowFunctionList(false);
|
||||
toggleShowExpression(true);
|
||||
|
||||
onHeightChange();
|
||||
}
|
||||
|
||||
async function handleClickImage() {
|
||||
const currentPermission = await ImagePicker.getMediaLibraryPermissionsAsync();
|
||||
if (currentPermission.accessPrivileges === 'none') {
|
||||
if (currentPermission.canAskAgain) {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (permission.accessPrivileges === 'none') {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
base64: true,
|
||||
});
|
||||
|
||||
if (!result.cancelled) {
|
||||
const id = addSelfMessage(
|
||||
'image',
|
||||
`${result.uri}?width=${result.width}&height=${result.height}`,
|
||||
);
|
||||
const key = `ImageMessage/${user._id}_${Date.now()}`;
|
||||
const imageUrl = await uploadFile(
|
||||
result.base64 as string,
|
||||
key,
|
||||
true,
|
||||
);
|
||||
sendMessage(
|
||||
id,
|
||||
'image',
|
||||
`${imageUrl}?width=${result.width}&height=${result.height}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClickCamera() {
|
||||
const currentPermission = await ImagePicker.getCameraPermissionsAsync();
|
||||
if (currentPermission.status === 'undetermined') {
|
||||
if (currentPermission.canAskAgain) {
|
||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (permission.status === 'undetermined') {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
base64: true,
|
||||
});
|
||||
|
||||
if (!result.cancelled) {
|
||||
const id = addSelfMessage(
|
||||
'image',
|
||||
`${result.uri}?width=${result.width}&height=${result.height}`,
|
||||
);
|
||||
const key = `ImageMessage/${user._id}_${Date.now()}`;
|
||||
const imageUrl = await uploadFile(
|
||||
result.base64 as string,
|
||||
key,
|
||||
true,
|
||||
);
|
||||
sendMessage(
|
||||
id,
|
||||
'image',
|
||||
`${imageUrl}?width=${result.width}&height=${result.height}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleChangeText(value: string) {
|
||||
setMessage(value);
|
||||
}
|
||||
|
||||
function insertExpression(e: string) {
|
||||
const expression = `#(${e})`;
|
||||
const newValue = `${message.substring(
|
||||
0,
|
||||
cursorPosition.start,
|
||||
)}${expression}${message.substring(
|
||||
cursorPosition.end,
|
||||
message.length,
|
||||
)}`;
|
||||
setMessage(newValue);
|
||||
setCursorPosition({
|
||||
start: cursorPosition.start + expression.length,
|
||||
end: cursorPosition.start + expression.length,
|
||||
});
|
||||
setInputText(newValue);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safeView}>
|
||||
<View style={styles.container}>
|
||||
{isLogin ? (
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
// @ts-ignore
|
||||
ref={$input}
|
||||
style={styles.input}
|
||||
placeholder="随便聊点啥吧, 不要无意义刷屏~~"
|
||||
onChangeText={handleChangeText}
|
||||
onSubmitEditing={handleSubmit}
|
||||
autoCapitalize="none"
|
||||
blurOnSubmit={false}
|
||||
maxLength={2048}
|
||||
returnKeyType="send"
|
||||
enablesReturnKeyAutomatically
|
||||
underlineColorAndroid="transparent"
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onFocus={handleFocus}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<Button block style={styles.button} onPress={Actions.login}>
|
||||
<Text style={styles.buttonText}>
|
||||
登录 / 注册, 参与聊天
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
{isLogin && showFunctionList ? (
|
||||
<View style={styles.iconButtonContainer}>
|
||||
<Button
|
||||
transparent
|
||||
style={styles.iconButton}
|
||||
onPress={openExpression}
|
||||
>
|
||||
<Ionicons name="ios-happy" size={28} color="#999" />
|
||||
</Button>
|
||||
<Button
|
||||
transparent
|
||||
style={styles.iconButton}
|
||||
onPress={handleClickImage}
|
||||
>
|
||||
<Ionicons name="ios-image" size={28} color="#999" />
|
||||
</Button>
|
||||
<Button
|
||||
transparent
|
||||
style={styles.iconButton}
|
||||
onPress={handleClickCamera}
|
||||
>
|
||||
<Ionicons
|
||||
name="ios-camera"
|
||||
size={28}
|
||||
color="#999"
|
||||
/>
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{showExpression ? (
|
||||
<View style={styles.expressionContainer}>
|
||||
{expressions.default.map((e, i) => (
|
||||
<TouchableOpacity
|
||||
key={e}
|
||||
onPress={() => insertExpression(e)}
|
||||
>
|
||||
<View style={styles.expression}>
|
||||
<Expression index={i} size={30} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeView: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.5)',
|
||||
},
|
||||
container: {
|
||||
paddingTop: 4,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: 36,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
backgroundColor: 'white',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5e5',
|
||||
borderRadius: 5,
|
||||
},
|
||||
sendButton: {
|
||||
width: 50,
|
||||
height: 36,
|
||||
marginLeft: 8,
|
||||
paddingLeft: 10,
|
||||
},
|
||||
button: {
|
||||
height: 36,
|
||||
marginTop: 4,
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
marginBottom: 8,
|
||||
},
|
||||
buttonText: {
|
||||
color: 'white',
|
||||
},
|
||||
iconContainer: {
|
||||
height: 40,
|
||||
},
|
||||
icon: {
|
||||
transform: [
|
||||
{
|
||||
// @ts-ignore
|
||||
translate: [0, -3],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
iconButtonContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingLeft: 15,
|
||||
paddingRight: 15,
|
||||
height: 44,
|
||||
},
|
||||
iconButton: {
|
||||
width: '15%',
|
||||
},
|
||||
|
||||
cancelButton: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e6e6e6',
|
||||
},
|
||||
cancelButtonText: {
|
||||
color: '#666',
|
||||
},
|
||||
|
||||
// 表情框
|
||||
expressionContainer: {
|
||||
height: (isiOS ? 34 : 30) * 5 + 6,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
paddingTop: 3,
|
||||
paddingBottom: 3,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
},
|
||||
expression: {
|
||||
width: ExpressionSize,
|
||||
height: isiOS ? 34 : 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { View, Text } from 'native-base';
|
||||
import React from 'react';
|
||||
import { StyleSheet, TouchableNativeFeedback } from 'react-native';
|
||||
import { Actions } from 'react-native-router-flux';
|
||||
import Toast from '../../components/Toast';
|
||||
import { getLinkmanHistoryMessages, joinGroup } from '../../service';
|
||||
import action from '../../state/action';
|
||||
import { Message } from '../../types/redux';
|
||||
|
||||
type Props = {
|
||||
message: Message;
|
||||
isSelf: boolean;
|
||||
};
|
||||
|
||||
function InviteMessage({ message, isSelf }: Props) {
|
||||
const invite = JSON.parse(message.content);
|
||||
|
||||
async function handleJoinGroup() {
|
||||
const group = await joinGroup(invite.group);
|
||||
if (group) {
|
||||
group.type = 'group';
|
||||
action.addLinkman(group, true);
|
||||
Actions.refresh({ title: group.name });
|
||||
Toast.success('加入群组成功');
|
||||
const messages = await getLinkmanHistoryMessages(invite.group, 0);
|
||||
if (messages) {
|
||||
action.addLinkmanHistoryMessages(invite.group, messages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableNativeFeedback onPress={handleJoinGroup}>
|
||||
<View style={styles.container}>
|
||||
<View
|
||||
style={[
|
||||
styles.info,
|
||||
{ borderBottomColor: isSelf ? 'white' : '#aaa' },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.text}>
|
||||
"
|
||||
{invite.inviterName}
|
||||
" 邀请你加入群组「
|
||||
{invite.groupName}」
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.join}>
|
||||
<Text style={styles.text}>加入</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableNativeFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteMessage;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '90%',
|
||||
alignItems: 'center',
|
||||
},
|
||||
text: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
lineHeight: 16,
|
||||
},
|
||||
info: {
|
||||
width: '100%',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'white',
|
||||
paddingBottom: 4,
|
||||
},
|
||||
join: {
|
||||
width: '100%',
|
||||
paddingTop: 4,
|
||||
paddingBottom: 2,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import Triangle from '@react-native-toolkit/triangle';
|
||||
|
||||
import { ActionSheet } from 'native-base';
|
||||
import { Actions } from 'react-native-router-flux';
|
||||
import Time from '../../utils/time';
|
||||
import Avatar from '../../components/Avatar';
|
||||
import { Message as MessageType } from '../../types/redux';
|
||||
import SystemMessage from './SystemMessage';
|
||||
import ImageMessage from './ImageMessage';
|
||||
import TextMessage from './TextMessage';
|
||||
import { getRandomColor } from '../../utils/getRandomColor';
|
||||
import InviteMessage from './InviteMessage';
|
||||
import {
|
||||
useFocus,
|
||||
useIsAdmin,
|
||||
useSelfId,
|
||||
useTheme,
|
||||
} from '../../hooks/useStore';
|
||||
import { deleteMessage } from '../../service';
|
||||
import action from '../../state/action';
|
||||
|
||||
const { width: ScreenWidth } = Dimensions.get('window');
|
||||
|
||||
type Props = {
|
||||
message: MessageType;
|
||||
isSelf: boolean;
|
||||
shouldScroll: boolean;
|
||||
scrollToEnd: () => void;
|
||||
openImageViewer: (imageUrl: string) => void;
|
||||
};
|
||||
|
||||
function Message({
|
||||
message,
|
||||
isSelf,
|
||||
shouldScroll,
|
||||
scrollToEnd,
|
||||
openImageViewer,
|
||||
}: Props) {
|
||||
const { primaryColor8 } = useTheme();
|
||||
const isAdmin = useIsAdmin();
|
||||
const self = useSelfId();
|
||||
const focus = useFocus();
|
||||
|
||||
const couldDelete =
|
||||
message.type !== 'system' && (isAdmin || message.from._id === self);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldScroll) {
|
||||
scrollToEnd();
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleDeleteMessage() {
|
||||
const options = ['撤回', '取消'];
|
||||
ActionSheet.show(
|
||||
{
|
||||
options: ['确定', '取消'],
|
||||
cancelButtonIndex: options.findIndex(
|
||||
(option) => option === '取消',
|
||||
),
|
||||
title: '是否撤回消息?',
|
||||
},
|
||||
async (buttonIndex) => {
|
||||
switch (buttonIndex) {
|
||||
case 0: {
|
||||
const isSuccess = await deleteMessage(message._id);
|
||||
if (isSuccess) {
|
||||
action.deleteLinkmanMessage(focus, message._id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime() {
|
||||
const createTime = new Date(message.createTime);
|
||||
const nowTime = new Date();
|
||||
if (Time.isToday(nowTime, createTime)) {
|
||||
return Time.getHourMinute(createTime);
|
||||
}
|
||||
if (Time.isYesterday(nowTime, createTime)) {
|
||||
return `昨天 ${Time.getHourMinute(createTime)}`;
|
||||
}
|
||||
if (Time.isSameYear(nowTime, createTime)) {
|
||||
return `${Time.getMonthDate(createTime)} ${Time.getHourMinute(
|
||||
createTime,
|
||||
)}`;
|
||||
}
|
||||
return `${Time.getYearMonthDate(createTime)} ${Time.getHourMinute(
|
||||
createTime,
|
||||
)}`;
|
||||
}
|
||||
|
||||
function handleClickAvatar() {
|
||||
Actions.push('userInfo', { user: message.from });
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
switch (message.type) {
|
||||
case 'text': {
|
||||
return <TextMessage message={message} isSelf={isSelf} />;
|
||||
}
|
||||
case 'image': {
|
||||
return (
|
||||
<ImageMessage
|
||||
message={message}
|
||||
openImageViewer={openImageViewer}
|
||||
couldDelete={couldDelete}
|
||||
onLongPress={handleDeleteMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'system': {
|
||||
return <SystemMessage message={message} />;
|
||||
}
|
||||
case 'inviteV2': {
|
||||
return <InviteMessage message={message} isSelf={isSelf} />;
|
||||
}
|
||||
case 'file':
|
||||
case 'code': {
|
||||
return (
|
||||
<Text style={{ color: isSelf ? 'white' : '#666' }}>
|
||||
暂未支持的消息类型[
|
||||
{message.type}
|
||||
], 请在Web端查看
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<Text style={{ color: isSelf ? 'white' : '#666' }}>
|
||||
不支持的消息类型
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isSelf && styles.containerSelf]}>
|
||||
{isSelf ? (
|
||||
<Avatar src={message.from.avatar} size={44} />
|
||||
) : (
|
||||
<TouchableOpacity onPress={handleClickAvatar}>
|
||||
<Avatar src={message.from.avatar} size={44} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<View style={[styles.info, isSelf && styles.infoSelf]}>
|
||||
<View style={[styles.nickTime, isSelf && styles.nickTimeSelf]}>
|
||||
{!!message.from.tag && (
|
||||
<View
|
||||
style={[
|
||||
styles.tag,
|
||||
{
|
||||
backgroundColor: getRandomColor(
|
||||
message.from.tag,
|
||||
),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.tagText}>
|
||||
{message.from.tag}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
styles.nick,
|
||||
isSelf ? styles.nickSelf : styles.nickOther,
|
||||
]}
|
||||
>
|
||||
{message.from.username}
|
||||
</Text>
|
||||
<Text style={[styles.time, isSelf && styles.timeSelf]}>
|
||||
{formatTime()}
|
||||
</Text>
|
||||
</View>
|
||||
{couldDelete ? (
|
||||
<TouchableOpacity onLongPress={handleDeleteMessage}>
|
||||
<View
|
||||
style={[
|
||||
styles.content,
|
||||
{
|
||||
backgroundColor: isSelf
|
||||
? primaryColor8
|
||||
: 'white',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{renderContent()}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.content,
|
||||
{
|
||||
backgroundColor: isSelf
|
||||
? primaryColor8
|
||||
: 'white',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{renderContent()}
|
||||
</View>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
styles.triangle,
|
||||
isSelf ? styles.triangleSelf : styles.triangleOther,
|
||||
]}
|
||||
>
|
||||
<Triangle
|
||||
type="isosceles"
|
||||
mode={isSelf ? 'right' : 'left'}
|
||||
base={10}
|
||||
height={5}
|
||||
color={isSelf ? primaryColor8 : 'white'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Message);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 6,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
},
|
||||
containerSelf: {
|
||||
flexDirection: 'row-reverse',
|
||||
},
|
||||
info: {
|
||||
position: 'relative',
|
||||
marginLeft: 8,
|
||||
marginRight: 8,
|
||||
maxWidth: ScreenWidth - 120,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
infoSelf: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
nickTime: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
nickTimeSelf: {
|
||||
flexDirection: 'row-reverse',
|
||||
},
|
||||
nick: {
|
||||
fontSize: 13,
|
||||
color: '#333',
|
||||
},
|
||||
nickSelf: {
|
||||
marginRight: 4,
|
||||
},
|
||||
nickOther: {
|
||||
marginLeft: 4,
|
||||
},
|
||||
time: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginLeft: 4,
|
||||
},
|
||||
timeSelf: {
|
||||
marginRight: 4,
|
||||
},
|
||||
content: {
|
||||
marginTop: 3,
|
||||
borderRadius: 6,
|
||||
padding: 5,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
backgroundColor: 'white',
|
||||
minHeight: 26,
|
||||
minWidth: 20,
|
||||
marginBottom: 6,
|
||||
},
|
||||
triangle: {
|
||||
position: 'absolute',
|
||||
top: 25,
|
||||
},
|
||||
triangleSelf: {
|
||||
right: -5,
|
||||
},
|
||||
triangleOther: {
|
||||
left: -5,
|
||||
},
|
||||
tag: {
|
||||
height: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingLeft: 3,
|
||||
paddingRight: 3,
|
||||
borderRadius: 3,
|
||||
},
|
||||
tagText: {
|
||||
fontSize: 11,
|
||||
color: 'white',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Keyboard, Modal, Image } from 'react-native';
|
||||
import ImageViewer from 'react-native-image-zoom-viewer';
|
||||
|
||||
import action from '../../state/action';
|
||||
import fetch from '../../utils/fetch';
|
||||
|
||||
import Message from './Message';
|
||||
import {
|
||||
useFocusLinkman,
|
||||
useIsLogin,
|
||||
useSelfId,
|
||||
useStore,
|
||||
} from '../../hooks/useStore';
|
||||
import { Message as MessageType } from '../../types/redux';
|
||||
import Toast from '../../components/Toast';
|
||||
import { isAndroid, isiOS } from '../../utils/platform';
|
||||
import { referer } from '../../utils/constant';
|
||||
|
||||
type Props = {
|
||||
$scrollView: React.MutableRefObject<ScrollView>;
|
||||
};
|
||||
|
||||
let prevContentHeight = 0;
|
||||
let prevMessageCount = 0;
|
||||
let shouldScroll = true;
|
||||
let isFirstTimeFetchHistory = true;
|
||||
|
||||
function MessageList({ $scrollView }: Props) {
|
||||
const isLogin = useIsLogin();
|
||||
const self = useSelfId();
|
||||
const focusLinkman = useFocusLinkman();
|
||||
const { focus } = useStore();
|
||||
const messages = focusLinkman?.messages || [];
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [showImageViewerDialog, toggleShowImageViewerDialog] = useState(
|
||||
false,
|
||||
);
|
||||
const [imageViewerIndex, setImageViewerIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const keyboardDidShowListener = Keyboard.addListener(
|
||||
'keyboardWillShow',
|
||||
handleKeyboardShow,
|
||||
);
|
||||
|
||||
return () => {
|
||||
prevContentHeight = 0;
|
||||
prevMessageCount = 0;
|
||||
shouldScroll = true;
|
||||
isFirstTimeFetchHistory = true;
|
||||
keyboardDidShowListener.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function getImages() {
|
||||
const imageMessages = messages.filter(
|
||||
(message) => message.type === 'image',
|
||||
);
|
||||
const images = imageMessages.map((message) => {
|
||||
const url = message.content;
|
||||
const parseResult = /width=(\d+)&height=(\d+)/.exec(url);
|
||||
return {
|
||||
url: `${url.startsWith('//') ? 'https:' : ''}${url}`,
|
||||
...(parseResult
|
||||
? {
|
||||
width: +parseResult[1],
|
||||
height: +parseResult[2],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
return images;
|
||||
}
|
||||
|
||||
function scrollToEnd(time = 0) {
|
||||
if (time > 200) {
|
||||
return;
|
||||
}
|
||||
if ($scrollView.current) {
|
||||
$scrollView.current!.scrollToEnd({ animated: false });
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToEnd(time + 50);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function handleKeyboardShow() {
|
||||
scrollToEnd();
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
if (refreshing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFirstTimeFetchHistory && isAndroid) {
|
||||
isFirstTimeFetchHistory = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setRefreshing(true);
|
||||
|
||||
let err = null;
|
||||
let result = null;
|
||||
if (isLogin) {
|
||||
[err, result] = await fetch('getLinkmanHistoryMessages', {
|
||||
linkmanId: focus,
|
||||
existCount: messages.length,
|
||||
});
|
||||
} else {
|
||||
[err, result] = await fetch('getDefalutGroupHistoryMessages', {
|
||||
existCount: messages.length,
|
||||
});
|
||||
}
|
||||
if (!err) {
|
||||
if (result.length > 0) {
|
||||
action.addLinkmanHistoryMessages(focus, result);
|
||||
} else {
|
||||
Toast.warning('没有更多消息了');
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setRefreshing(false);
|
||||
}, 1000);
|
||||
}
|
||||
/**
|
||||
* 加载历史消息后, 自动滚动到合适位置
|
||||
*/
|
||||
function handleContentSizeChange(
|
||||
contentWidth: number,
|
||||
contentHeight: number,
|
||||
) {
|
||||
if (prevContentHeight === 0) {
|
||||
$scrollView.current!.scrollTo({
|
||||
x: 0,
|
||||
y: 0,
|
||||
animated: false,
|
||||
});
|
||||
} else if (
|
||||
contentHeight !== prevContentHeight &&
|
||||
messages.length - prevMessageCount > 1
|
||||
) {
|
||||
$scrollView.current!.scrollTo({
|
||||
x: 0,
|
||||
y: contentHeight - prevContentHeight - 60,
|
||||
animated: false,
|
||||
});
|
||||
}
|
||||
prevContentHeight = contentHeight;
|
||||
prevMessageCount = messages.length;
|
||||
}
|
||||
|
||||
function handleScroll(event: any) {
|
||||
const {
|
||||
layoutMeasurement,
|
||||
contentSize,
|
||||
contentOffset,
|
||||
} = event.nativeEvent;
|
||||
shouldScroll =
|
||||
contentOffset.y >
|
||||
contentSize.height - layoutMeasurement.height * 1.2;
|
||||
|
||||
if (contentOffset.y < (isiOS ? 0 : 50)) {
|
||||
handleRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
function openImageViewer(url: string) {
|
||||
const images = getImages();
|
||||
const index = images.findIndex(
|
||||
(image) => image.url.indexOf(url) !== -1,
|
||||
);
|
||||
toggleShowImageViewerDialog(true);
|
||||
setImageViewerIndex(index);
|
||||
}
|
||||
|
||||
function renderMessage(message: MessageType) {
|
||||
return (
|
||||
<Message
|
||||
key={message._id}
|
||||
message={message}
|
||||
isSelf={self === message.from._id}
|
||||
shouldScroll={shouldScroll}
|
||||
scrollToEnd={scrollToEnd}
|
||||
openImageViewer={openImageViewer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function closeImageViewerDialog() {
|
||||
toggleShowImageViewerDialog(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
ref={$scrollView}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
scrollEventThrottle={50}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{messages.map((message) => renderMessage(message))}
|
||||
<Modal
|
||||
visible={showImageViewerDialog}
|
||||
transparent
|
||||
onRequestClose={closeImageViewerDialog}
|
||||
>
|
||||
<ImageViewer
|
||||
imageUrls={getImages()}
|
||||
index={imageViewerIndex}
|
||||
onClick={closeImageViewerDialog}
|
||||
onSwipeDown={closeImageViewerDialog}
|
||||
saveToLocalByLongPress={false}
|
||||
renderImage={(image) => (
|
||||
<Image
|
||||
source={{
|
||||
uri: image.source.uri,
|
||||
cache: 'force-cache',
|
||||
headers: {
|
||||
Referer: referer,
|
||||
},
|
||||
}}
|
||||
style={image.style}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Modal>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
export default MessageList;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { View, Text } from 'native-base';
|
||||
import React from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { Message } from '../../types/redux';
|
||||
import { getPerRandomColor } from '../../utils/getRandomColor';
|
||||
|
||||
type Props = {
|
||||
message: Message;
|
||||
};
|
||||
|
||||
function SystemMessage({ message }: Props) {
|
||||
const { content, from } = message;
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text
|
||||
style={[
|
||||
styles.text,
|
||||
{ color: getPerRandomColor(from.originUsername as string) },
|
||||
]}
|
||||
>
|
||||
{from.originUsername}
|
||||
|
||||
</Text>
|
||||
<Text style={styles.text}>{content}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default SystemMessage;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
text: {
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { View, Text } from 'native-base';
|
||||
import React from 'react';
|
||||
import { TouchableOpacity, Linking, StyleSheet } from 'react-native';
|
||||
import Expression from '../../components/Expression';
|
||||
import { Message } from '../../types/redux';
|
||||
import expressions from '../../utils/expressions';
|
||||
|
||||
type Props = {
|
||||
message: Message;
|
||||
isSelf: boolean;
|
||||
};
|
||||
|
||||
function TextMessage({ message, isSelf }: Props) {
|
||||
const children = [];
|
||||
let copy = message.content;
|
||||
|
||||
function push(str: string) {
|
||||
children.push(
|
||||
<Text
|
||||
key={Math.random()}
|
||||
style={{ color: isSelf ? 'white' : '#444' }}
|
||||
>
|
||||
{str}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
|
||||
// 处理文本消息中的表情和链接
|
||||
let offset = 0;
|
||||
while (copy.length > 0) {
|
||||
const regex = /#\(([\u4e00-\u9fa5a-z]+)\)|https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/g;
|
||||
const matchResult = regex.exec(copy);
|
||||
if (matchResult) {
|
||||
const r = matchResult[0];
|
||||
const e = matchResult[1];
|
||||
const i = copy.indexOf(r);
|
||||
if (r[0] === '#') {
|
||||
// 表情消息
|
||||
const index = expressions.default.indexOf(e);
|
||||
if (index !== -1) {
|
||||
// 处理从开头到匹配位置的文本
|
||||
if (i > 0) {
|
||||
push(copy.substring(0, i));
|
||||
}
|
||||
children.push(
|
||||
<Expression
|
||||
key={Math.random()}
|
||||
style={styles.expression}
|
||||
size={30}
|
||||
index={index}
|
||||
/>,
|
||||
);
|
||||
offset += i + r.length;
|
||||
}
|
||||
} else {
|
||||
// 链接消息
|
||||
if (i > 0) {
|
||||
push(copy.substring(0, i));
|
||||
}
|
||||
children.push(
|
||||
<TouchableOpacity
|
||||
key={Math.random()}
|
||||
onPress={() => Linking.openURL(r)}
|
||||
>
|
||||
{// Do not nest in view error in dev environment
|
||||
process.env.NODE_ENV === 'development' ? (
|
||||
<View>
|
||||
<Text style={{ color: '#001be5' }}>{r}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={{ color: '#001be5' }}>{r}</Text>
|
||||
)}
|
||||
</TouchableOpacity>,
|
||||
);
|
||||
offset += i + r.length;
|
||||
}
|
||||
copy = copy.substr(i + r.length);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理剩余文本
|
||||
if (offset < message.content.length) {
|
||||
push(message.content.substring(offset, message.content.length));
|
||||
}
|
||||
|
||||
return <View style={[styles.container]}>{children}</View>;
|
||||
}
|
||||
|
||||
export default TextMessage;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
// width: '100%',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
text: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
textSelf: {
|
||||
color: 'white',
|
||||
},
|
||||
expression: {
|
||||
marginLeft: 1,
|
||||
marginRight: 1,
|
||||
transform: [
|
||||
{
|
||||
translateY: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ScrollView, StyleSheet } from 'react-native';
|
||||
|
||||
import { Header, Item, Icon, Input } from 'native-base';
|
||||
import { Actions } from 'react-native-router-flux';
|
||||
import Linkman from './Linkman';
|
||||
import { useLinkmans } from '../../hooks/useStore';
|
||||
import { Linkman as LinkmanType } from '../../types/redux';
|
||||
import PageContainer from '../../components/PageContainer';
|
||||
import { search } from '../../service';
|
||||
import { isiOS } from '../../utils/platform';
|
||||
|
||||
export default function ChatList() {
|
||||
const [searchKeywords, updateSearchKeywords] = useState('');
|
||||
const linkmans = useLinkmans();
|
||||
|
||||
async function handleSearch() {
|
||||
const result = await search(searchKeywords);
|
||||
updateSearchKeywords('');
|
||||
Actions.push('searchResult', result);
|
||||
}
|
||||
|
||||
function renderLinkman(linkman: LinkmanType) {
|
||||
const { _id: linkmanId, unread, messages, createTime } = linkman;
|
||||
const lastMessage =
|
||||
messages.length > 0 ? messages[messages.length - 1] : null;
|
||||
|
||||
let time = new Date(createTime);
|
||||
let preview = '暂无消息';
|
||||
if (lastMessage) {
|
||||
time = new Date(lastMessage.createTime);
|
||||
preview =
|
||||
lastMessage.type === 'text'
|
||||
? `${lastMessage.content}`
|
||||
: `[${lastMessage.type}]`;
|
||||
if (linkman.type === 'group') {
|
||||
preview = `${lastMessage.from.username}: ${preview}`;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Linkman
|
||||
key={linkmanId}
|
||||
id={linkmanId}
|
||||
name={linkman.name}
|
||||
avatar={linkman.avatar}
|
||||
preview={preview}
|
||||
time={time}
|
||||
unread={unread}
|
||||
linkman={linkman}
|
||||
lastMessageId={lastMessage ? lastMessage._id : ''}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Header searchBar rounded noShadow style={styles.searchContainer}>
|
||||
<Item style={styles.searchItem}>
|
||||
<Icon name="ios-search" style={styles.searchIcon} />
|
||||
<Input
|
||||
style={styles.searchText}
|
||||
placeholder="搜索群组/用户"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="search"
|
||||
value={searchKeywords}
|
||||
onChangeText={updateSearchKeywords}
|
||||
onSubmitEditing={handleSearch}
|
||||
/>
|
||||
</Item>
|
||||
</Header>
|
||||
<ScrollView style={styles.messageList}>
|
||||
{linkmans && linkmans.map((linkman) => renderLinkman(linkman))}
|
||||
</ScrollView>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
messageList: {},
|
||||
searchContainer: {
|
||||
marginTop: isiOS ? 0 : 5,
|
||||
backgroundColor: 'transparent',
|
||||
height: 42,
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
searchItem: {
|
||||
backgroundColor: 'rgba(255,255,255,0.5)',
|
||||
},
|
||||
searchIcon: {
|
||||
color: '#555',
|
||||
},
|
||||
searchText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { View, Icon } from 'native-base';
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { Actions } from 'react-native-router-flux';
|
||||
import Dialog from 'react-native-dialog';
|
||||
import { createGroup } from '../../service';
|
||||
import action from '../../state/action';
|
||||
|
||||
function ChatListRightButton() {
|
||||
const [showDialog, toggleDialog] = useState(false);
|
||||
const [groupName, updateGroupName] = useState('');
|
||||
|
||||
function handleCloseDialog() {
|
||||
updateGroupName('');
|
||||
toggleDialog(false);
|
||||
}
|
||||
|
||||
async function handleCreateGroup() {
|
||||
const group = await createGroup(groupName);
|
||||
if (group) {
|
||||
action.addLinkman({
|
||||
...group,
|
||||
type: 'group',
|
||||
unread: 1,
|
||||
messages: [],
|
||||
});
|
||||
action.setFocus(group._id);
|
||||
handleCloseDialog();
|
||||
Actions.push('chat', { title: group.name });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity onPress={() => toggleDialog(true)}>
|
||||
<View style={styles.container}>
|
||||
<Icon name="add-outline" style={styles.icon} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<Dialog.Container visible={showDialog}>
|
||||
<Dialog.Title>创建群组</Dialog.Title>
|
||||
<Dialog.Description>请输入群组名</Dialog.Description>
|
||||
<Dialog.Input
|
||||
value={groupName}
|
||||
onChangeText={updateGroupName}
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Dialog.Button label="取消" onPress={handleCloseDialog} />
|
||||
<Dialog.Button label="创建" onPress={handleCreateGroup} />
|
||||
</Dialog.Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatListRightButton;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
icon: {
|
||||
color: 'white',
|
||||
fontSize: 32,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { Text, StyleSheet, View, TouchableOpacity } from 'react-native';
|
||||
import { Actions } from 'react-native-router-flux';
|
||||
|
||||
import Time from '../../utils/time';
|
||||
import action from '../../state/action';
|
||||
|
||||
import Avatar from '../../components/Avatar';
|
||||
import { Linkman as LinkmanType } from '../../types/redux';
|
||||
import { formatLinkmanName } from '../../utils/linkman';
|
||||
import fetch from '../../utils/fetch';
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
preview: string;
|
||||
time: Date;
|
||||
unread: number;
|
||||
lastMessageId: string;
|
||||
linkman: LinkmanType;
|
||||
};
|
||||
|
||||
export default function Linkman({
|
||||
id,
|
||||
name,
|
||||
avatar,
|
||||
preview,
|
||||
time,
|
||||
unread,
|
||||
lastMessageId,
|
||||
linkman,
|
||||
}: Props) {
|
||||
function formatTime() {
|
||||
const nowTime = new Date();
|
||||
if (Time.isToday(nowTime, time)) {
|
||||
return Time.getHourMinute(time);
|
||||
}
|
||||
if (Time.isYesterday(nowTime, time)) {
|
||||
return '昨天';
|
||||
}
|
||||
if (Time.isSameYear(nowTime, time)) {
|
||||
return Time.getMonthDate(time);
|
||||
}
|
||||
return Time.getYearMonthDate(time);
|
||||
}
|
||||
|
||||
function handlePress() {
|
||||
action.setFocus(id);
|
||||
Actions.chat({ title: formatLinkmanName(linkman) });
|
||||
|
||||
if (id && lastMessageId) {
|
||||
fetch('updateHistory', { linkmanId: id, messageId: lastMessageId });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress}>
|
||||
<View style={styles.container}>
|
||||
<Avatar src={avatar} size={50} />
|
||||
<View style={styles.content}>
|
||||
<View style={styles.nickTime}>
|
||||
<Text style={styles.nick}>{name}</Text>
|
||||
<Text style={styles.time}>{formatTime()}</Text>
|
||||
</View>
|
||||
<View style={styles.previewUnread}>
|
||||
<Text style={styles.preview} numberOfLines={1}>
|
||||
{preview}
|
||||
</Text>
|
||||
{unread > 0 ? (
|
||||
<View style={styles.unread}>
|
||||
<Text style={styles.unreadText}>
|
||||
{unread > 99 ? '99' : unread}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
height: 70,
|
||||
alignItems: 'center',
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginLeft: 8,
|
||||
},
|
||||
nickTime: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
nick: {
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
},
|
||||
time: {
|
||||
fontSize: 14,
|
||||
color: '#888',
|
||||
},
|
||||
previewUnread: {
|
||||
marginTop: 8,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
preview: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
unread: {
|
||||
backgroundColor: '#2a7bf6',
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 5,
|
||||
},
|
||||
unreadText: {
|
||||
fontSize: 10,
|
||||
color: 'white',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Text, View } from 'native-base';
|
||||
import React from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import Avatar from '../../components/Avatar';
|
||||
import { useIsLogin, useStore, useTheme, useUser } from '../../hooks/useStore';
|
||||
|
||||
function SelfInfo() {
|
||||
const isLogin = useIsLogin();
|
||||
const user = useUser();
|
||||
const { primaryTextColor10 } = useTheme();
|
||||
const { connect } = useStore();
|
||||
|
||||
if (!isLogin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { avatar, username } = user;
|
||||
|
||||
return (
|
||||
<View style={[styles.container]}>
|
||||
<View>
|
||||
<Avatar src={avatar} size={32} />
|
||||
<View
|
||||
style={[
|
||||
styles.onlineStatus,
|
||||
connect ? styles.online : styles.offline,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={[styles.nickname, { color: primaryTextColor10 }]}>
|
||||
{username}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelfInfo;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: 38,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
},
|
||||
avatar: {
|
||||
position: 'relative',
|
||||
},
|
||||
nickname: {
|
||||
marginLeft: 8,
|
||||
},
|
||||
onlineStatus: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
online: {
|
||||
backgroundColor: 'rgba(94, 212, 92, 1)',
|
||||
},
|
||||
offline: {
|
||||
backgroundColor: 'rgba(206, 12, 35, 1)',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { Button, Text, View } from 'native-base';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { Actions } from 'react-native-router-flux';
|
||||
import PageContainer from '../../components/PageContainer';
|
||||
import Avatar from '../../components/Avatar';
|
||||
import { useFocusLinkman, useLinkmans } from '../../hooks/useStore';
|
||||
import { Linkman } from '../../types/redux';
|
||||
import action from '../../state/action';
|
||||
import { getLinkmanHistoryMessages, joinGroup } from '../../service';
|
||||
|
||||
type Props = {
|
||||
group: {
|
||||
_id: string;
|
||||
avatar: string;
|
||||
name: string;
|
||||
members: number;
|
||||
};
|
||||
};
|
||||
|
||||
function GroupInfo({ group }: Props) {
|
||||
const { _id, avatar, name, members } = group;
|
||||
const linkmans = useLinkmans();
|
||||
const linkman = linkmans.find(
|
||||
(x) => x._id === _id && x.type === 'group',
|
||||
) as Linkman;
|
||||
const isJoined = !!linkman;
|
||||
const currentLinkman = useFocusLinkman() as Linkman;
|
||||
|
||||
function handleSendMessage() {
|
||||
action.setFocus(group._id);
|
||||
if (currentLinkman._id === group._id) {
|
||||
Actions.popTo('chat');
|
||||
} else {
|
||||
Actions.popTo('_chatlist');
|
||||
Actions.push('chat', { title: group.name });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleJoinGroup() {
|
||||
const newLinkman = await joinGroup(_id);
|
||||
if (newLinkman) {
|
||||
action.addLinkman({
|
||||
...newLinkman,
|
||||
type: 'group',
|
||||
unread: 0,
|
||||
messages: [],
|
||||
});
|
||||
const messages = await getLinkmanHistoryMessages(_id, 0);
|
||||
action.addLinkmanHistoryMessages(_id, messages);
|
||||
action.setFocus(_id);
|
||||
|
||||
Actions.popTo('_chatlist');
|
||||
Actions.push('chat', { title: newLinkman.name });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.userContainer}>
|
||||
<Avatar src={avatar} size={88} />
|
||||
<Text style={styles.nick}>{name}</Text>
|
||||
</View>
|
||||
<View style={styles.infoContainer}>
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.infoLabel}>人数:</Text>
|
||||
<Text style={styles.infoValue}>{members}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.buttonContainer}>
|
||||
{isJoined ? (
|
||||
<Button
|
||||
primary
|
||||
block
|
||||
style={styles.button}
|
||||
onPress={handleSendMessage}
|
||||
>
|
||||
<Text>发送消息</Text>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
primary
|
||||
block
|
||||
style={styles.button}
|
||||
onPress={handleJoinGroup}
|
||||
>
|
||||
<Text>加入群组</Text>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupInfo;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingTop: 20,
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
},
|
||||
userContainer: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
infoContainer: {
|
||||
marginTop: 20,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
infoLabel: {
|
||||
color: '#666',
|
||||
},
|
||||
infoValue: {
|
||||
color: '#333',
|
||||
marginLeft: 12,
|
||||
},
|
||||
nick: {
|
||||
color: '#333',
|
||||
marginTop: 6,
|
||||
},
|
||||
buttonContainer: {
|
||||
marginTop: 20,
|
||||
},
|
||||
button: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { View, Text, Button } from 'native-base';
|
||||
import React from 'react';
|
||||
import { Alert, Pressable, ScrollView, StyleSheet } from 'react-native';
|
||||
import { Actions } from 'react-native-router-flux';
|
||||
import Avatar from '../../components/Avatar';
|
||||
import PageContainer from '../../components/PageContainer';
|
||||
import { useFocusLinkman, useSelfId } from '../../hooks/useStore';
|
||||
import { deleteGroup, leaveGroup } from '../../service';
|
||||
import action from '../../state/action';
|
||||
import { Group } from '../../types/redux';
|
||||
|
||||
function GroupProfile() {
|
||||
const linkman = useFocusLinkman() as Group;
|
||||
const self = useSelfId();
|
||||
const isGroupCreator = linkman.creator === self;
|
||||
|
||||
function getOS(os: string) {
|
||||
return os === 'Windows Server 2008 R2 / 7' ? 'Windows 7' : os;
|
||||
}
|
||||
|
||||
function ShowEnvironment(environment: string) {
|
||||
Alert.alert('设备信息', environment);
|
||||
}
|
||||
|
||||
async function handleLeaveGroup() {
|
||||
if (isGroupCreator) {
|
||||
const isSuccess = await deleteGroup(linkman._id);
|
||||
if (isSuccess) {
|
||||
action.removeLinkman(linkman._id);
|
||||
Actions.popTo('_chatlist', { title: '' });
|
||||
}
|
||||
} else {
|
||||
const isSuccess = await leaveGroup(linkman._id);
|
||||
if (isSuccess) {
|
||||
action.removeLinkman(linkman._id);
|
||||
Actions.popTo('_chatlist', { title: '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<ScrollView style={styles.container}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>功能</Text>
|
||||
<Button danger onPress={handleLeaveGroup}>
|
||||
<Text>{isGroupCreator ? '解散群组' : '退出群组'}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>在线成员</Text>
|
||||
{linkman.members.map((member) => (
|
||||
<View key={member._id} style={styles.member}>
|
||||
<Avatar src={member.user.avatar} size={24} />
|
||||
<Text style={styles.memberName}>
|
||||
{member.user.username}
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.memberInfoContainer}
|
||||
onPress={() =>
|
||||
ShowEnvironment(member.environment)
|
||||
}
|
||||
>
|
||||
<Text style={styles.memberInfo}>
|
||||
{member.browser} {getOS(member.os)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupProfile;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingLeft: 12,
|
||||
paddingRight: 12,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 12,
|
||||
},
|
||||
member: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: 32,
|
||||
},
|
||||
memberName: {
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
marginLeft: 8,
|
||||
},
|
||||
memberInfoContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
memberInfo: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
textAlign: 'right',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Alert, StyleSheet, Text, TextInput } from 'react-native';
|
||||
import { Form, Label, Button, View } from 'native-base';
|
||||
import { Actions } from 'react-native-router-flux';
|
||||
|
||||
import PageContainer from '../../components/PageContainer';
|
||||
|
||||
type Props = {
|
||||
buttonText: string;
|
||||
jumpText: string;
|
||||
jumpPage: string;
|
||||
onSubmit: (username: string, password: string) => void;
|
||||
};
|
||||
|
||||
export default function Base({
|
||||
buttonText,
|
||||
jumpText,
|
||||
jumpPage,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const $username = useRef<TextInput>();
|
||||
const $password = useRef<TextInput>();
|
||||
|
||||
function handlePress() {
|
||||
$username.current!.blur();
|
||||
$password.current!.blur();
|
||||
onSubmit(username, password);
|
||||
}
|
||||
|
||||
function handleJump() {
|
||||
if (Actions[jumpPage]) {
|
||||
Actions.replace(jumpPage);
|
||||
} else {
|
||||
Alert.alert(`跳转 ${jumpPage} 失败`);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<PageContainer>
|
||||
<View style={styles.container}>
|
||||
<Form>
|
||||
<Label style={styles.label}>用户名</Label>
|
||||
<TextInput
|
||||
style={[styles.input]}
|
||||
// @ts-ignore
|
||||
ref={$username}
|
||||
clearButtonMode="while-editing"
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
autoCompleteType="username"
|
||||
/>
|
||||
<Label style={styles.label}>密码</Label>
|
||||
<TextInput
|
||||
style={[styles.input]}
|
||||
// @ts-ignore
|
||||
ref={$password}
|
||||
secureTextEntry
|
||||
clearButtonMode="while-editing"
|
||||
onChangeText={setPassword}
|
||||
autoCapitalize="none"
|
||||
autoCompleteType="password"
|
||||
/>
|
||||
</Form>
|
||||
<Button
|
||||
primary
|
||||
block
|
||||
style={styles.button}
|
||||
onPress={handlePress}
|
||||
>
|
||||
<Text style={styles.buttonText}>{buttonText}</Text>
|
||||
</Button>
|
||||
<Button transparent style={styles.signup} onPress={handleJump}>
|
||||
<Text style={styles.signupText}>{jumpText}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingLeft: 12,
|
||||
paddingRight: 12,
|
||||
paddingTop: 20,
|
||||
},
|
||||
button: {
|
||||
marginTop: 18,
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 18,
|
||||
color: '#fafafa',
|
||||
},
|
||||
signup: {
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
signupText: {
|
||||
color: '#2a7bf6',
|
||||
fontSize: 14,
|
||||
},
|
||||
label: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
height: 42,
|
||||
fontSize: 16,
|
||||
borderRadius: 6,
|
||||
marginBottom: 12,
|
||||
paddingLeft: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: '#777',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Container } from 'native-base';
|
||||
import { Actions } from 'react-native-router-flux';
|
||||
|
||||
import fetch from '../../utils/fetch';
|
||||
import platform from '../../utils/platform';
|
||||
import action from '../../state/action';
|
||||
|
||||
import Base from './Base';
|
||||
import { setStorageValue } from '../../utils/storage';
|
||||
import { Friend, Group } from '../../types/redux';
|
||||
|
||||
export default function Login() {
|
||||
async function handleSubmit(username: string, password: string) {
|
||||
const [err, res] = await fetch('login', {
|
||||
username,
|
||||
password,
|
||||
...platform,
|
||||
});
|
||||
if (!err) {
|
||||
const user = res;
|
||||
action.setUser(user);
|
||||
|
||||
const linkmanIds = [
|
||||
...user.groups.map((g: Group) => g._id),
|
||||
...user.friends.map((f: Friend) => f._id),
|
||||
];
|
||||
const [err2, linkmans] = await fetch('getLinkmansLastMessagesV2', {
|
||||
linkmans: linkmanIds,
|
||||
});
|
||||
if (!err2) {
|
||||
action.setLinkmansLastMessages(linkmans);
|
||||
}
|
||||
|
||||
Actions.pop();
|
||||
await setStorageValue('token', res.token);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Container>
|
||||
<Base
|
||||
buttonText="登录"
|
||||
jumpText="注册新用户"
|
||||
jumpPage="signup"
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Container, Toast } from 'native-base';
|
||||
import { Actions } from 'react-native-router-flux';
|
||||
|
||||
import fetch from '../../utils/fetch';
|
||||
import platform from '../../utils/platform';
|
||||
import action from '../../state/action';
|
||||
|
||||
import Base from './Base';
|
||||
import { setStorageValue } from '../../utils/storage';
|
||||
import { Friend, Group } from '../../types/redux';
|
||||
|
||||
export default function Signup() {
|
||||
async function handleSubmit(username: string, password: string) {
|
||||
const [err, res] = await fetch('register', {
|
||||
username,
|
||||
password,
|
||||
...platform,
|
||||
});
|
||||
if (!err) {
|
||||
Toast.show({
|
||||
text: '创建成功',
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
const user = res;
|
||||
action.setUser(user);
|
||||
|
||||
const linkmanIds = [
|
||||
...user.groups.map((g: Group) => g._id),
|
||||
...user.friends.map((f: Friend) => f._id),
|
||||
];
|
||||
const [err2, linkmans] = await fetch('getLinkmansLastMessagesV2', {
|
||||
linkmans: linkmanIds,
|
||||
});
|
||||
if (!err2) {
|
||||
action.setLinkmansLastMessages(linkmans);
|
||||
}
|
||||
|
||||
Actions.chatlist();
|
||||
await setStorageValue('token', res.token);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Container>
|
||||
<Base
|
||||
buttonText="注册"
|
||||
jumpText="已有账号? 去登陆"
|
||||
jumpPage="login"
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Content,
|
||||
Icon,
|
||||
List,
|
||||
ListItem,
|
||||
Right,
|
||||
Text,
|
||||
Toast,
|
||||
View,
|
||||
} from 'native-base';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Linking, StyleSheet } from 'react-native';
|
||||
import { Actions } from 'react-native-router-flux';
|
||||
import PageContainer from '../../components/PageContainer';
|
||||
|
||||
import { useIsLogin } from '../../hooks/useStore';
|
||||
import socket from '../../socket';
|
||||
import action from '../../state/action';
|
||||
import { getStorageValue, removeStorageValue } from '../../utils/storage';
|
||||
import appInfo from '../../../app.json';
|
||||
import Avatar from '../../components/Avatar';
|
||||
import PrivacyPolicy, { PrivacyPolicyStorageKey } from './PrivacyPolicy';
|
||||
|
||||
function getIsNight() {
|
||||
const hour = new Date().getHours();
|
||||
return hour >= 18 || hour < 6;
|
||||
}
|
||||
|
||||
function Other() {
|
||||
const isLogin = useIsLogin();
|
||||
const [isNight, setIsNight] = useState(getIsNight());
|
||||
const [showPrivacyPolicy, togglePrivacyPolicy] = useState(false);
|
||||
|
||||
async function getPrivacyPolicyStatus() {
|
||||
const privacyPoliceStorageValue = await getStorageValue(
|
||||
PrivacyPolicyStorageKey,
|
||||
);
|
||||
togglePrivacyPolicy(privacyPoliceStorageValue !== 'true');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setIsNight(getIsNight());
|
||||
}, 1000);
|
||||
|
||||
getPrivacyPolicyStatus();
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function logout() {
|
||||
action.logout();
|
||||
await removeStorageValue('token');
|
||||
Toast.show({ text: '您已经退出登录' });
|
||||
socket.disconnect();
|
||||
socket.connect();
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const privacyPoliceStorageValue = await getStorageValue(
|
||||
PrivacyPolicyStorageKey,
|
||||
);
|
||||
if (privacyPoliceStorageValue !== 'true') {
|
||||
togglePrivacyPolicy(true);
|
||||
return;
|
||||
}
|
||||
|
||||
Actions.push('login');
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Content>
|
||||
<View style={styles.app}>
|
||||
<Avatar
|
||||
src={
|
||||
isNight
|
||||
? require('../../../icon.png')
|
||||
: require('../../assets/images/wuzeiniang.gif')
|
||||
}
|
||||
size={100}
|
||||
/>
|
||||
<Text style={styles.name}>
|
||||
fiora v{appInfo.expo.version}
|
||||
</Text>
|
||||
</View>
|
||||
<List style={styles.list}>
|
||||
<ListItem
|
||||
icon
|
||||
onPress={() =>
|
||||
Linking.openURL(
|
||||
'https://github.com/yinxin630/fiora-app',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Body>
|
||||
<Text style={styles.listItemTitle}>源码</Text>
|
||||
</Body>
|
||||
<Right>
|
||||
<Icon
|
||||
active
|
||||
name="arrow-forward"
|
||||
style={styles.listItemArrow}
|
||||
/>
|
||||
</Right>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
icon
|
||||
onPress={() =>
|
||||
Linking.openURL('https://www.suisuijiang.com')
|
||||
}
|
||||
>
|
||||
<Body>
|
||||
<Text style={styles.listItemTitle}>作者</Text>
|
||||
</Body>
|
||||
<Right>
|
||||
<Icon
|
||||
active
|
||||
name="arrow-forward"
|
||||
style={styles.listItemArrow}
|
||||
/>
|
||||
</Right>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
icon
|
||||
onPress={() =>
|
||||
Linking.openURL('https://fiora.suisuijiang.com')
|
||||
}
|
||||
>
|
||||
<Body>
|
||||
<Text style={styles.listItemTitle}>
|
||||
fiora 网页版
|
||||
</Text>
|
||||
</Body>
|
||||
<Right>
|
||||
<Icon
|
||||
active
|
||||
name="arrow-forward"
|
||||
style={styles.listItemArrow}
|
||||
/>
|
||||
</Right>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Content>
|
||||
{isLogin ? (
|
||||
<Button
|
||||
danger
|
||||
block
|
||||
style={styles.logoutButton}
|
||||
onPress={logout}
|
||||
>
|
||||
<Text>退出登录</Text>
|
||||
</Button>
|
||||
) : (
|
||||
<Button block style={styles.logoutButton} onPress={login}>
|
||||
<Text>登录 / 注册</Text>
|
||||
</Button>
|
||||
)}
|
||||
<View style={styles.copyrightContainer}>
|
||||
<Text style={styles.copyright}>
|
||||
Copyright© 2015-
|
||||
{new Date().getFullYear()} 碎碎酱
|
||||
</Text>
|
||||
</View>
|
||||
<PrivacyPolicy
|
||||
visible={showPrivacyPolicy}
|
||||
onClose={() => togglePrivacyPolicy(false)}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
logoutButton: {
|
||||
marginLeft: 12,
|
||||
marginRight: 12,
|
||||
},
|
||||
app: {
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
},
|
||||
name: {
|
||||
marginTop: 6,
|
||||
color: '#222',
|
||||
},
|
||||
list: {
|
||||
marginTop: 20,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.4)',
|
||||
},
|
||||
listItemTitle: {
|
||||
color: '#333',
|
||||
},
|
||||
listItemArrow: {
|
||||
color: '#999',
|
||||
},
|
||||
github: {
|
||||
fontSize: 26,
|
||||
color: '#000',
|
||||
},
|
||||
copyrightContainer: {
|
||||
marginTop: 12,
|
||||
marginBottom: 6,
|
||||
},
|
||||
copyright: {
|
||||
fontSize: 10,
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
},
|
||||
});
|
||||
|
||||
export default Other;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Text } from 'native-base';
|
||||
import React from 'react';
|
||||
import { Linking, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import Dialog from 'react-native-dialog';
|
||||
import { removeStorageValue, setStorageValue } from '../../utils/storage';
|
||||
|
||||
export const PrivacyPolicyStorageKey = 'privacy-policy';
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function PrivacyPolicy({ visible, onClose }: Props) {
|
||||
function handleClickPrivacyPolicy() {
|
||||
Linking.openURL('https://fiora.suisuijiang.com/PrivacyPolicy.html');
|
||||
}
|
||||
|
||||
async function handleAgree() {
|
||||
await setStorageValue(PrivacyPolicyStorageKey, 'true');
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleDisagree() {
|
||||
await removeStorageValue(PrivacyPolicyStorageKey);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Container visible={visible}>
|
||||
<Dialog.Title>服务协议和隐私条款</Dialog.Title>
|
||||
<Dialog.Description style={styles.container}>
|
||||
欢迎使用 fiora
|
||||
APP。我们非常重视您的个人信息和隐私保护,在您使用之前,请务必审慎阅读
|
||||
<TouchableOpacity onPress={handleClickPrivacyPolicy}>
|
||||
<Text style={styles.text}>《隐私政策》</Text>
|
||||
</TouchableOpacity>
|
||||
,并充分理解协议条款内容。我们将严格按照您同意的各项条款使用您的个人信息,以便为您提供更好的服务。
|
||||
</Dialog.Description>
|
||||
<Dialog.Button label="不同意" onPress={handleDisagree} />
|
||||
<Dialog.Button label="同意" onPress={handleAgree} />
|
||||
</Dialog.Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default PrivacyPolicy;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
textAlign: 'left',
|
||||
},
|
||||
text: {
|
||||
fontSize: 12,
|
||||
color: '#2a7bf6',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { View, Text } from 'native-base';
|
||||
import React from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import Dialog from 'react-native-dialog';
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onOK: () => void;
|
||||
};
|
||||
|
||||
function Sponsor({ visible, onClose, onOK }: Props) {
|
||||
return (
|
||||
<Dialog.Container visible={visible}>
|
||||
<Dialog.Title>赞助</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
<View>
|
||||
<Text style={styles.text}>
|
||||
如果你觉得这个聊天室还不错的话, 希望能赞助一下~~
|
||||
</Text>
|
||||
<Text style={styles.tip}>
|
||||
请在转账备注中填写您的 fiora 账号
|
||||
</Text>
|
||||
</View>
|
||||
</Dialog.Description>
|
||||
<Dialog.Button label="关闭" onPress={onClose} />
|
||||
<Dialog.Button label="赞助" onPress={onOK} />
|
||||
</Dialog.Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sponsor;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
text: {
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
marginTop: 16,
|
||||
},
|
||||
tip: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
marginTop: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import { Tab, Tabs, Text, View } from 'native-base';
|
||||
import { ScrollView, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { Actions } from 'react-native-router-flux';
|
||||
import PageContainer from '../../components/PageContainer';
|
||||
import Avatar from '../../components/Avatar';
|
||||
|
||||
type Props = {
|
||||
groups: {
|
||||
_id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
members: number;
|
||||
}[];
|
||||
users: {
|
||||
_id: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
function SearchResult({ groups, users }: Props) {
|
||||
function handleClickGroup(group: any) {
|
||||
Actions.push('groupInfo', { group });
|
||||
}
|
||||
|
||||
function handleClickUser(user: any) {
|
||||
Actions.push('userInfo', { user });
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer disableSafeAreaView>
|
||||
<Tabs
|
||||
style={styles.container}
|
||||
tabContainerStyle={{ backgroundColor: 'transparent' }}
|
||||
>
|
||||
<Tab
|
||||
heading={`群组(${groups.length})`}
|
||||
tabStyle={{ backgroundColor: 'transparent' }}
|
||||
activeTabStyle={{ backgroundColor: 'transparent' }}
|
||||
>
|
||||
<PageContainer>
|
||||
<ScrollView>
|
||||
{groups.map((group) => (
|
||||
<TouchableOpacity
|
||||
key={group._id}
|
||||
onPress={() => handleClickGroup(group)}
|
||||
>
|
||||
<View style={styles.item}>
|
||||
<Avatar src={group.avatar} size={40} />
|
||||
<View style={styles.groupInfo}>
|
||||
<Text style={styles.groupName}>
|
||||
{group.name}
|
||||
</Text>
|
||||
<Text style={styles.groupMembers}>
|
||||
{group.members}人
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</PageContainer>
|
||||
</Tab>
|
||||
<Tab
|
||||
heading={`用户(${users.length})`}
|
||||
tabStyle={{ backgroundColor: 'transparent' }}
|
||||
activeTabStyle={{ backgroundColor: 'transparent' }}
|
||||
>
|
||||
<PageContainer>
|
||||
<ScrollView>
|
||||
{users.map((user) => (
|
||||
<TouchableOpacity
|
||||
key={user._id}
|
||||
onPress={() => handleClickUser(user)}
|
||||
>
|
||||
<View style={styles.item}>
|
||||
<Avatar src={user.avatar} size={40} />
|
||||
<Text style={styles.username}>
|
||||
{user.username}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</PageContainer>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchResult;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
item: {
|
||||
height: 56,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
},
|
||||
groupInfo: {
|
||||
marginLeft: 8,
|
||||
},
|
||||
groupName: {
|
||||
color: '#444',
|
||||
},
|
||||
groupMembers: {
|
||||
fontSize: 14,
|
||||
color: '#888',
|
||||
marginTop: 1,
|
||||
},
|
||||
username: {
|
||||
color: '#444',
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import React from 'react';
|
||||
import { Button, Text, View } from 'native-base';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { Actions } from 'react-native-router-flux';
|
||||
import PageContainer from '../../components/PageContainer';
|
||||
import Avatar from '../../components/Avatar';
|
||||
import {
|
||||
useFocusLinkman,
|
||||
useIsAdmin,
|
||||
useLinkmans,
|
||||
useSelfId,
|
||||
} from '../../hooks/useStore';
|
||||
import { Linkman } from '../../types/redux';
|
||||
import action from '../../state/action';
|
||||
import {
|
||||
addFriend,
|
||||
deleteFriend,
|
||||
getLinkmanHistoryMessages,
|
||||
sealUser,
|
||||
sealUserOnlineIp,
|
||||
} from '../../service';
|
||||
import getFriendId from '../../utils/getFriendId';
|
||||
import Toast from '../../components/Toast';
|
||||
|
||||
type Props = {
|
||||
user: {
|
||||
_id: string;
|
||||
avatar: string;
|
||||
tag: string;
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
|
||||
function UserInfo({ user }: Props) {
|
||||
const { _id, avatar, username } = user;
|
||||
const linkmans = useLinkmans();
|
||||
const friend = linkmans.find((linkman) =>
|
||||
linkman._id.includes(_id),
|
||||
) as Linkman;
|
||||
const isFriend = friend && friend.type === 'friend';
|
||||
const isAdmin = useIsAdmin();
|
||||
const currentLinkman = useFocusLinkman() as Linkman;
|
||||
const self = useSelfId();
|
||||
|
||||
function handleSendMessage() {
|
||||
action.setFocus(friend._id);
|
||||
if (currentLinkman._id === friend._id) {
|
||||
Actions.pop();
|
||||
} else {
|
||||
Actions.popTo('_chatlist');
|
||||
Actions.push('chat', { title: friend.name });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteFriend() {
|
||||
const isSuccess = await deleteFriend(_id);
|
||||
if (isSuccess) {
|
||||
action.removeLinkman(friend._id);
|
||||
if (currentLinkman._id === friend._id) {
|
||||
Actions.popTo('_chatlist');
|
||||
} else {
|
||||
Actions.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddFriend() {
|
||||
const newLinkman = await addFriend(_id);
|
||||
const friendId = getFriendId(_id, self);
|
||||
if (newLinkman) {
|
||||
if (friend) {
|
||||
action.updateFriendProperty(friend._id, 'type', 'friend');
|
||||
const messages = await getLinkmanHistoryMessages(
|
||||
friend._id,
|
||||
friend.messages.length,
|
||||
);
|
||||
action.addLinkmanHistoryMessages(friend._id, messages);
|
||||
} else {
|
||||
action.addLinkman({
|
||||
...newLinkman,
|
||||
_id: friendId,
|
||||
name: username,
|
||||
type: 'friend',
|
||||
unread: 0,
|
||||
messages: [],
|
||||
from: self,
|
||||
to: {
|
||||
_id,
|
||||
avatar,
|
||||
username,
|
||||
},
|
||||
});
|
||||
const messages = await getLinkmanHistoryMessages(friendId, 0);
|
||||
action.addLinkmanHistoryMessages(friendId, messages);
|
||||
}
|
||||
action.setFocus(friendId);
|
||||
|
||||
if (currentLinkman._id === friend?._id) {
|
||||
Actions.pop();
|
||||
} else {
|
||||
Actions.popTo('_chatlist');
|
||||
Actions.push('chat', { title: newLinkman.username });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSealUser() {
|
||||
const isSuccess = await sealUser(username);
|
||||
if (isSuccess) {
|
||||
Toast.success('封禁用户成功');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSealIp() {
|
||||
const isSuccess = await sealUserOnlineIp(_id);
|
||||
if (isSuccess) {
|
||||
Toast.success('封禁用户当前ip成功');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.userContainer}>
|
||||
<Avatar src={avatar} size={88} />
|
||||
<Text style={styles.nick}>{username}</Text>
|
||||
</View>
|
||||
<View style={styles.buttonContainer}>
|
||||
{isFriend ? (
|
||||
<>
|
||||
<Button
|
||||
primary
|
||||
block
|
||||
style={styles.button}
|
||||
onPress={handleSendMessage}
|
||||
>
|
||||
<Text>发送消息</Text>
|
||||
</Button>
|
||||
<Button
|
||||
primary
|
||||
block
|
||||
danger
|
||||
style={styles.button}
|
||||
onPress={handleDeleteFriend}
|
||||
>
|
||||
<Text>删除好友</Text>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
primary
|
||||
block
|
||||
style={styles.button}
|
||||
onPress={handleAddFriend}
|
||||
>
|
||||
<Text>加为好友</Text>
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Button
|
||||
primary
|
||||
block
|
||||
danger
|
||||
style={styles.button}
|
||||
onPress={handleSealUser}
|
||||
>
|
||||
<Text>封禁用户</Text>
|
||||
</Button>
|
||||
<Button
|
||||
primary
|
||||
block
|
||||
danger
|
||||
style={styles.button}
|
||||
onPress={handleSealIp}
|
||||
>
|
||||
<Text>封禁 ip</Text>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserInfo;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingTop: 20,
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
},
|
||||
userContainer: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
nick: {
|
||||
color: '#333',
|
||||
marginTop: 6,
|
||||
},
|
||||
buttonContainer: {
|
||||
marginTop: 20,
|
||||
},
|
||||
button: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import { User } from './types/redux';
|
||||
import fetch from './utils/fetch';
|
||||
|
||||
function saveUsername(username: string) {
|
||||
window.localStorage.setItem('username', username);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册新用户
|
||||
* @param username 用户名
|
||||
* @param password 密码
|
||||
* @param os 系统
|
||||
* @param browser 浏览器
|
||||
* @param environment 环境信息
|
||||
*/
|
||||
export async function register(
|
||||
username: string,
|
||||
password: string,
|
||||
os = '',
|
||||
browser = '',
|
||||
environment = '',
|
||||
) {
|
||||
const [err, user] = await fetch('register', {
|
||||
username,
|
||||
password,
|
||||
os,
|
||||
browser,
|
||||
environment,
|
||||
});
|
||||
|
||||
if (err) {
|
||||
return null;
|
||||
}
|
||||
|
||||
saveUsername(user.username);
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用账密登录
|
||||
* @param username 用户名
|
||||
* @param password 密码
|
||||
* @param os 系统
|
||||
* @param browser 浏览器
|
||||
* @param environment 环境信息
|
||||
*/
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
os = '',
|
||||
browser = '',
|
||||
environment = '',
|
||||
) {
|
||||
const [err, user] = await fetch('login', {
|
||||
username,
|
||||
password,
|
||||
os,
|
||||
browser,
|
||||
environment,
|
||||
});
|
||||
|
||||
if (err) {
|
||||
return null;
|
||||
}
|
||||
|
||||
saveUsername(user.username);
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用token登录
|
||||
* @param token 登录token
|
||||
* @param os 系统
|
||||
* @param browser 浏览器
|
||||
* @param environment 环境信息
|
||||
*/
|
||||
export async function loginByToken(
|
||||
token: string,
|
||||
os = '',
|
||||
browser = '',
|
||||
environment = '',
|
||||
) {
|
||||
const [err, user] = await fetch(
|
||||
'loginByToken',
|
||||
{
|
||||
token,
|
||||
os,
|
||||
browser,
|
||||
environment,
|
||||
},
|
||||
{ toast: false },
|
||||
);
|
||||
|
||||
if (err) {
|
||||
return null;
|
||||
}
|
||||
|
||||
saveUsername(user.username);
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 游客模式登陆
|
||||
* @param os 系统
|
||||
* @param browser 浏览器
|
||||
* @param environment 环境信息
|
||||
*/
|
||||
export async function guest(os = '', browser = '', environment = '') {
|
||||
const [err, res] = await fetch('guest', { os, browser, environment });
|
||||
if (err) {
|
||||
return null;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修用户头像
|
||||
* @param avatar 新头像链接
|
||||
*/
|
||||
export async function changeAvatar(avatar: string) {
|
||||
const [error] = await fetch('changeAvatar', { avatar });
|
||||
return !error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改用户密码
|
||||
* @param oldPassword 旧密码
|
||||
* @param newPassword 新密码
|
||||
*/
|
||||
export async function changePassword(oldPassword: string, newPassword: string) {
|
||||
const [error] = await fetch('changePassword', {
|
||||
oldPassword,
|
||||
newPassword,
|
||||
});
|
||||
return !error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改用户名
|
||||
* @param username 新用户名
|
||||
*/
|
||||
export async function changeUsername(username: string) {
|
||||
const [error] = await fetch('changeUsername', {
|
||||
username,
|
||||
});
|
||||
return !error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改群组名
|
||||
* @param groupId 目标群组
|
||||
* @param name 新名字
|
||||
*/
|
||||
export async function changeGroupName(groupId: string, name: string) {
|
||||
const [error] = await fetch('changeGroupName', { groupId, name });
|
||||
return !error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改群头像
|
||||
* @param groupId 目标群组
|
||||
* @param name 新头像
|
||||
*/
|
||||
export async function changeGroupAvatar(groupId: string, avatar: string) {
|
||||
const [error] = await fetch('changeGroupAvatar', { groupId, avatar });
|
||||
return !error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建群组
|
||||
* @param name 群组名
|
||||
*/
|
||||
export async function createGroup(name: string) {
|
||||
const [, group] = await fetch('createGroup', { name });
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除群组
|
||||
* @param groupId 群组id
|
||||
*/
|
||||
export async function deleteGroup(groupId: string) {
|
||||
const [error] = await fetch('deleteGroup', { groupId });
|
||||
return !error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入群组
|
||||
* @param groupId 群组id
|
||||
*/
|
||||
export async function joinGroup(groupId: string) {
|
||||
const [, group] = await fetch('joinGroup', { groupId });
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* 离开群组
|
||||
* @param groupId 群组id
|
||||
*/
|
||||
export async function leaveGroup(groupId: string) {
|
||||
const [error] = await fetch('leaveGroup', { groupId });
|
||||
return !error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加好友
|
||||
* @param userId 目标用户id
|
||||
*/
|
||||
export async function addFriend(userId: string) {
|
||||
const [, user] = await fetch<User>('addFriend', { userId });
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除好友
|
||||
* @param userId 目标用户id
|
||||
*/
|
||||
export async function deleteFriend(userId: string) {
|
||||
const [err] = await fetch('deleteFriend', { userId });
|
||||
return !err;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取联系人历史消息
|
||||
* @param linkmanId 联系人id
|
||||
* @param existCount 客户端已有消息条数
|
||||
*/
|
||||
export async function getLinkmanHistoryMessages(
|
||||
linkmanId: string,
|
||||
existCount: number,
|
||||
) {
|
||||
const [, messages] = await fetch('getLinkmanHistoryMessages', {
|
||||
linkmanId,
|
||||
existCount,
|
||||
});
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认群组的历史消息
|
||||
* @param existCount 客户端已有消息条数
|
||||
*/
|
||||
export async function getDefaultGroupHistoryMessages(existCount: number) {
|
||||
const [, messages] = await fetch('getDefaultGroupHistoryMessages', {
|
||||
existCount,
|
||||
});
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索用户和群组
|
||||
* @param keywords 关键字
|
||||
*/
|
||||
export async function search(keywords: string) {
|
||||
const [, result] = await fetch('search', { keywords });
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索表情包
|
||||
* @param keywords 关键字
|
||||
*/
|
||||
export async function searchExpression(keywords: string) {
|
||||
const [, result] = await fetch('searchExpression', { keywords });
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
* @param to 目标
|
||||
* @param type 消息类型
|
||||
* @param content 消息内容
|
||||
*/
|
||||
export async function sendMessage(to: string, type: string, content: string) {
|
||||
return fetch('sendMessage', { to, type, content });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
* @param messageId 要删除的消息id
|
||||
*/
|
||||
export async function deleteMessage(messageId: string) {
|
||||
const [err] = await fetch('deleteMessage', { messageId });
|
||||
return !err;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标群组的在线用户列表
|
||||
* @param groupId 目标群id
|
||||
*/
|
||||
export async function getGroupOnlineMembers(groupId: string) {
|
||||
const [, members] = await fetch('getGroupOnlineMembers', { groupId });
|
||||
return members;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认群组的在线用户列表
|
||||
*/
|
||||
export async function getDefaultGroupOnlineMembers() {
|
||||
const [, members] = await fetch('getDefaultGroupOnlineMembers');
|
||||
return members;
|
||||
}
|
||||
|
||||
/**
|
||||
* 封禁用户
|
||||
* @param username 目标用户名
|
||||
*/
|
||||
export async function sealUser(username: string) {
|
||||
const [err] = await fetch('sealUser', { username });
|
||||
return !err;
|
||||
}
|
||||
|
||||
/**
|
||||
* 封禁ip
|
||||
* @param ip ip地址
|
||||
*/
|
||||
export async function sealIp(ip: string) {
|
||||
const [err] = await fetch('sealIp', { ip });
|
||||
return !err;
|
||||
}
|
||||
|
||||
/**
|
||||
* 封禁用户所有在线ip
|
||||
* @param userId 用户id
|
||||
*/
|
||||
export async function sealUserOnlineIp(userId: string) {
|
||||
const [err] = await fetch('sealUserOnlineIp', { userId });
|
||||
return !err;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取封禁用户列表
|
||||
*/
|
||||
export async function getSealList() {
|
||||
const [, sealList] = await fetch('getSealList');
|
||||
return sealList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置指定用户的密码
|
||||
* @param username 目标用户名
|
||||
*/
|
||||
export async function resetUserPassword(username: string) {
|
||||
const [, res] = await fetch('resetUserPassword', { username });
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新指定用户的标签
|
||||
* @param username 目标用户名
|
||||
* @param tag 标签
|
||||
*/
|
||||
export async function setUserTag(username: string, tag: string) {
|
||||
const [err] = await fetch('setUserTag', { username, tag });
|
||||
return !err;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取在线用户 ip
|
||||
* @param userId 用户id
|
||||
*/
|
||||
export async function getUserIps(userId: string) {
|
||||
const [, res] = await fetch('getUserIps', { userId });
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function getUserOnlineStatus(userId: string) {
|
||||
const [, res] = await fetch('getUserOnlineStatus', { userId });
|
||||
return res && res.isOnline;
|
||||
}
|
||||
|
||||
export async function setNotificationToken(token: string) {
|
||||
const [, res] = await fetch(
|
||||
'setNotificationToken',
|
||||
{ token },
|
||||
{ toast: false },
|
||||
);
|
||||
return res && res.isOK;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import IO from 'socket.io-client';
|
||||
import Toast from './components/Toast';
|
||||
import action from './state/action';
|
||||
import store from './state/store';
|
||||
import {
|
||||
AddLinkmanAction,
|
||||
AddLinkmanActionType,
|
||||
AddLinkmanHistoryMessagesAction,
|
||||
AddLinkmanHistoryMessagesActionType,
|
||||
AddlinkmanMessageAction,
|
||||
AddlinkmanMessageActionType,
|
||||
ConnectAction,
|
||||
ConnectActionType,
|
||||
DeleteLinkmanMessageAction,
|
||||
DeleteLinkmanMessageActionType,
|
||||
Friend,
|
||||
Group,
|
||||
Message,
|
||||
RemoveLinkmanAction,
|
||||
RemoveLinkmanActionType,
|
||||
SetGuestAction,
|
||||
SetGuestActionType,
|
||||
State,
|
||||
Temporary,
|
||||
UpdateGroupPropertyAction,
|
||||
UpdateGroupPropertyActionType,
|
||||
UpdateUserPropertyAction,
|
||||
UpdateUserPropertyActionType,
|
||||
User,
|
||||
} from './types/redux';
|
||||
import getFriendId from './utils/getFriendId';
|
||||
import platform from './utils/platform';
|
||||
import { getStorageValue } from './utils/storage';
|
||||
|
||||
const { dispatch } = store;
|
||||
|
||||
const options = {
|
||||
transports: ['websocket'],
|
||||
};
|
||||
|
||||
const host = 'http://10.132.67.127:9200';
|
||||
const socket = IO(host, options);
|
||||
|
||||
function fetch<T = any>(
|
||||
event: string,
|
||||
data: any = {},
|
||||
{ toast = true } = {},
|
||||
): Promise<[string | null, T | null]> {
|
||||
return new Promise((resolve) => {
|
||||
socket.emit(event, data, (res: any) => {
|
||||
if (typeof res === 'string') {
|
||||
if (toast) {
|
||||
Toast.danger(res);
|
||||
}
|
||||
resolve([res, null]);
|
||||
} else {
|
||||
resolve([null, res]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function guest() {
|
||||
const [err, res] = await fetch('guest', {});
|
||||
if (!err) {
|
||||
dispatch({
|
||||
type: SetGuestActionType,
|
||||
linkmans: [res],
|
||||
} as SetGuestAction);
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('connect', async () => {
|
||||
dispatch({
|
||||
type: ConnectActionType,
|
||||
value: true,
|
||||
} as ConnectAction);
|
||||
|
||||
const token = await getStorageValue('token');
|
||||
|
||||
if (token) {
|
||||
const [err, res] = await fetch(
|
||||
'loginByToken',
|
||||
{
|
||||
token,
|
||||
...platform,
|
||||
},
|
||||
{ toast: false },
|
||||
);
|
||||
if (err) {
|
||||
guest();
|
||||
} else {
|
||||
const user = res;
|
||||
action.setUser(user);
|
||||
|
||||
const linkmanIds = [
|
||||
...user.groups.map((g: Group) => g._id),
|
||||
...user.friends.map((f: Friend) => f._id),
|
||||
];
|
||||
const [err2, linkmans] = await fetch('getLinkmansLastMessagesV2', {
|
||||
linkmans: linkmanIds,
|
||||
});
|
||||
if (!err2) {
|
||||
action.setLinkmansLastMessages(linkmans);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
guest();
|
||||
}
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
dispatch({
|
||||
type: ConnectActionType,
|
||||
value: false,
|
||||
} as ConnectAction);
|
||||
});
|
||||
socket.on('message', (message: Message) => {
|
||||
const state = store.getState() as State;
|
||||
const linkman = state.linkmans.find((x) => x._id === message.to);
|
||||
if (linkman) {
|
||||
dispatch({
|
||||
type: AddlinkmanMessageActionType,
|
||||
linkmanId: message.to,
|
||||
message,
|
||||
} as AddlinkmanMessageAction);
|
||||
} else {
|
||||
const newLinkman: Temporary = {
|
||||
_id: getFriendId((state.user as User)._id, message.from._id),
|
||||
type: 'temporary',
|
||||
createTime: Date.now(),
|
||||
avatar: message.from.avatar,
|
||||
name: message.from.username,
|
||||
messages: [],
|
||||
unread: 1,
|
||||
};
|
||||
dispatch({
|
||||
type: AddLinkmanActionType,
|
||||
linkman: newLinkman,
|
||||
focus: false,
|
||||
} as AddLinkmanAction);
|
||||
|
||||
fetch('getLinkmanHistoryMessages', {
|
||||
linkmanId: newLinkman._id,
|
||||
existCount: 0,
|
||||
}).then(([err, res]) => {
|
||||
if (!err) {
|
||||
dispatch({
|
||||
type: AddLinkmanHistoryMessagesActionType,
|
||||
linkmanId: newLinkman._id,
|
||||
messages: res,
|
||||
} as AddLinkmanHistoryMessagesAction);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(
|
||||
'changeGroupName',
|
||||
({ groupId, name }: { groupId: string; name: string }) => {
|
||||
dispatch({
|
||||
type: UpdateGroupPropertyActionType,
|
||||
groupId,
|
||||
key: 'name',
|
||||
value: name,
|
||||
} as UpdateGroupPropertyAction);
|
||||
},
|
||||
);
|
||||
|
||||
socket.on('deleteGroup', ({ groupId }: { groupId: string }) => {
|
||||
dispatch({
|
||||
type: RemoveLinkmanActionType,
|
||||
linkmanId: groupId,
|
||||
} as RemoveLinkmanAction);
|
||||
});
|
||||
|
||||
socket.on('changeTag', (tag: string) => {
|
||||
dispatch({
|
||||
type: UpdateUserPropertyActionType,
|
||||
key: 'tag',
|
||||
value: tag,
|
||||
} as UpdateUserPropertyAction);
|
||||
});
|
||||
|
||||
socket.on(
|
||||
'deleteMessage',
|
||||
({ linkmanId, messageId }: { linkmanId: string; messageId: string }) => {
|
||||
dispatch({
|
||||
type: DeleteLinkmanMessageActionType,
|
||||
linkmanId,
|
||||
messageId,
|
||||
} as DeleteLinkmanMessageAction);
|
||||
},
|
||||
);
|
||||
|
||||
socket.connect();
|
||||
|
||||
export default socket;
|
||||
@@ -0,0 +1,260 @@
|
||||
import getFriendId from '../utils/getFriendId';
|
||||
import store from './store';
|
||||
import {
|
||||
ConnectActionType,
|
||||
ConnectAction,
|
||||
Friend,
|
||||
SetUserActionType,
|
||||
SetUserAction,
|
||||
SetLinkmanMessagesAction,
|
||||
SetGuestActionType,
|
||||
SetGuestAction,
|
||||
LogoutActionType,
|
||||
UpdateUserPropertyActionType,
|
||||
UpdateUserPropertyAction,
|
||||
AddlinkmanMessageActionType,
|
||||
AddlinkmanMessageAction,
|
||||
AddLinkmanHistoryMessagesActionType,
|
||||
AddLinkmanHistoryMessagesAction,
|
||||
UpdateSelfMessageActionType,
|
||||
UpdateSelfMessageAction,
|
||||
SetFocusAction,
|
||||
AddLinkmanAction,
|
||||
RemoveLinkmanActionType,
|
||||
RemoveLinkmanAction,
|
||||
SetFriendAction,
|
||||
UpdateUIPropertyActionType,
|
||||
UpdateUIPropertyAction,
|
||||
Group,
|
||||
Message,
|
||||
Linkman,
|
||||
UpdateGroupPropertyActionType,
|
||||
UpdateGroupPropertyAction,
|
||||
UpdateFriendPropertyActionType,
|
||||
UpdateFriendPropertyAction,
|
||||
DeleteLinkmanMessageAction,
|
||||
DeleteLinkmanMessageActionType,
|
||||
} from '../types/redux';
|
||||
|
||||
const { dispatch } = store;
|
||||
|
||||
function connect() {
|
||||
dispatch({
|
||||
type: ConnectActionType,
|
||||
value: true,
|
||||
} as ConnectAction);
|
||||
}
|
||||
function disconnect() {
|
||||
dispatch({
|
||||
type: ConnectActionType,
|
||||
value: false,
|
||||
} as ConnectAction);
|
||||
}
|
||||
|
||||
function setUser(user: any) {
|
||||
user.groups.forEach((group: Group) => {
|
||||
Object.assign(group, {
|
||||
type: 'group',
|
||||
unread: 0,
|
||||
messages: [],
|
||||
members: [],
|
||||
});
|
||||
});
|
||||
user.friends.forEach((friend: Friend) => {
|
||||
Object.assign(friend, {
|
||||
type: 'friend',
|
||||
_id: getFriendId(friend.from, friend.to._id),
|
||||
messages: [],
|
||||
unread: 0,
|
||||
avatar: friend.to.avatar,
|
||||
name: friend.to.username,
|
||||
to: friend.to._id,
|
||||
});
|
||||
});
|
||||
|
||||
const linkmans = [...user.groups, ...user.friends];
|
||||
dispatch({
|
||||
type: SetUserActionType,
|
||||
user: {
|
||||
...user,
|
||||
groups: null,
|
||||
friends: null,
|
||||
},
|
||||
linkmans,
|
||||
} as SetUserAction);
|
||||
}
|
||||
function setLinkmansLastMessages(
|
||||
linkmans: SetLinkmanMessagesAction['linkmans'],
|
||||
) {
|
||||
dispatch({
|
||||
type: 'SetLinkmanMessages',
|
||||
linkmans,
|
||||
} as SetLinkmanMessagesAction);
|
||||
}
|
||||
function setGuest(defaultGroup: Group) {
|
||||
dispatch({
|
||||
type: SetGuestActionType,
|
||||
linkmans: [
|
||||
Object.assign(defaultGroup, {
|
||||
type: 'group',
|
||||
unread: 0,
|
||||
members: [],
|
||||
}),
|
||||
],
|
||||
} as SetGuestAction);
|
||||
}
|
||||
function logout() {
|
||||
dispatch({
|
||||
type: LogoutActionType,
|
||||
});
|
||||
}
|
||||
function setAvatar(avatar: string) {
|
||||
dispatch({
|
||||
type: UpdateUserPropertyActionType,
|
||||
key: 'avatar',
|
||||
value: avatar,
|
||||
} as UpdateUserPropertyAction);
|
||||
}
|
||||
function updateUserProperty(key: string, value: any) {
|
||||
dispatch({
|
||||
type: UpdateUserPropertyActionType,
|
||||
key,
|
||||
value,
|
||||
} as UpdateUserPropertyAction);
|
||||
}
|
||||
|
||||
function addLinkmanMessage(linkmanId: string, message: Message) {
|
||||
dispatch({
|
||||
type: AddlinkmanMessageActionType,
|
||||
linkmanId,
|
||||
message,
|
||||
} as AddlinkmanMessageAction);
|
||||
}
|
||||
|
||||
function deleteLinkmanMessage(linkmanId: string, messageId: string) {
|
||||
dispatch({
|
||||
type: DeleteLinkmanMessageActionType,
|
||||
linkmanId,
|
||||
messageId,
|
||||
} as DeleteLinkmanMessageAction);
|
||||
}
|
||||
|
||||
function addLinkmanHistoryMessages(linkmanId: string, messages: Message[]) {
|
||||
dispatch({
|
||||
type: AddLinkmanHistoryMessagesActionType,
|
||||
linkmanId,
|
||||
messages,
|
||||
} as AddLinkmanHistoryMessagesAction);
|
||||
}
|
||||
function updateSelfMessage(
|
||||
linkmanId: string,
|
||||
messageId: string,
|
||||
message: Message,
|
||||
) {
|
||||
dispatch({
|
||||
type: UpdateSelfMessageActionType,
|
||||
linkmanId,
|
||||
messageId,
|
||||
message,
|
||||
} as UpdateSelfMessageAction);
|
||||
}
|
||||
|
||||
function setFocus(linkmanId: string) {
|
||||
dispatch({
|
||||
type: 'SetFocus',
|
||||
linkmanId,
|
||||
} as SetFocusAction);
|
||||
}
|
||||
function setGroupMembers(groupId: string, members: Group['members']) {
|
||||
dispatch({
|
||||
type: UpdateGroupPropertyActionType,
|
||||
groupId,
|
||||
key: 'members',
|
||||
value: members,
|
||||
} as UpdateGroupPropertyAction);
|
||||
}
|
||||
function setGroupAvatar(groupId: string, avatar: string) {
|
||||
dispatch({
|
||||
type: UpdateGroupPropertyActionType,
|
||||
groupId,
|
||||
key: 'avatar',
|
||||
value: avatar,
|
||||
} as UpdateGroupPropertyAction);
|
||||
}
|
||||
function updateGroupProperty(groupId: string, key: string, value: any) {
|
||||
dispatch({
|
||||
type: UpdateGroupPropertyActionType,
|
||||
groupId,
|
||||
key,
|
||||
value,
|
||||
} as UpdateGroupPropertyAction);
|
||||
}
|
||||
function updateFriendProperty(userId: string, key: string, value: any) {
|
||||
dispatch({
|
||||
type: UpdateFriendPropertyActionType,
|
||||
userId,
|
||||
key,
|
||||
value,
|
||||
} as UpdateFriendPropertyAction);
|
||||
}
|
||||
function addLinkman(linkman: Linkman, focus = false) {
|
||||
if (linkman.type === 'group') {
|
||||
linkman.members = [];
|
||||
linkman.messages = [];
|
||||
linkman.unread = 0;
|
||||
}
|
||||
dispatch({
|
||||
type: 'AddLinkman',
|
||||
linkman,
|
||||
focus,
|
||||
} as AddLinkmanAction);
|
||||
}
|
||||
function removeLinkman(linkmanId: string) {
|
||||
dispatch({
|
||||
type: RemoveLinkmanActionType,
|
||||
linkmanId,
|
||||
} as RemoveLinkmanAction);
|
||||
}
|
||||
function setFriend(linkmanId: string, from: Friend['from'], to: Friend['to']) {
|
||||
dispatch({
|
||||
type: 'SetFriend',
|
||||
linkmanId,
|
||||
from,
|
||||
to,
|
||||
} as SetFriendAction);
|
||||
}
|
||||
|
||||
function loading(text: string) {
|
||||
dispatch({
|
||||
type: UpdateUIPropertyActionType,
|
||||
key: 'loading',
|
||||
value: text,
|
||||
} as UpdateUIPropertyAction);
|
||||
}
|
||||
|
||||
export default {
|
||||
setUser,
|
||||
setGuest,
|
||||
connect,
|
||||
disconnect,
|
||||
logout,
|
||||
setAvatar,
|
||||
updateUserProperty,
|
||||
setLinkmansLastMessages,
|
||||
|
||||
setFocus,
|
||||
setGroupMembers,
|
||||
setGroupAvatar,
|
||||
addLinkman,
|
||||
removeLinkman,
|
||||
setFriend,
|
||||
updateGroupProperty,
|
||||
updateFriendProperty,
|
||||
|
||||
addLinkmanMessage,
|
||||
addLinkmanHistoryMessages,
|
||||
updateSelfMessage,
|
||||
deleteLinkmanMessage,
|
||||
|
||||
loading,
|
||||
};
|
||||
@@ -0,0 +1,282 @@
|
||||
import produce from 'immer';
|
||||
import deepmerge from 'deepmerge';
|
||||
import {
|
||||
State,
|
||||
ActionTypes,
|
||||
ConnectActionType,
|
||||
LogoutActionType,
|
||||
SetUserActionType,
|
||||
SetGuestActionType,
|
||||
UpdateUserPropertyActionType,
|
||||
SetLinkmanMessagesActionType,
|
||||
SetFocusActionType,
|
||||
SetFriendActionType,
|
||||
Friend,
|
||||
AddLinkmanActionType,
|
||||
RemoveLinkmanActionType,
|
||||
AddlinkmanMessageActionType,
|
||||
AddLinkmanHistoryMessagesActionType,
|
||||
UpdateSelfMessageActionType,
|
||||
UpdateUIPropertyActionType,
|
||||
Group,
|
||||
UpdateGroupPropertyActionType,
|
||||
UpdateFriendPropertyActionType,
|
||||
DeleteLinkmanMessageActionType,
|
||||
User,
|
||||
Linkman,
|
||||
} from '../types/redux';
|
||||
import convertMessage from '../utils/convertMessage';
|
||||
|
||||
export function mergeLinkmans(
|
||||
linkmans1: Linkman[],
|
||||
linkmans2: Linkman[],
|
||||
): Linkman[] {
|
||||
const linkmansMap2 = linkmans2.reduce(
|
||||
(map: { [key: string]: Linkman }, linkman) => {
|
||||
map[linkman._id] = linkman;
|
||||
return map;
|
||||
},
|
||||
{},
|
||||
);
|
||||
const unionListingsIdSet = new Set(
|
||||
linkmans1
|
||||
.map((linkman) => linkman._id)
|
||||
.filter((linkmanId) => !!linkmansMap2[linkmanId]),
|
||||
);
|
||||
|
||||
const linkmans = [
|
||||
...linkmans1.filter((linkman) => unionListingsIdSet.has(linkman._id)),
|
||||
...linkmans2.filter((linkman) => !unionListingsIdSet.has(linkman._id)),
|
||||
];
|
||||
return linkmans.map((linkman) => {
|
||||
if (unionListingsIdSet.has(linkman._id)) {
|
||||
return deepmerge(linkman as any, linkmansMap2[linkman._id] as any, {
|
||||
customMerge: (key) => {
|
||||
if (key === 'messages') {
|
||||
// The new linkman data at this time does not have messages
|
||||
// So keep the old messages
|
||||
return () => linkman.messages;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return linkman;
|
||||
});
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
linkmans: [],
|
||||
focus: '',
|
||||
connect: true,
|
||||
ui: {
|
||||
ready: false,
|
||||
loading: '', // 全局loading文本内容, 为空则不展示
|
||||
primaryColor: '5,159,149',
|
||||
primaryTextColor: '255, 255, 255',
|
||||
},
|
||||
};
|
||||
|
||||
const reducer = produce((state: State = initialState, action: ActionTypes) => {
|
||||
switch (action.type) {
|
||||
case ConnectActionType: {
|
||||
state.connect = action.value;
|
||||
return state;
|
||||
}
|
||||
case LogoutActionType: {
|
||||
return initialState;
|
||||
}
|
||||
case SetUserActionType: {
|
||||
const currentUserId = (state.user as User)?._id;
|
||||
if (!currentUserId || currentUserId !== action.user._id) {
|
||||
// No user or guest user or different user
|
||||
state.user = action.user;
|
||||
state.linkmans = action.linkmans;
|
||||
} else {
|
||||
// Same user. Deep merge to reserve history messages;
|
||||
// But these history messages must be overwritten in SetLinkmanMessagesAction
|
||||
// Otherwise, there may be errors when fetch history messages later
|
||||
state.user = action.user;
|
||||
state.linkmans = mergeLinkmans(state.linkmans, action.linkmans);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case SetGuestActionType: {
|
||||
action.linkmans.forEach((linkman) => {
|
||||
linkman.messages.forEach(convertMessage);
|
||||
});
|
||||
state.linkmans = action.linkmans;
|
||||
return state;
|
||||
}
|
||||
case UpdateUserPropertyActionType: {
|
||||
// @ts-ignore
|
||||
state!.user[action.key] = action.value;
|
||||
return state;
|
||||
}
|
||||
case SetLinkmanMessagesActionType: {
|
||||
state.linkmans = state.linkmans.map((linkman) => ({
|
||||
...linkman,
|
||||
...(action.linkmans[linkman._id]
|
||||
? {
|
||||
messages: action.linkmans[linkman._id].messages.map(convertMessage),
|
||||
unread: action.linkmans[linkman._id].unread,
|
||||
}
|
||||
: {}),
|
||||
})) as Linkman[];
|
||||
state.linkmans.sort((linkman1, linkman2) => {
|
||||
const lastMessageTime1 =
|
||||
linkman1.messages.length > 0
|
||||
? linkman1.messages[linkman1.messages.length - 1]
|
||||
.createTime
|
||||
: linkman1.createTime;
|
||||
const lastMessageTime2 =
|
||||
linkman2.messages.length > 0
|
||||
? linkman2.messages[linkman2.messages.length - 1]
|
||||
.createTime
|
||||
: linkman2.createTime;
|
||||
return new Date(lastMessageTime1) < new Date(lastMessageTime2)
|
||||
? 1
|
||||
: -1;
|
||||
});
|
||||
if (
|
||||
!state.focus ||
|
||||
!state.linkmans.find((linkman) => linkman._id === state.focus)
|
||||
) {
|
||||
state.focus =
|
||||
state.linkmans.length > 0 ? state.linkmans[0]._id : '';
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case UpdateGroupPropertyActionType: {
|
||||
const group = state.linkmans.find(
|
||||
(linkman) =>
|
||||
linkman.type === 'group' && linkman._id === action.groupId,
|
||||
) as Group;
|
||||
if (group) {
|
||||
// @ts-ignore
|
||||
group[action.key] = action.value;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case UpdateFriendPropertyActionType: {
|
||||
const friend = state.linkmans.find(
|
||||
(linkman) =>
|
||||
linkman.type !== 'group' && linkman._id === action.userId,
|
||||
) as Friend;
|
||||
if (friend) {
|
||||
// @ts-ignore
|
||||
friend[action.key] = action.value;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case SetFocusActionType: {
|
||||
const targetLinkman = state.linkmans.find(
|
||||
(linkman) => linkman._id === action.linkmanId,
|
||||
);
|
||||
if (targetLinkman) {
|
||||
state.focus = action.linkmanId;
|
||||
targetLinkman.unread = 0;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case SetFriendActionType: {
|
||||
const friend = state.linkmans.find(
|
||||
(linkman) => linkman._id === action.linkmanId,
|
||||
) as Friend;
|
||||
if (friend) {
|
||||
friend.type = 'friend';
|
||||
friend.from = action.from;
|
||||
friend.to = action.to;
|
||||
friend.unread = 0;
|
||||
state.focus = action.linkmanId;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case AddLinkmanActionType: {
|
||||
state.linkmans.unshift(action.linkman);
|
||||
if (action.focus) {
|
||||
state.focus = action.linkman._id;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case RemoveLinkmanActionType: {
|
||||
const index = state.linkmans.findIndex(
|
||||
(linkman) => linkman._id === action.linkmanId,
|
||||
);
|
||||
if (index !== -1) {
|
||||
state.linkmans.splice(index, 1);
|
||||
if (state.focus === action.linkmanId) {
|
||||
state.focus =
|
||||
state.linkmans.length > 0 ? state.linkmans[0]._id : '';
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case AddlinkmanMessageActionType: {
|
||||
const targetLinkman = state.linkmans.find(
|
||||
(linkman) => linkman._id === action.linkmanId,
|
||||
);
|
||||
if (targetLinkman) {
|
||||
if (state.focus !== targetLinkman._id) {
|
||||
targetLinkman.unread += 1;
|
||||
}
|
||||
targetLinkman.messages.push(convertMessage(action.message));
|
||||
if (targetLinkman.messages.length > 500) {
|
||||
targetLinkman.messages.slice(250);
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case AddLinkmanHistoryMessagesActionType: {
|
||||
const targetLinkman = state.linkmans.find(
|
||||
(linkman) => linkman._id === action.linkmanId,
|
||||
);
|
||||
if (targetLinkman) {
|
||||
targetLinkman.messages.unshift(
|
||||
...action.messages.map(convertMessage),
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case UpdateSelfMessageActionType: {
|
||||
const targetLinkman = state.linkmans.find(
|
||||
(linkman) => linkman._id === action.linkmanId,
|
||||
);
|
||||
if (targetLinkman) {
|
||||
const targetMessage = targetLinkman.messages.find(
|
||||
(message) => message._id === action.messageId,
|
||||
);
|
||||
if (targetMessage) {
|
||||
Object.assign(
|
||||
targetMessage,
|
||||
convertMessage(action.message),
|
||||
);
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case DeleteLinkmanMessageActionType: {
|
||||
const targetLinkman = state.linkmans.find(
|
||||
(linkman) => linkman._id === action.linkmanId,
|
||||
);
|
||||
if (targetLinkman) {
|
||||
const targetMessage = targetLinkman.messages.find(
|
||||
(message) => message._id === action.messageId,
|
||||
);
|
||||
if (targetMessage) {
|
||||
targetMessage.deleted = true;
|
||||
convertMessage(targetMessage);
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case UpdateUIPropertyActionType: {
|
||||
state.ui[action.key] = action.value;
|
||||
return state;
|
||||
}
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}, initialState);
|
||||
|
||||
export default reducer;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createStore } from 'redux';
|
||||
import reducer from './reducer';
|
||||
|
||||
const store = createStore(
|
||||
// @ts-ignore
|
||||
reducer,
|
||||
// @ts-ignore
|
||||
window.__REDUX_DEVTOOLS_EXTENSION__ &&
|
||||
window.__REDUX_DEVTOOLS_EXTENSION__(),
|
||||
);
|
||||
export default store;
|
||||
@@ -0,0 +1,4 @@
|
||||
declare module '@react-native-toolkit/triangle';
|
||||
declare module 'react-native-dialog';
|
||||
|
||||
declare module '*.png';
|
||||
@@ -0,0 +1,232 @@
|
||||
export const ConnectActionType = 'SetConnect';
|
||||
export type ConnectAction = {
|
||||
type: typeof ConnectActionType;
|
||||
value: boolean;
|
||||
};
|
||||
|
||||
export const SetUserActionType = 'SetUser';
|
||||
export type SetUserAction = {
|
||||
type: typeof SetUserActionType;
|
||||
user: User;
|
||||
linkmans: Linkman[];
|
||||
};
|
||||
|
||||
export const SetLinkmanMessagesActionType = 'SetLinkmanMessages';
|
||||
export type SetLinkmanMessagesAction = {
|
||||
type: typeof SetLinkmanMessagesActionType;
|
||||
linkmans: Record<
|
||||
string,
|
||||
{
|
||||
messages: Message[];
|
||||
unread: number;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export const SetGuestActionType = 'SetGuest';
|
||||
export type SetGuestAction = {
|
||||
type: typeof SetGuestActionType;
|
||||
linkmans: Linkman[];
|
||||
};
|
||||
|
||||
export const LogoutActionType = 'Logout';
|
||||
export type LogoutAction = {
|
||||
type: typeof LogoutActionType;
|
||||
};
|
||||
|
||||
export const UpdateUserPropertyActionType = 'UpdateUserProperty';
|
||||
export type UpdateUserPropertyAction = {
|
||||
type: typeof UpdateUserPropertyActionType;
|
||||
key: keyof User;
|
||||
value: any;
|
||||
};
|
||||
|
||||
export const AddlinkmanMessageActionType = 'AddlinkmanMessage';
|
||||
export type AddlinkmanMessageAction = {
|
||||
type: typeof AddlinkmanMessageActionType;
|
||||
linkmanId: string;
|
||||
message: Message;
|
||||
};
|
||||
|
||||
export const DeleteLinkmanMessageActionType = 'DeleteLinkmanMessage';
|
||||
export type DeleteLinkmanMessageAction = {
|
||||
type: typeof DeleteLinkmanMessageActionType;
|
||||
linkmanId: string;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
export const AddLinkmanHistoryMessagesActionType = 'AddLinkmanHistoryMessages';
|
||||
export type AddLinkmanHistoryMessagesAction = {
|
||||
type: typeof AddLinkmanHistoryMessagesActionType;
|
||||
linkmanId: string;
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
export const UpdateSelfMessageActionType = 'UpdateSelfMessageActionType';
|
||||
export type UpdateSelfMessageAction = {
|
||||
type: typeof UpdateSelfMessageActionType;
|
||||
linkmanId: string;
|
||||
messageId: string;
|
||||
message: Message;
|
||||
};
|
||||
|
||||
export const SetFocusActionType = 'SetFocus';
|
||||
export type SetFocusAction = {
|
||||
type: typeof SetFocusActionType;
|
||||
linkmanId: string;
|
||||
};
|
||||
|
||||
export const UpdateGroupPropertyActionType = 'UpdateGroupProperty';
|
||||
export type UpdateGroupPropertyAction = {
|
||||
type: typeof UpdateGroupPropertyActionType;
|
||||
groupId: string;
|
||||
key: keyof Group;
|
||||
value: any;
|
||||
};
|
||||
|
||||
export const UpdateFriendPropertyActionType = 'UpdateFriendProperty';
|
||||
export type UpdateFriendPropertyAction = {
|
||||
type: typeof UpdateFriendPropertyActionType;
|
||||
userId: string;
|
||||
key: keyof Group;
|
||||
value: any;
|
||||
};
|
||||
|
||||
export const AddLinkmanActionType = 'AddLinkman';
|
||||
export type AddLinkmanAction = {
|
||||
type: typeof AddLinkmanActionType;
|
||||
linkman: Linkman;
|
||||
focus: boolean;
|
||||
};
|
||||
|
||||
export const RemoveLinkmanActionType = 'RemoveLinkmanActionType';
|
||||
export type RemoveLinkmanAction = {
|
||||
type: typeof RemoveLinkmanActionType;
|
||||
linkmanId: string;
|
||||
};
|
||||
|
||||
export const SetFriendActionType = 'SetFriend';
|
||||
export type SetFriendAction = {
|
||||
type: typeof SetFriendActionType;
|
||||
linkmanId: string;
|
||||
from: Friend['from'];
|
||||
to: Friend['to'];
|
||||
};
|
||||
|
||||
export const UpdateUIPropertyActionType = 'UpdateUIPropertyActionType';
|
||||
export type UpdateUIPropertyAction = {
|
||||
type: typeof UpdateUIPropertyActionType;
|
||||
key: keyof State['ui'];
|
||||
value: any;
|
||||
};
|
||||
|
||||
export type ActionTypes =
|
||||
| ConnectAction
|
||||
| SetUserAction
|
||||
| SetLinkmanMessagesAction
|
||||
| UpdateGroupPropertyAction
|
||||
| SetGuestAction
|
||||
| SetFocusAction
|
||||
| SetFriendAction
|
||||
| AddLinkmanAction
|
||||
| RemoveLinkmanAction
|
||||
| AddlinkmanMessageAction
|
||||
| AddLinkmanHistoryMessagesAction
|
||||
| DeleteLinkmanMessageAction
|
||||
| UpdateSelfMessageAction
|
||||
| UpdateUserPropertyAction
|
||||
| UpdateUIPropertyAction
|
||||
| UpdateFriendPropertyAction
|
||||
| LogoutAction;
|
||||
|
||||
export type Message = {
|
||||
_id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
createTime: number;
|
||||
percent?: number;
|
||||
loading?: boolean;
|
||||
from: {
|
||||
_id: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
tag: string;
|
||||
originUsername?: string;
|
||||
};
|
||||
to: string;
|
||||
deleted?: boolean;
|
||||
};
|
||||
|
||||
export type Group = {
|
||||
_id: string;
|
||||
type: 'group';
|
||||
name: string;
|
||||
avatar: string;
|
||||
messages: Message[];
|
||||
unread: number;
|
||||
members: {
|
||||
_id: string;
|
||||
user: {
|
||||
_id: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
};
|
||||
os: string;
|
||||
browser: string;
|
||||
environment: string;
|
||||
}[];
|
||||
creator: string;
|
||||
createTime: number;
|
||||
};
|
||||
|
||||
export type Friend = {
|
||||
_id: string;
|
||||
type: 'friend';
|
||||
name: string;
|
||||
avatar: string;
|
||||
from: string;
|
||||
to: {
|
||||
_id: string;
|
||||
avatar: string;
|
||||
username: string;
|
||||
};
|
||||
messages: Message[];
|
||||
unread: number;
|
||||
createTime: number;
|
||||
isOnline?: boolean;
|
||||
};
|
||||
|
||||
export type Temporary = {
|
||||
_id: string;
|
||||
type: 'temporary';
|
||||
name: string;
|
||||
avatar: string;
|
||||
messages: Message[];
|
||||
unread: number;
|
||||
createTime: number;
|
||||
isOnline?: boolean;
|
||||
};
|
||||
|
||||
export type Linkman = Group | Friend | Temporary;
|
||||
|
||||
export type User = {
|
||||
_id: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
tag: string;
|
||||
isAdmin: boolean;
|
||||
notificationTokens: string[];
|
||||
createTime: number;
|
||||
};
|
||||
|
||||
export type State = {
|
||||
user?: User;
|
||||
linkmans: Linkman[];
|
||||
focus: string;
|
||||
connect: boolean;
|
||||
ui: {
|
||||
loading: string;
|
||||
primaryColor: string;
|
||||
primaryTextColor: string;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export type Socket = {
|
||||
on: (event: string, callback: (...params: any) => void) => void;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const referer = 'https://fiora.suisuijiang.com/';
|
||||
@@ -0,0 +1,79 @@
|
||||
// function convertRobot10Message(message) {
|
||||
// if (message.from._id === '5adad39555703565e7903f79') {
|
||||
// try {
|
||||
// const parseMessage = JSON.parse(message.content);
|
||||
// message.from.tag = parseMessage.source;
|
||||
// message.from.avatar = parseMessage.avatar;
|
||||
// message.from.username = parseMessage.username;
|
||||
// message.type = parseMessage.type;
|
||||
// message.content = parseMessage.content;
|
||||
// } catch (err) {
|
||||
// console.warn('解析robot10消息失败', err);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
import { Message } from '../types/redux';
|
||||
|
||||
const WuZeiNiangImage = require('../assets/images/wuzeiniang.gif');
|
||||
|
||||
function convertSystemMessage(message: Message) {
|
||||
if (message.type === 'system') {
|
||||
message.from._id = 'system';
|
||||
message.from.originUsername = message.from.username;
|
||||
message.from.username = '乌贼娘殿下';
|
||||
message.from.avatar = WuZeiNiangImage;
|
||||
message.from.tag = 'system';
|
||||
|
||||
let content = null;
|
||||
try {
|
||||
content = JSON.parse(message.content);
|
||||
} catch {
|
||||
content = {
|
||||
command: 'parse-error',
|
||||
};
|
||||
}
|
||||
switch (content?.command) {
|
||||
case 'roll': {
|
||||
message.content = `掷出了${content.value}点 (上限${content.top}点)`;
|
||||
break;
|
||||
}
|
||||
case 'rps': {
|
||||
message.content = `使出了 ${content.value}`;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
message.content = '不支持的指令';
|
||||
}
|
||||
}
|
||||
} else if (message.deleted) {
|
||||
message.type = 'system';
|
||||
message.from._id = 'system';
|
||||
message.from.originUsername = message.from.username;
|
||||
message.from.username = '乌贼娘殿下';
|
||||
message.from.avatar = WuZeiNiangImage;
|
||||
message.from.tag = 'system';
|
||||
message.content = `撤回了消息`;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文本消息的html转义字符
|
||||
* @param {Object} message 消息
|
||||
*/
|
||||
function convertMessageHtml(message: Message) {
|
||||
if (message.type === 'text') {
|
||||
message.content = message.content
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
export default function convertMessage(message: Message) {
|
||||
convertSystemMessage(message);
|
||||
convertMessageHtml(message);
|
||||
return message;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export default {
|
||||
default: [
|
||||
'呵呵',
|
||||
'哈哈',
|
||||
'吐舌',
|
||||
'啊',
|
||||
'酷',
|
||||
'怒',
|
||||
'开心',
|
||||
'汗',
|
||||
'泪',
|
||||
'黑线',
|
||||
'鄙视',
|
||||
'不高兴',
|
||||
'真棒',
|
||||
'钱',
|
||||
'疑问',
|
||||
'阴险',
|
||||
'吐',
|
||||
'咦',
|
||||
'委屈',
|
||||
'花心',
|
||||
'呼',
|
||||
'笑眼',
|
||||
'冷',
|
||||
'太开心',
|
||||
'滑稽',
|
||||
'勉强',
|
||||
'狂汗',
|
||||
'乖',
|
||||
'睡觉',
|
||||
'惊哭',
|
||||
'升起',
|
||||
'惊讶',
|
||||
'喷',
|
||||
'爱心',
|
||||
'心碎',
|
||||
'玫瑰',
|
||||
'礼物',
|
||||
'星星月亮',
|
||||
'太阳',
|
||||
'音乐',
|
||||
'灯泡',
|
||||
'蛋糕',
|
||||
'彩虹',
|
||||
'钱币',
|
||||
'咖啡',
|
||||
'haha',
|
||||
'胜利',
|
||||
'大拇指',
|
||||
'弱',
|
||||
'ok',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import Toast from '../components/Toast';
|
||||
import socket from '../socket';
|
||||
|
||||
export default function fetch<T = any>(
|
||||
event: string,
|
||||
data: any = {},
|
||||
{ toast = true } = {},
|
||||
): Promise<[string | null, T | null]> {
|
||||
return new Promise((resolve) => {
|
||||
socket.emit(event, data, (res: any) => {
|
||||
if (typeof res === 'string') {
|
||||
if (toast) {
|
||||
Toast.danger(res);
|
||||
}
|
||||
resolve([res, null]);
|
||||
} else {
|
||||
resolve([null, res]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default function getFriendId(userId1: string, userId2: string) {
|
||||
if (userId1 < userId2) {
|
||||
return userId1 + userId2;
|
||||
}
|
||||
return userId2 + userId1;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import randomColor from 'randomcolor';
|
||||
|
||||
type ColorMode = 'dark' | 'bright' | 'light' | 'random';
|
||||
|
||||
/**
|
||||
* 获取随机颜色, 刷新页面不变
|
||||
* @param seed when passed will cause randomColor to return the same color each time
|
||||
*/
|
||||
export function getRandomColor(seed: string, luminosity: ColorMode = 'dark') {
|
||||
return randomColor({
|
||||
luminosity,
|
||||
seed,
|
||||
});
|
||||
}
|
||||
|
||||
type Cache = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
const cache: Cache = {};
|
||||
|
||||
/**
|
||||
* 获取随机颜色, 刷新页面后重新随机
|
||||
* @param seed 随机种子
|
||||
* @param luminosity 亮度
|
||||
*/
|
||||
export function getPerRandomColor(
|
||||
seed: string,
|
||||
luminosity: ColorMode = 'dark',
|
||||
) {
|
||||
if (cache[seed]) {
|
||||
return cache[seed];
|
||||
}
|
||||
cache[seed] = randomColor({ luminosity });
|
||||
return cache[seed];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Friend, Group, Linkman } from '../types/redux';
|
||||
|
||||
export function formatLinkmanName(linkman: Linkman) {
|
||||
if (linkman!.type === 'group' && (linkman as Group).members.length > 0) {
|
||||
return `${(linkman as Group).name} (${
|
||||
(linkman as Group).members.length
|
||||
})`;
|
||||
}
|
||||
if (
|
||||
linkman!.type !== 'group' &&
|
||||
(linkman as Friend).isOnline !== undefined
|
||||
) {
|
||||
return `${(linkman as Friend).name} (${
|
||||
(linkman as Friend).isOnline ? '在线' : '离线'
|
||||
})`;
|
||||
}
|
||||
return linkman.name;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Platform } from 'react-native';
|
||||
import Constants from 'expo-constants';
|
||||
// eslint-disable-next-line import/extensions
|
||||
import packageInfo from '../../app.json';
|
||||
|
||||
const os = Platform.OS === 'ios' ? 'iOS' : 'Android';
|
||||
|
||||
export const isiOS = Platform.OS === 'ios';
|
||||
export const isAndroid = Platform.OS === 'android';
|
||||
|
||||
export default {
|
||||
os,
|
||||
browser: 'App',
|
||||
environment: `App ${
|
||||
process.env.NODE_ENV === 'development'
|
||||
? '开发版'
|
||||
: packageInfo.expo.version
|
||||
} on ${os} ${
|
||||
isiOS ? Constants.platform?.ios?.systemVersion : Constants.systemVersion
|
||||
} ${isiOS ? Constants.platform?.ios?.model : ''}`,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export async function getStorageValue(key: string) {
|
||||
return AsyncStorage.getItem(key);
|
||||
}
|
||||
|
||||
export async function setStorageValue(key: string, value: string) {
|
||||
return AsyncStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
export async function removeStorageValue(key: string) {
|
||||
return AsyncStorage.removeItem(key);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export default {
|
||||
isToday(time1: Date, time2: Date) {
|
||||
return (
|
||||
time1.getFullYear() === time2.getFullYear() &&
|
||||
time1.getMonth() === time2.getMonth() &&
|
||||
time1.getDate() === time2.getDate()
|
||||
);
|
||||
},
|
||||
isYesterday(time1: Date, time2: Date) {
|
||||
const prevDate = new Date(time1);
|
||||
prevDate.setDate(time1.getDate() - 1);
|
||||
return (
|
||||
prevDate.getFullYear() === time2.getFullYear() &&
|
||||
prevDate.getMonth() === time2.getMonth() &&
|
||||
prevDate.getDate() === time2.getDate()
|
||||
);
|
||||
},
|
||||
isSameYear(time1: Date, time2: Date) {
|
||||
return time1.getFullYear() === time2.getFullYear();
|
||||
},
|
||||
getHourMinute(time: Date) {
|
||||
const hours = time.getHours();
|
||||
const minutes = time.getMinutes();
|
||||
return `${hours < 10 ? `0${hours}` : hours}:${
|
||||
minutes < 10 ? `0${minutes}` : minutes
|
||||
}`;
|
||||
},
|
||||
getMonthDate(time: Date) {
|
||||
return `${time.getMonth() + 1}/${time.getDate()}`;
|
||||
},
|
||||
getYearMonthDate(time: Date) {
|
||||
return `${time.getFullYear()}/${time.getMonth() + 1}/${time.getDate()}`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import fetch from './fetch';
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param blob 文件blob数据
|
||||
* @param fileName 文件名
|
||||
*/
|
||||
export default async function uploadFile(
|
||||
blob: Blob | string,
|
||||
fileName: string,
|
||||
isBase64 = false,
|
||||
): Promise<string> {
|
||||
const [uploadErr, result] = await fetch('uploadFile', {
|
||||
file: blob,
|
||||
fileName,
|
||||
isBase64,
|
||||
});
|
||||
if (uploadErr) {
|
||||
throw Error(`上传图片失败::${uploadErr}`);
|
||||
}
|
||||
return result.url;
|
||||
}
|
||||
|
||||
export function getOSSFileUrl(url: string | number = '', process = '') {
|
||||
if (typeof url === 'number') {
|
||||
return url;
|
||||
}
|
||||
const [rawUrl = '', extraPrams = ''] = url.split('?');
|
||||
if (/^\/\/cdn\.suisuijiang\.com/.test(rawUrl)) {
|
||||
return `https:${rawUrl}?x-oss-process=${process}${
|
||||
extraPrams ? `&${extraPrams}` : ''
|
||||
}`;
|
||||
}
|
||||
if (url.startsWith('//')) {
|
||||
return `https:${url}`;
|
||||
}
|
||||
if (url.startsWith('/')) {
|
||||
return `https://fiora.suisuijiang.com${url}`;
|
||||
}
|
||||
return `${url}`;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { mergeLinkmans } from '../../src/state/reducer';
|
||||
import { Linkman } from '../../src/types/redux';
|
||||
|
||||
describe('mergeLinkmans', () => {
|
||||
it('should return linkmans which is newly and reserve history messages', () => {
|
||||
const linkmans1 = [
|
||||
{
|
||||
_id: 'l1',
|
||||
name: 'l1',
|
||||
messages: [],
|
||||
},
|
||||
{
|
||||
_id: 'l2',
|
||||
name: 'l2',
|
||||
messages: [
|
||||
{
|
||||
_id: 'm1',
|
||||
},
|
||||
{
|
||||
_id: 'm2',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const linkmans2 = [
|
||||
{
|
||||
_id: 'l2',
|
||||
name: 'l2',
|
||||
messages: [
|
||||
{
|
||||
_id: 'm1',
|
||||
},
|
||||
{
|
||||
_id: 'm2',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
_id: 'l3',
|
||||
name: 'l3',
|
||||
messages: [],
|
||||
},
|
||||
];
|
||||
|
||||
const linkmans = mergeLinkmans(
|
||||
linkmans1 as any,
|
||||
linkmans2 as any,
|
||||
) as Linkman[];
|
||||
expect(linkmans).toHaveLength(2);
|
||||
expect(linkmans[0]._id).toBe('l2');
|
||||
expect(linkmans[1]._id).toBe('l3');
|
||||
|
||||
expect(linkmans[0].messages).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../tsconfig",
|
||||
}
|
||||