feat: add QR code login and enhance chat experience

- Add QR code login flow with scanner and confirmation screens
- Implement swipe-to-reply in chat with SwipeableMessageBubble
- Replace FlatList with FlashList for chat performance optimization
- Add Schedule stack navigator with CourseDetail screen support
- Configure deep linking for QR code login (carrotbbs://qrcode/login/:sessionId)
- Update branding from 胡萝卜 to 萝卜社区
- Display dynamic app version in settings
This commit is contained in:
lafay
2026-03-20 19:28:42 +08:00
parent 59877e6ae3
commit a005fb0a15
24 changed files with 2878 additions and 1859 deletions

View File

@@ -188,7 +188,7 @@ export const LoginScreen: React.FC = () => {
</View>
{/* 品牌名称 - 优化大屏排版 */}
<Text style={[styles.appName, isSplitLayout && styles.splitAppName]}></Text>
<Text style={[styles.appName, isSplitLayout && styles.splitAppName]}></Text>
<Text style={[styles.subtitle, isSplitLayout && styles.splitSubtitle]}></Text>
{/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */}
@@ -431,7 +431,7 @@ export const LoginScreen: React.FC = () => {
color="#FFF"
/>
</View>
<Text style={[styles.appName, { fontSize: appNameSize }]}></Text>
<Text style={[styles.appName, { fontSize: appNameSize }]}></Text>
<Text style={styles.subtitle}></Text>
</Animated.View>

View File

@@ -0,0 +1,288 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
ActivityIndicator,
Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '../../navigation/types';
import { qrcodeApi } from '../../services/authService';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
type QRCodeConfirmRouteProp = RouteProp<RootStackParamList, 'QRCodeConfirm'>;
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
export const QRCodeConfirmScreen: React.FC = () => {
const route = useRoute<QRCodeConfirmRouteProp>();
const navigation = useNavigation<NavigationProp>();
const { sessionId } = route.params;
const [loading, setLoading] = useState(false);
const [userInfo, setUserInfo] = useState<{ id: string; nickname: string; avatar: string } | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// 扫码
handleScan();
}, []);
const handleScan = async () => {
try {
setLoading(true);
const response = await qrcodeApi.scan(sessionId);
setUserInfo(response.user);
} catch (err: any) {
const errorMsg = err.response?.data?.message || '扫码失败';
setError(errorMsg);
Alert.alert('扫码失败', errorMsg, [
{ text: '确定', onPress: () => navigation.goBack() }
]);
} finally {
setLoading(false);
}
};
const handleConfirm = async () => {
try {
setLoading(true);
await qrcodeApi.confirm(sessionId);
Alert.alert('登录成功', '网页端已登录', [
{ text: '确定', onPress: () => navigation.goBack() }
]);
} catch (err: any) {
const errorMsg = err.response?.data?.message || '确认登录失败';
Alert.alert('登录失败', errorMsg);
} finally {
setLoading(false);
}
};
const handleCancel = async () => {
try {
await qrcodeApi.cancel(sessionId);
} catch (err) {
// 忽略取消错误
}
navigation.goBack();
};
if (loading && !userInfo) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
<Text style={styles.loadingText}>...</Text>
</View>
</SafeAreaView>
);
}
if (error) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.errorContainer}>
<MaterialCommunityIcons name="alert-circle" size={64} color={colors.error.main} />
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity style={styles.backButton} onPress={() => navigation.goBack()}>
<Text style={styles.backButtonText}></Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={handleCancel} style={styles.closeButton}>
<MaterialCommunityIcons name="close" size={24} color="#666" />
</TouchableOpacity>
<Text style={styles.headerTitle}></Text>
<View style={styles.placeholder} />
</View>
<View style={styles.content}>
<View style={styles.infoCard}>
<Text style={styles.infoText}></Text>
{userInfo && (
<View style={styles.userInfo}>
{userInfo.avatar ? (
<Image source={{ uri: userInfo.avatar }} style={styles.avatar} />
) : (
<View style={styles.avatarPlaceholder}>
<MaterialCommunityIcons name="account" size={40} color="#999" />
</View>
)}
<Text style={styles.nickname}>{userInfo.nickname}</Text>
</View>
)}
</View>
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[styles.button, styles.confirmButton]}
onPress={handleConfirm}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.confirmButtonText}></Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.cancelButton]}
onPress={handleCancel}
disabled={loading}
>
<Text style={styles.cancelButtonText}></Text>
</TouchableOpacity>
</View>
</View>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
closeButton: {
padding: spacing.sm,
},
headerTitle: {
fontSize: fontSizes.lg,
fontWeight: '600',
color: '#333',
},
placeholder: {
width: 40,
},
content: {
flex: 1,
padding: spacing.lg,
justifyContent: 'center',
},
infoCard: {
backgroundColor: '#fff',
borderRadius: borderRadius.lg,
padding: spacing.xl,
alignItems: 'center',
marginBottom: spacing.xl,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
infoText: {
fontSize: fontSizes.md,
color: '#666',
marginBottom: spacing.lg,
},
userInfo: {
alignItems: 'center',
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
marginBottom: spacing.md,
},
avatarPlaceholder: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#f0f0f0',
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.md,
},
nickname: {
fontSize: fontSizes.lg,
fontWeight: '600',
color: '#333',
},
buttonContainer: {
gap: spacing.md,
},
button: {
paddingVertical: spacing.md,
paddingHorizontal: spacing.xl,
borderRadius: borderRadius.md,
alignItems: 'center',
},
confirmButton: {
backgroundColor: colors.primary.main,
},
confirmButtonText: {
color: '#fff',
fontSize: fontSizes.md,
fontWeight: '600',
},
cancelButton: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ddd',
},
cancelButtonText: {
color: '#666',
fontSize: fontSizes.md,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: spacing.md,
fontSize: fontSizes.md,
color: '#666',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
},
errorText: {
marginTop: spacing.md,
fontSize: fontSizes.md,
color: '#666',
textAlign: 'center',
},
backButton: {
marginTop: spacing.xl,
paddingVertical: spacing.md,
paddingHorizontal: spacing.xl,
backgroundColor: colors.primary.main,
borderRadius: borderRadius.md,
},
backButtonText: {
color: '#fff',
fontSize: fontSizes.md,
fontWeight: '600',
},
});
export default QRCodeConfirmScreen;

