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
+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}`;
}