chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:27:50 +08:00
commit cc8f841fc5
422 changed files with 70222 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
**/node_module
packages/docs/
packages/web/.linaria-cache/
packages/web/dist/
yarn-error.log
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
public/
build/
docs/
*.d.ts
+72
View File
@@ -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"
}
}
+67
View File
@@ -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
+21
View File
@@ -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
+18
View File
@@ -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
+21
View File
@@ -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
+23
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
{
"tabWidth": 4,
"trailingComma": "all",
"singleQuote": true,
"arrowParens": "always",
"printWidth": 80
}
+3
View File
@@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}
+13
View File
@@ -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
+21
View File
@@ -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.
+57
View File
@@ -0,0 +1,57 @@
# [Fiora](https://fiora.suisuijiang.com/) · [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/yinxin630/fiora/blob/master/LICENSE) [![author](https://img.shields.io/badge/author-%E7%A2%8E%E7%A2%8E%E9%85%B1-blue.svg)](http://suisuijiang.com) [![Node.js Version](https://img.shields.io/badge/node.js-14.16.0-blue.svg)](http://nodejs.org/download) [![Test Status](https://github.com/yinxin630/fiora/workflows/Unit%20Test/badge.svg)](https://github.com/yinxin630/fiora/actions?query=workflow%3A%22Unit+Test%22) [![Typescript Status](https://github.com/yinxin630/fiora/workflows/Typescript%20Type%20Check/badge.svg)](https://github.com/yinxin630/fiora/actions?query=workflow%3A%22Typescript+Type+Check%22)
Fiora is an interesting open source chat application. It is developed based on [node.js](https://nodejs.org/), [react](https://reactjs.org/) and [socket.io](https://socket.io/) technologies
- **Richness:** Fiora contains backend, frontend, Android and iOS apps
- **Cross Platform:** Fiora is developed with node.js. Supports Windows / Linux / macOS systems
- **Open Source:** Fiora follows the MIT open source license
Online Example: [https://fiora.suisuijiang.com/](https://fiora.suisuijiang.com/)
Documentation: [https://yinxin630.github.io/fiora/](https://yinxin630.github.io/fiora/)
**Other Client**
Vscode Extension: [https://github.com/moonrailgun/fiora-for-vscode](https://github.com/moonrailgun/fiora-for-vscode)
If you are seek for other open-source IM Application which like discord or slack, maybe try out `Tailchat`: https://tailchat.msgbyte.com/
## Features
1. Register an account and log in, it can save your data for a long time
2. Join an existing group or create your own group to communicate with everyone
3. Chat privately with anyone and add them as friends
4. Multiple message types, including text / emoticons / pictures / codes / files / commands, you can also search for emoticons
5. Push notification when you receive a new message, you can customize the notification ringtone, and it can also read the message out
6. Choose the theme you like, and you can set it as any wallpaper and theme color you like
7. Set up an administrator to manage users
## Screenshot
<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)
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`yinxin630/fiora`
- 原始仓库:https://github.com/yinxin630/fiora
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+17
View File
@@ -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
Executable
+84
View File
@@ -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);
+20
View File
@@ -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/**',
],
};
+5
View File
@@ -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'));
+7
View File
@@ -0,0 +1,7 @@
const path = require('path');
module.exports = {
process(src, filename) {
return `module.exports = ${JSON.stringify(path.basename(filename))};`;
},
};
+7
View File
@@ -0,0 +1,7 @@
{
"packages": [
"packages/*"
],
"version": "independent",
"npmClient": "yarn"
}
+60
View File
@@ -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"
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"presets": ["babel-preset-expo"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../.eslintrc",
"rules": {
"no-use-before-define": "off",
"consistent-return": "off"
}
}
+18
View File
@@ -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*
+1
View File
@@ -0,0 +1 @@
{}
+13
View File
@@ -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>
);
}
+28
View File
@@ -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
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+53
View File
@@ -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"
}
}
+175
View File
@@ -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,
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+23
View File
@@ -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>
);
}
+40
View File
@@ -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 }]} />;
}
+43
View File
@@ -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)',
},
});
+25
View File
@@ -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',
});
},
};
+53
View File
@@ -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 || '';
}
+167
View File
@@ -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',
},
});
+384
View File
@@ -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}>
&quot;
{invite.inviterName}
&quot;
{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,
},
});
+317
View File
@@ -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',
},
});
+243
View File
@@ -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}
&nbsp;
</Text>
<Text style={styles.text}>{content}</Text>
</View>
);
}
export default SystemMessage;
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
},
text: {
fontSize: 14,
},
});
+115
View File
@@ -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,
},
});
+131
View File
@@ -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',
},
});
+114
View File
@@ -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>
);
}
+215
View File
@@ -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',
},
});
+46
View File
@@ -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,
},
});
+379
View File
@@ -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;
}
+197
View File
@@ -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;
+260
View File
@@ -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,
};
+282
View File
@@ -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;
+11
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
declare module '@react-native-toolkit/triangle';
declare module 'react-native-dialog';
declare module '*.png';
+232
View File
@@ -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;
};
};
+3
View File
@@ -0,0 +1,3 @@
export type Socket = {
on: (event: string, callback: (...params: any) => void) => void;
};
+1
View File
@@ -0,0 +1 @@
export const referer = 'https://fiora.suisuijiang.com/';
+79
View File
@@ -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(/&lt;/g, '<')
.replace(/&gt;/g, '>');
}
return message;
}
export default function convertMessage(message: Message) {
convertSystemMessage(message);
convertMessageHtml(message);
return message;
}
+54
View File
@@ -0,0 +1,54 @@
export default {
default: [
'呵呵',
'哈哈',
'吐舌',
'啊',
'酷',
'怒',
'开心',
'汗',
'泪',
'黑线',
'鄙视',
'不高兴',
'真棒',
'钱',
'疑问',
'阴险',
'吐',
'咦',
'委屈',
'花心',
'呼',
'笑眼',
'冷',
'太开心',
'滑稽',
'勉强',
'狂汗',
'乖',
'睡觉',
'惊哭',
'升起',
'惊讶',
'喷',
'爱心',
'心碎',
'玫瑰',
'礼物',
'星星月亮',
'太阳',
'音乐',
'灯泡',
'蛋糕',
'彩虹',
'钱币',
'咖啡',
'haha',
'胜利',
'大拇指',
'弱',
'ok',
],
};
+21
View File
@@ -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]);
}
});
});
}
+6
View File
@@ -0,0 +1,6 @@
export default function getFriendId(userId1: string, userId2: string) {
if (userId1 < userId2) {
return userId1 + userId2;
}
return userId2 + userId1;
}
+36
View File
@@ -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];
}
+18
View File
@@ -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;
}
+21
View File
@@ -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 : ''}`,
};
+13
View File
@@ -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);
}
+34
View File
@@ -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()}`;
},
};
+41
View File
@@ -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}`;
}
+55
View File
@@ -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);
});
});
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig",
}
+15480
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More