View File

@@ -268,8 +268,8 @@ export const RegisterScreen: React.FC = () => {
</View>
{/* 品牌名称 - 优化大屏排版 */}
<Text style={[styles.title, isSplitLayout && styles.splitTitle]}></Text>
<Text style={[styles.subtitle, isSplitLayout && styles.splitSubtitle]}></Text>
<Text style={[styles.title, isSplitLayout && styles.splitTitle]}></Text>
<Text style={[styles.subtitle, isSplitLayout && styles.splitSubtitle]}></Text>
{/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */}
<View style={[styles.leftPanelDecorBottom, isSplitLayout && styles.splitLeftPanelDecorBottom]}>
@@ -720,8 +720,8 @@ export const RegisterScreen: React.FC = () => {
color="#FFF"
/>
</View>
<Text style={[styles.title, { fontSize: titleSize }]}></Text>
<Text style={styles.subtitle}></Text>
<Text style={[styles.title, { fontSize: titleSize }]}></Text>
<Text style={styles.subtitle}></Text>
</Animated.View>
{/* 表单 */}

View File

@@ -19,15 +19,15 @@
* - ChatInput.tsx: 输入框组件
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import {
View,
FlatList,
TouchableWithoutFeedback,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { FlashList } from "@shopify/flash-list";
import { useNavigation } from '@react-navigation/native';
import { StatusBar } from 'expo-status-bar';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -224,6 +224,7 @@ export const ChatScreen: React.FC = () => {
formatTime={formatTime}
shouldShowTime={shouldShowTime}
onImagePress={handleImagePress}
onReply={handleReplyMessage}
/>
);
@@ -268,25 +269,23 @@ export const ChatScreen: React.FC = () => {
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
) : (
<FlatList
<FlashList
ref={flatListRef}
data={messages}
renderItem={renderMessage}
keyExtractor={item => String(item.id)}
contentContainerStyle={listContentStyle}
keyExtractor={(item: any) => String(item.id)}
contentContainerStyle={listContentStyle as any}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
refreshing={loadingMore}
onRefresh={hasMoreHistory ? loadMoreHistory : undefined}
progressViewOffset={0}
onContentSizeChange={handleMessageListContentSizeChange}
// @ts-ignore FlashList 类型定义问题estimatedItemSize 是必需属性但类型定义缺失
estimatedItemSize={80}
maintainVisibleContentPosition={{
minIndexForVisible: 0,
autoscrollToTopThreshold: undefined,
} as any}
onScroll={handleMessageListScroll}
scrollEventThrottle={16}
// 优化渲染性能
initialNumToRender={15}
maxToRenderPerBatch={10}
windowSize={10}
removeClippedSubviews={true}
/>
)}
</View>

View File

@@ -42,6 +42,8 @@ import { getUserCache } from '../../services/database';
import { EmbeddedChat } from './components/EmbeddedChat';
// 导入 NotificationsScreen 用于在内部显示
import { NotificationsScreen } from './NotificationsScreen';
// 导入扫码组件
import { QRCodeScanner } from '../../components/business/QRCodeScanner';
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
type MessageNavProp = NativeStackNavigationProp<MessageStackParamList>;
@@ -182,6 +184,7 @@ export const MessageListScreen: React.FC = () => {
const [isSearching, setIsSearching] = useState(false);
const [activeSearchTab, setActiveSearchTab] = useState<'chat' | 'user'>('chat');
const [actionMenuVisible, setActionMenuVisible] = useState(false);
const [scannerVisible, setScannerVisible] = useState(false);
// 系统通知显示状态 - 用于在移动端显示通知页面
const [showNotifications, setShowNotifications] = useState(false);
@@ -356,7 +359,7 @@ export const MessageListScreen: React.FC = () => {
setActionMenuVisible(true);
};
const runMenuAction = (action: 'join' | 'create' | 'readAll') => {
const runMenuAction = (action: 'join' | 'create' | 'readAll' | 'scanQRCode') => {
setActionMenuVisible(false);
if (action === 'join') {
handleJoinGroup();
@@ -366,6 +369,10 @@ export const MessageListScreen: React.FC = () => {
handleCreateGroup();
return;
}
if (action === 'scanQRCode') {
setScannerVisible(true);
return;
}
handleMarkAllRead();
};
@@ -911,6 +918,10 @@ export const MessageListScreen: React.FC = () => {
<Modal visible={actionMenuVisible} transparent animationType="fade" onRequestClose={() => setActionMenuVisible(false)}>
<Pressable style={styles.actionMenuBackdrop} onPress={() => setActionMenuVisible(false)}>
<View style={styles.actionMenu}>
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('scanQRCode')}>
<MaterialCommunityIcons name="qrcode-scan" size={21} color="#444" />
<Text style={styles.actionMenuText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('join')}>
<MaterialCommunityIcons name="account-plus-outline" size={21} color="#444" />
<Text style={styles.actionMenuText}></Text>
@@ -980,6 +991,7 @@ export const MessageListScreen: React.FC = () => {
renderConversationList()
)}
{renderActionMenu()}
<QRCodeScanner visible={scannerVisible} onClose={() => setScannerVisible(false)} />
</SafeAreaView>
);
};

View File

@@ -4,7 +4,7 @@
* 支持响应式布局(宽屏下优化显示)
*/
import React, { useRef, useMemo } from 'react';
import React, { useRef, useMemo, useCallback } from 'react';
import {
View,
TouchableOpacity,
@@ -22,6 +22,14 @@ import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive
import { MessageBubbleProps, SenderInfo, MenuPosition, GroupMessage } from './types';
import { MessageSegmentsRenderer } from './SegmentRenderer';
import { MessageSegment, ImageSegmentData, extractTextFromSegments } from '../../../../types/dto';
import { SwipeableMessageBubble } from './SwipeableMessageBubble';
import {
getBubbleBorderRadius,
getMessageGroupPosition,
shouldShowSenderInfo,
bubbleColors,
bubbleStyles,
} from './bubbleStyles';
// 获取屏幕宽度
const { width: SCREEN_WIDTH } = Dimensions.get('window');
@@ -50,6 +58,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
formatTime,
shouldShowTime,
onImagePress,
onReply,
}) => {
const bubbleRef = useRef<View>(null);
const pressPositionRef = useRef({ x: 0, y: 0 });
@@ -343,6 +352,13 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
);
};
// 处理滑动回复
const handleSwipeReply = useCallback(() => {
if (onReply) {
onReply(message);
}
}, [onReply, message]);
// 系统通知消息渲染
if (isSystemNotice) {
return (
@@ -361,7 +377,8 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
);
}
return (
// 消息内容渲染
const messageContent = (
<TouchableOpacity
onPressIn={handlePressIn}
onLongPress={handleLongPress}
@@ -444,6 +461,17 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
</View>
</TouchableOpacity>
);
// 使用 SwipeableMessageBubble 包裹消息内容
return (
<SwipeableMessageBubble
isMe={isMe}
onReply={handleSwipeReply}
enabled={!!onReply}
>
{messageContent}
</SwipeableMessageBubble>
);
};
// Segment 消息的特殊样式 - 自适应宽度
@@ -460,4 +488,16 @@ const segmentStyles = StyleSheet.create({
},
});
export default MessageBubble;
// 使用 React.memo 优化渲染性能
export default React.memo(MessageBubble, (prevProps, nextProps) => {
// 自定义比较逻辑:只比较关键属性
return (
prevProps.message.id === nextProps.message.id &&
prevProps.message.status === nextProps.message.status &&
prevProps.selectedMessageId === nextProps.selectedMessageId &&
prevProps.currentUserId === nextProps.currentUserId &&
prevProps.isGroupChat === nextProps.isGroupChat &&
prevProps.otherUserLastReadSeq === nextProps.otherUserLastReadSeq &&
prevProps.index === nextProps.index
);
});

