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
+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.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

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