View File

@@ -290,6 +290,7 @@ const ImageSegment: React.FC<{
contentFit="cover"
cachePolicy="disk"
priority="normal"
transition={200}
onError={() => {
setLoadError(true);
}}

View File

@@ -0,0 +1,157 @@
/**
* 可滑动的消息气泡容器
* 实现滑动回复手势功能 - 严格单向滑动,无抖动
*/
import React, { useRef, useCallback } from 'react';
import { View, StyleSheet, Animated } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { PanGestureHandler, State } from 'react-native-gesture-handler';
import * as Haptics from 'expo-haptics';
import { colors } from '../../../../theme';
// 滑动阈值
const SWIPE_THRESHOLD = 30;
const MAX_SWIPE_DISTANCE = 50;
interface SwipeableMessageBubbleProps {
children: React.ReactNode;
isMe: boolean;
onReply: () => void;
enabled?: boolean;
}
export const SwipeableMessageBubble: React.FC<SwipeableMessageBubbleProps> = ({
children,
isMe,
onReply,
enabled = true,
}) => {
const translateX = useRef(new Animated.Value(0)).current;
const hasTriggeredRef = useRef(false);
// 处理手势事件 - 使用原生驱动,不干预值
const onGestureEvent = Animated.event(
[{ nativeEvent: { translationX: translateX } }],
{ useNativeDriver: true }
);
// 处理手势状态变化
const onHandlerStateChange = useCallback((event: any) => {
const { nativeEvent } = event;
if (nativeEvent.state === State.END) {
const translationX = nativeEvent.translationX;
// 只允许特定方向的滑动
const shouldTrigger = isMe
? translationX < -SWIPE_THRESHOLD // 自己消息只能向左滑
: translationX > SWIPE_THRESHOLD; // 对方消息只能向右滑
if (shouldTrigger && !hasTriggeredRef.current) {
hasTriggeredRef.current = true;
// 触觉反馈
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// 立即触发回复
onReply();
// 重置标记
setTimeout(() => {
hasTriggeredRef.current = false;
}, 300);
}
// 立即回到原位
translateX.setValue(0);
}
}, [isMe, onReply]);
// 计算回复图标的透明度 - 只在允许的方向显示
const iconOpacity = translateX.interpolate({
inputRange: isMe
? [-MAX_SWIPE_DISTANCE, -SWIPE_THRESHOLD, 0, 10]
: [-10, 0, SWIPE_THRESHOLD, MAX_SWIPE_DISTANCE],
outputRange: isMe ? [1, 0.8, 0, 0] : [0, 0, 0.8, 1],
extrapolate: 'clamp',
});
// 计算消息的位移 - 限制在允许的方向
const clampedTranslateX = translateX.interpolate({
inputRange: isMe
? [-MAX_SWIPE_DISTANCE - 10, -MAX_SWIPE_DISTANCE, 0, 10]
: [-10, 0, MAX_SWIPE_DISTANCE, MAX_SWIPE_DISTANCE + 10],
outputRange: isMe
? [-MAX_SWIPE_DISTANCE, -MAX_SWIPE_DISTANCE, 0, 0]
: [0, 0, MAX_SWIPE_DISTANCE, MAX_SWIPE_DISTANCE],
extrapolate: 'clamp',
});
// 如果禁用手势,直接返回子元素
if (!enabled) {
return <>{children}</>;
}
return (
<View style={styles.container}>
{/* 回复图标 - 在消息后面 */}
<Animated.View
style={[
styles.replyIconContainer,
isMe ? styles.replyIconLeft : styles.replyIconRight,
{
opacity: iconOpacity,
},
]}
>
<MaterialCommunityIcons
name="reply"
size={20}
color={colors.primary.main}
/>
</Animated.View>
{/* 可滑动的消息内容 */}
<PanGestureHandler
onGestureEvent={onGestureEvent}
onHandlerStateChange={onHandlerStateChange}
activeOffsetX={isMe ? [-10, 9999] : [-9999, 10]}
failOffsetY={[-20, 20]}
>
<Animated.View
style={{
transform: [{ translateX: clampedTranslateX }],
}}
>
{children}
</Animated.View>
</PanGestureHandler>
</View>
);
};
const styles = StyleSheet.create({
container: {
position: 'relative',
},
replyIconContainer: {
position: 'absolute',
top: '50%',
marginTop: -15,
width: 30,
height: 30,
borderRadius: 15,
backgroundColor: 'rgba(255, 107, 53, 0.1)',
alignItems: 'center',
justifyContent: 'center',
},
replyIconLeft: {
left: 8,
},
replyIconRight: {
right: 8,
},
});
export default SwipeableMessageBubble;

View File

@@ -0,0 +1,270 @@
/**
* 消息气泡样式工具
* 参考 Element X 设计,实现动态圆角和现代化气泡样式
*/
import { StyleSheet, ViewStyle, TextStyle } from 'react-native';
import { colors, spacing } from '../../../../theme';
// 气泡圆角大小(参考 Element X: 12pt
export const BUBBLE_RADIUS = 16;
// 气泡颜色方案(参考 Element X 的语义化颜色)
export const bubbleColors = {
// 自己发送的消息
outgoing: {
background: '#E8E8E8', // Element X 风格:灰色系
text: '#1A1A1A',
},
// 收到的消息
incoming: {
background: '#FFFFFF',
text: '#1A1A1A',
},
// 被回复的消息高亮
replyHighlight: {
background: 'rgba(255, 107, 53, 0.08)',
borderLeft: '#FF6B35',
},
};
// 消息在组中的位置类型
export type MessageGroupPosition = 'single' | 'first' | 'middle' | 'last';
/**
* 根据消息在组中的位置获取圆角样式
* 参考 Element X 的动态圆角逻辑
*/
export const getBubbleBorderRadius = (
isMe: boolean,
position: MessageGroupPosition
): ViewStyle => {
const radius = BUBBLE_RADIUS;
if (isMe) {
// 自己发送的消息
switch (position) {
case 'single':
return {
borderTopLeftRadius: radius,
borderTopRightRadius: radius,
borderBottomLeftRadius: radius,
borderBottomRightRadius: 4, // 尖角在右下
};
case 'first':
return {
borderTopLeftRadius: radius,
borderTopRightRadius: radius,
borderBottomLeftRadius: radius,
borderBottomRightRadius: 4,
};
case 'middle':
return {
borderTopLeftRadius: radius,
borderTopRightRadius: 4,
borderBottomLeftRadius: radius,
borderBottomRightRadius: 4,
};
case 'last':
return {
borderTopLeftRadius: radius,
borderTopRightRadius: 4,
borderBottomLeftRadius: radius,
borderBottomRightRadius: radius,
};
}
} else {
// 收到的消息
switch (position) {
case 'single':
return {
borderTopLeftRadius: 4, // 尖角在左上
borderTopRightRadius: radius,
borderBottomLeftRadius: radius,
borderBottomRightRadius: radius,
};
case 'first':
return {
borderTopLeftRadius: 4,
borderTopRightRadius: radius,
borderBottomLeftRadius: 4,
borderBottomRightRadius: radius,
};
case 'middle':
return {
borderTopLeftRadius: 4,
borderTopRightRadius: radius,
borderBottomLeftRadius: 4,
borderBottomRightRadius: radius,
};
case 'last':
return {
borderTopLeftRadius: radius,
borderTopRightRadius: radius,
borderBottomLeftRadius: radius,
borderBottomRightRadius: radius,
};
}
}
};
/**
* 判断消息在组中的位置
*/
export const getMessageGroupPosition = (
index: number,
messages: Array<{ sender_id: string }>,
currentUserId: string
): MessageGroupPosition => {
const currentMessage = messages[index];
if (!currentMessage) return 'single';
const prevMessage = index > 0 ? messages[index - 1] : null;
const nextMessage = index < messages.length - 1 ? messages[index + 1] : null;
const isSameSenderAsPrev = prevMessage && prevMessage.sender_id === currentMessage.sender_id;
const isSameSenderAsNext = nextMessage && nextMessage.sender_id === currentMessage.sender_id;
if (!isSameSenderAsPrev && !isSameSenderAsNext) {
return 'single';
} else if (!isSameSenderAsPrev && isSameSenderAsNext) {
return 'first';
} else if (isSameSenderAsPrev && isSameSenderAsNext) {
return 'middle';
} else {
return 'last';
}
};
/**
* 判断是否显示发送者信息(只在组的第一条消息显示)
*/
export const shouldShowSenderInfo = (
index: number,
messages: Array<{ sender_id: string }>,
): boolean => {
if (index === 0) return true;
const currentMessage = messages[index];
const prevMessage = messages[index - 1];
return prevMessage.sender_id !== currentMessage.sender_id;
};
/**
* 获取气泡基础样式
*/
export const getBubbleBaseStyle = (isMe: boolean): ViewStyle => ({
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 4,
minWidth: 60,
maxWidth: '75%',
backgroundColor: isMe ? bubbleColors.outgoing.background : bubbleColors.incoming.background,
// Element X 风格的微妙阴影
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 2,
elevation: 2,
});
/**
* 获取文本样式
*/
export const getBubbleTextStyle = (isMe: boolean): TextStyle => ({
color: isMe ? bubbleColors.outgoing.text : bubbleColors.incoming.text,
fontSize: 16,
lineHeight: 23,
letterSpacing: 0.2,
fontWeight: '400',
});
/**
* 消息气泡样式集合
*/
export const bubbleStyles = StyleSheet.create({
// 基础气泡
bubble: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 4,
minWidth: 60,
},
// 自己发送的消息
outgoing: {
backgroundColor: bubbleColors.outgoing.background,
},
// 收到的消息
incoming: {
backgroundColor: bubbleColors.incoming.background,
},
// 文本样式
text: {
fontSize: 16,
lineHeight: 23,
letterSpacing: 0.2,
fontWeight: '400',
},
// 阴影样式Element X 风格)
shadow: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 2,
elevation: 2,
},
// 长按反馈阴影
longPressShadow: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.12,
shadowRadius: 8,
elevation: 6,
},
// 被回复消息的高亮边框
replyHighlight: {
borderLeftWidth: 3,
borderLeftColor: colors.primary.main,
backgroundColor: bubbleColors.replyHighlight.background,
},
// 已撤回消息
recalled: {
backgroundColor: '#F5F7FA',
borderWidth: 1,
borderColor: '#E8E8E8',
borderStyle: 'dashed',
borderRadius: 12,
},
// 系统通知
systemNotice: {
alignItems: 'center',
marginVertical: spacing.sm,
},
systemNoticeText: {
fontSize: 12,
color: '#8E8E93',
backgroundColor: 'rgba(142, 142, 147, 0.12)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: 12,
overflow: 'hidden',
},
});
export default {
BUBBLE_RADIUS,
bubbleColors,
getBubbleBorderRadius,
getMessageGroupPosition,
shouldShowSenderInfo,
getBubbleBaseStyle,
getBubbleTextStyle,
bubbleStyles,
};

View File

@@ -20,6 +20,7 @@ export { ChatHeader } from './ChatHeader';
export { MessageBubble } from './MessageBubble';
export { ChatInput } from './ChatInput';
export { GroupInfoPanel } from './GroupInfoPanel';
export { SwipeableMessageBubble } from './SwipeableMessageBubble';
// Segment 渲染组件
export {

View File

@@ -86,6 +86,8 @@ export interface MessageBubbleProps {
shouldShowTime: (index: number) => boolean;
// 图片点击回调
onImagePress?: (images: { id: string; url: string; thumbnail_url?: string; width?: number; height?: number }[], index: number) => void;
// 滑动回复回调
onReply?: (message: GroupMessage) => void;
}
// 输入框 Props
@@ -180,6 +182,14 @@ export interface ReplyPreviewProps {
onCancel: () => void;
}
// 可滑动消息气泡 Props
export interface SwipeableMessageBubbleProps {
children: React.ReactNode;
isMe: boolean;
onReply: () => void;
enabled?: boolean;
}
// ChatScreen 状态接口
export interface ChatScreenState {
// 基础状态

View File

@@ -390,6 +390,16 @@ export const useChatScreen = () => {
return;
}
// 禁用自动滚动到底部,防止加载历史消息后滚动位置跳转
shouldAutoScrollOnEnterRef.current = false;
// 清除所有待执行的自动滚动定时器
autoScrollTimersRef.current.forEach(clearTimeout);
autoScrollTimersRef.current = [];
// 保存加载前的滚动位置和内容高度
const scrollYBefore = scrollPositionRef.current.scrollY;
const contentHeightBefore = scrollPositionRef.current.contentHeight;
setLoadingMore(true);
try {
@@ -400,6 +410,23 @@ export const useChatScreen = () => {
const minSeq = Math.min(...messages.map(m => m.seq));
setFirstSeq(minSeq);
}
// 加载完成后,恢复滚动位置
// 使用 setTimeout 确保 FlashList 已经更新
setTimeout(() => {
if (flatListRef.current) {
// 计算新的滚动位置,保持相对位置不变
const newContentHeight = scrollPositionRef.current.contentHeight;
const heightDiff = newContentHeight - contentHeightBefore;
const newScrollY = scrollYBefore + heightDiff;
// 滚动到计算后的位置
flatListRef.current.scrollToOffset({
offset: newScrollY,
animated: false,
});
}
}, 100);
} catch (error) {
console.error('加载历史消息失败:', error);
} finally {
@@ -409,13 +436,19 @@ export const useChatScreen = () => {
// 列表内容尺寸变化后触发首次自动滚动
const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
// 如果是加载更多历史消息,不要自动滚动
if (loadingMore) {
return;
}
if (!shouldAutoScrollOnEnterRef.current) return;
if (loading || loadingMore) return;
if (loading) return;
if (messages.length === 0) return;
const prevContentHeight = scrollPositionRef.current.contentHeight;
scrollPositionRef.current.contentHeight = contentHeight;
// 如果内容高度增加(加载了更多消息),不要自动滚动到底部
if (prevContentHeight > 0 && contentHeight > prevContentHeight) {
return;
}

View File

@@ -15,6 +15,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import Constants from 'expo-constants';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { useAuthStore } from '../../stores';
import { Text, ResponsiveContainer } from '../../components/common';
@@ -30,6 +31,9 @@ interface SettingsItem {
subtitle?: string;
}
// 获取应用版本号
const APP_VERSION = Constants.expoConfig?.version || '1.0.0';
// 设置分组配置
const SETTINGS_GROUPS = [
{
@@ -53,7 +57,7 @@ const SETTINGS_GROUPS = [
title: '关于与帮助',
icon: 'information-outline',
items: [
{ key: 'about', title: '关于我们', icon: 'information-outline', showArrow: true, subtitle: '版本 1.0.2' },
{ key: 'about', title: '关于我们', icon: 'information-outline', showArrow: true, subtitle: `版本 ${APP_VERSION}` },
{ key: 'help', title: '帮助与反馈', icon: 'help-circle-outline', showArrow: true },
],
},
@@ -183,7 +187,7 @@ export const SettingsScreen: React.FC = () => {
{/* 底部版权信息 */}
<View style={styles.footer}>
<Text variant="caption" color={colors.text.hint}>
v1.0.2
v{APP_VERSION}
</Text>
<Text variant="caption" color={colors.text.hint} style={styles.copyright}>
© 2024 Carrot BBS. All rights reserved.