diff --git a/.gitignore b/.gitignore index 5c813b9..4074a5c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules/ # Expo .expo/ dist/ +dist-web/ web-build/ expo-env.d.ts diff --git a/app.config.js b/app.config.js index 8a3129e..35b69db 100644 --- a/app.config.js +++ b/app.config.js @@ -2,10 +2,8 @@ const appJson = require('./app.json'); const isDevVariant = process.env.APP_VARIANT === 'dev'; const releaseApiBaseUrl = 'https://bbs.littlelan.cn/api/v1'; -const releaseWsUrl = 'wss://bbs.littlelan.cn/ws'; const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn'; const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1'; -const devWsUrl = process.env.EXPO_PUBLIC_WS_URL || 'ws://192.168.31.238:8080/ws'; function toManifestUrl(baseUrl, portOverride) { const parsed = new URL(baseUrl); @@ -46,7 +44,6 @@ module.exports = { ...(expo.extra || {}), appVariant: isDevVariant ? 'dev' : 'release', apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl, - wsUrl: isDevVariant ? devWsUrl : releaseWsUrl, updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl, }, }; diff --git a/package-lock.json b/package-lock.json index daa532d..4756a76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "react-native-reanimated": "^4.2.1", "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.23.0", + "react-native-sse": "^1.2.1", "react-native-web": "^0.21.0", "react-native-worklets": "0.7.2", "zod": "^4.3.6", @@ -9374,6 +9375,12 @@ "react-native": "*" } }, + "node_modules/react-native-sse": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/react-native-sse/-/react-native-sse-1.2.1.tgz", + "integrity": "sha512-zejanlScF+IB9tYnbdry0MT34qjBXbiV/E72qGz33W/tX1bx8MXsbB4lxiuPETc9v/008vYZ60yjIstW22VlVg==", + "license": "MIT" + }, "node_modules/react-native-tab-view": { "version": "4.2.2", "resolved": "https://registry.npmmirror.com/react-native-tab-view/-/react-native-tab-view-4.2.2.tgz", diff --git a/package.json b/package.json index 3f5fee0..49baba2 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "react-native-reanimated": "^4.2.1", "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.23.0", + "react-native-sse": "^1.2.1", "react-native-web": "^0.21.0", "react-native-worklets": "0.7.2", "zod": "^4.3.6", diff --git a/src/components/business/PostCard.tsx b/src/components/business/PostCard.tsx index f9e05c9..5e103f0 100644 --- a/src/components/business/PostCard.tsx +++ b/src/components/business/PostCard.tsx @@ -13,8 +13,6 @@ import { useWindowDimensions, } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { formatDistanceToNow } from 'date-fns'; -import { zhCN } from 'date-fns/locale'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { Post } from '../../types'; import Text from '../common/Text'; @@ -128,16 +126,20 @@ const PostCard: React.FC = ({ return 0; // 移动端无额外内边距 }, [isWideScreen, isDesktop, isTablet]); - const formatTime = (dateString: string | undefined | null): string => { + const formatDateTime = (dateString?: string | null): string => { if (!dateString) return ''; - try { - return formatDistanceToNow(new Date(dateString), { - addSuffix: true, - locale: zhCN, - }); - } catch { - return ''; - } + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) return ''; + const pad = (num: number) => String(num).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + }; + + const isPostEdited = (createdAt?: string, updatedAt?: string): boolean => { + if (!createdAt || !updatedAt) return false; + const created = new Date(createdAt).getTime(); + const updated = new Date(updatedAt).getTime(); + if (Number.isNaN(created) || Number.isNaN(updated)) return false; + return updated - created > 1000; }; const getTruncatedContent = (content: string | undefined | null, maxLength: number = 100): string => { @@ -497,8 +499,13 @@ const PostCard: React.FC = ({ - {formatTime(post.created_at || '')} + 发布 {formatDateTime(post.created_at)} + {isPostEdited(post.created_at, post.updated_at) && ( + + {' · 修改 '}{formatDateTime(post.updated_at)} + + )} {post.is_pinned && ( diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 0a8cc7c..07b3118 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -72,7 +72,12 @@ export type RootStackParamList = { Auth: undefined; PostDetail: { postId: string; scrollToComments?: boolean }; UserProfile: { userId: string }; - CreatePost: undefined; + CreatePost: + | undefined + | { + mode?: 'create' | 'edit'; + postId?: string; + }; Chat: { conversationId: string; userId?: string; diff --git a/src/screens/create/CreatePostScreen.tsx b/src/screens/create/CreatePostScreen.tsx index 61b25fd..7d0d597 100644 --- a/src/screens/create/CreatePostScreen.tsx +++ b/src/screens/create/CreatePostScreen.tsx @@ -21,7 +21,7 @@ import { Image, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; @@ -31,6 +31,7 @@ import { ApiError } from '../../services/api'; import { uploadService } from '../../services/uploadService'; import VoteEditor from '../../components/business/VoteEditor'; import { useResponsive, useResponsiveValue } from '../../hooks'; +import { RootStackParamList } from '../../navigation/types'; const MAX_TITLE_LENGTH = 100; const MAX_CONTENT_LENGTH = 2000; @@ -57,9 +58,9 @@ const EMOJIS = [ '👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💪', '🦾', '🦵', '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', - '🤎', '💔', '❤️\u200d🔥', '❤️\u200d🩹', '💕', '💞', '💓', '💗', - '💖', '💘', '💝', '🎉', '🎊', '🎁', '🎈', '✨', - '🔥', '💯', '💢', '💥', '💫', '💦', '💨', '🕳️', + '🤎', '💔', '🩹', '💕', '💞', '💓', '💗', '💖', + '💘', '💝', '🎉', '🎊', '🎁', '🎈', '✨', '🔥', + '💯', '💢', '💥', '💫', '💦', '💨', '🕳️', ]; // 动画值 @@ -74,6 +75,9 @@ const getPublishErrorMessage = (error: unknown): string => { export const CreatePostScreen: React.FC = () => { const navigation = useNavigation(); + const route = useRoute>(); + const isEditMode = route.params?.mode === 'edit' && !!route.params?.postId; + const editPostID = route.params?.postId || ''; // 响应式布局 const { isWideScreen, width } = useResponsive(); @@ -86,6 +90,7 @@ export const CreatePostScreen: React.FC = () => { const [showTagInput, setShowTagInput] = useState(false); const [showEmojiPanel, setShowEmojiPanel] = useState(false); const [posting, setPosting] = useState(false); + const [loadingPost, setLoadingPost] = useState(false); // 投票相关状态 const [isVotePost, setIsVotePost] = useState(false); @@ -122,6 +127,41 @@ export const CreatePostScreen: React.FC = () => { ]).start(); }, []); + React.useLayoutEffect(() => { + navigation.setOptions({ + title: isEditMode ? '编辑帖子' : '发布帖子', + }); + }, [navigation, isEditMode]); + + React.useEffect(() => { + if (!isEditMode || !editPostID) { + return; + } + + const loadPostForEdit = async () => { + setLoadingPost(true); + try { + const existingPost = await postService.getPost(editPostID); + if (!existingPost) { + Alert.alert('提示', '帖子不存在或已被删除'); + navigation.goBack(); + return; + } + setTitle(existingPost.title || ''); + setContent(existingPost.content || ''); + setImages((existingPost.images || []).map((img) => ({ uri: img.url, uploading: false }))); + } catch (error) { + console.error('加载待编辑帖子失败:', error); + Alert.alert('错误', '加载帖子失败,请稍后重试'); + navigation.goBack(); + } finally { + setLoadingPost(false); + } + }; + + loadPostForEdit(); + }, [isEditMode, editPostID, navigation]); + // 选择图片 const handlePickImage = async () => { const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); @@ -323,19 +363,37 @@ export const CreatePostScreen: React.FC = () => { }); navigation.goBack(); } else { - // 创建普通帖子 - await postService.createPost({ - title: title.trim() || '无标题', - content: content.trim(), - images: imageUrls, - }); - showPrompt({ - type: 'info', - title: '审核中', - message: '帖子已提交,内容审核中,稍后展示', - duration: 2600, - }); - navigation.goBack(); + if (isEditMode && editPostID) { + const updated = await postService.updatePost(editPostID, { + title: title.trim() || '无标题', + content: content.trim(), + images: imageUrls, + }); + if (!updated) { + throw new Error('更新帖子失败'); + } + showPrompt({ + type: 'success', + title: '修改成功', + message: '帖子内容已更新', + duration: 2200, + }); + navigation.goBack(); + } else { + // 创建普通帖子 + await postService.createPost({ + title: title.trim() || '无标题', + content: content.trim(), + images: imageUrls, + }); + showPrompt({ + type: 'info', + title: '审核中', + message: '帖子已提交,内容审核中,稍后展示', + duration: 2600, + }); + navigation.goBack(); + } } } catch (error) { console.error('发布帖子失败:', error); @@ -631,7 +689,7 @@ export const CreatePostScreen: React.FC = () => { ) : ( - 发布 + {isEditMode ? '保存' : '发布'} )} @@ -677,10 +735,24 @@ export const CreatePostScreen: React.FC = () => { > {isWideScreen ? ( - {renderMainContent()} + {loadingPost ? ( + + + 加载帖子中... + + ) : ( + renderMainContent() + )} ) : ( - renderMainContent() + loadingPost ? ( + + + 加载帖子中... + + ) : ( + renderMainContent() + ) )} @@ -934,6 +1006,15 @@ const styles = StyleSheet.create({ emojiText: { fontSize: 24, }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + gap: spacing.md, + }, + loadingText: { + fontSize: fontSizes.md, + }, }); export default CreatePostScreen; diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 3e8f7c2..4ea3d1a 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -15,6 +15,8 @@ import { TouchableOpacity, NativeScrollEvent, NativeSyntheticEvent, + Alert, + Clipboard, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; @@ -357,8 +359,16 @@ export const HomeScreen: React.FC = () => { }; // 分享帖子 - const handleShare = (post: Post) => { - void post; + const handleShare = async (post: Post) => { + if (!post?.id) return; + try { + await postService.sharePost(post.id); + } catch (error) { + console.error('上报分享次数失败:', error); + } + const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`; + Clipboard.setString(postUrl); + Alert.alert('已复制', '帖子链接已复制到剪贴板'); }; // 删除帖子 diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index 07627d3..dea7aea 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -20,14 +20,13 @@ import { Alert, ScrollView, Image, + Clipboard, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; -import { formatDistanceToNow } from 'date-fns'; -import { zhCN } from 'date-fns/locale'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { Post, Comment, VoteResultDTO } from '../../types'; import { useUserStore } from '../../stores'; @@ -35,11 +34,11 @@ import { useCurrentUser } from '../../stores/authStore'; import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services'; import { CommentItem, VoteCard } from '../../components/business'; import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout } from '../../components/common'; -import { HomeStackParamList } from '../../navigation/types'; +import { RootStackParamList } from '../../navigation/types'; import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive'; -type NavigationProp = NativeStackNavigationProp; -type PostDetailRouteProp = RouteProp; +type NavigationProp = NativeStackNavigationProp; +type PostDetailRouteProp = RouteProp; export const PostDetailScreen: React.FC = () => { const navigation = useNavigation(); @@ -181,6 +180,13 @@ export const PostDetailScreen: React.FC = () => { loadPostDetail(true); }, [loadPostDetail]); + useEffect(() => { + const unsubscribe = navigation.addListener('focus', () => { + loadPostDetail(false); + }); + return unsubscribe; + }, [navigation, loadPostDetail]); + // 如果是从评论按钮跳转过来的,加载完成后滚动到评论区 useEffect(() => { if (shouldScrollToComments && !loading && comments.length > 0) { @@ -262,16 +268,44 @@ export const PostDetailScreen: React.FC = () => { setRefreshing(false); }, [loadPostDetail]); - // 格式化时间 - const formatTime = (dateString: string): string => { - try { - return formatDistanceToNow(new Date(dateString), { - addSuffix: true, - locale: zhCN, - }); - } catch { - return ''; + const formatDateTime = (dateString?: string | null): string => { + if (!dateString) return ''; + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) return ''; + const pad = (num: number) => String(num).padStart(2, '0'); + return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; + }; + + const formatRelativeTime = (dateString?: string | null): string => { + if (!dateString) return ''; + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) return ''; + + const now = Date.now(); + const diffMs = now - date.getTime(); + if (diffMs < 0) return formatDateTime(dateString); + + const minuteMs = 60 * 1000; + const hourMs = 60 * minuteMs; + const dayMs = 24 * hourMs; + + if (diffMs < minuteMs) return '刚刚'; + if (diffMs < hourMs) return `${Math.floor(diffMs / minuteMs)}分钟前`; + if (diffMs < dayMs) return `${Math.floor(diffMs / hourMs)}小时前`; + if (diffMs < 2 * dayMs) { + const pad = (num: number) => String(num).padStart(2, '0'); + return `昨天 ${pad(date.getHours())}:${pad(date.getMinutes())}`; } + + return formatDateTime(dateString); + }; + + const isPostEdited = (createdAt?: string, updatedAt?: string): boolean => { + if (!createdAt || !updatedAt) return false; + const created = new Date(createdAt).getTime(); + const updated = new Date(updatedAt).getTime(); + if (Number.isNaN(created) || Number.isNaN(updated)) return false; + return updated-created > 1000; }; // 格式化数字 @@ -350,9 +384,16 @@ export const PostDetailScreen: React.FC = () => { }, [post, favoritePost, unfavoritePost]); // 分享帖子 - const handleShare = useCallback(() => { - // TODO: 实现分享功能 - void post; + const handleShare = useCallback(async () => { + if (!post?.id) return; + try { + await postService.sharePost(post.id); + } catch (error) { + console.error('上报分享次数失败:', error); + } + const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`; + Clipboard.setString(postUrl); + Alert.alert('已复制', '帖子链接已复制到剪贴板'); }, [post?.id]); // 投票处理函数 @@ -484,6 +525,14 @@ export const PostDetailScreen: React.FC = () => { ); }, [post, isDeleting, currentUser?.id, navigation]); + const handleEditPost = useCallback(() => { + if (!post) return; + navigation.navigate('CreatePost', { + mode: 'edit', + postId: post.id, + }); + }, [navigation, post]); + // 点击图片查看大图 const handleImagePress = useCallback((images: ImageGridItem[], index: number) => { setAllImages(images); @@ -1012,33 +1061,59 @@ export const PostDetailScreen: React.FC = () => { {/* 发帖时间和浏览量 - 放在图片下方 */} - - {formatTime(post.created_at)} - - {post.views_count !== undefined && post.views_count > 0 && ( - <> - · - - {formatNumber(post.views_count)} 浏览 - - - )} - {/* 删除按钮 - 只对帖子作者显示 */} + + + 发布 {formatRelativeTime(post.created_at)} + + {isPostEdited(post.created_at, post.updated_at) && ( + <> + · + + 修改 {formatRelativeTime(post.updated_at)} + + + )} + {post.views_count !== undefined && post.views_count > 0 && ( + <> + · + + {formatNumber(post.views_count)} 浏览 + + + )} + + {currentUser?.id === post.author?.id && ( - - - - 删除 - - + + + + + 编辑 + + + {/* 删除按钮 - 只对帖子作者显示 */} + + + + 删除 + + + )} @@ -1107,7 +1182,7 @@ export const PostDetailScreen: React.FC = () => { ); - }, [post, postImages, currentUser?.id, isDeleting, handleLike, handleShare, handleFavorite, handleDeletePost, handleImagePress, voteResult, isVoteLoading, handleVote, handleUnvote, isDesktop, isTablet, isWideScreen, responsivePadding, responsiveGap]); + }, [post, postImages, currentUser?.id, isDeleting, handleLike, handleShare, handleFavorite, handleDeletePost, handleEditPost, handleImagePress, voteResult, isVoteLoading, handleVote, handleUnvote, isDesktop, isTablet, isWideScreen, responsivePadding, responsiveGap]); // 回复评论 const [replyingTo, setReplyingTo] = useState(null); @@ -1548,8 +1623,16 @@ const styles = StyleSheet.create({ postMetaInfo: { flexDirection: 'row', alignItems: 'center', + justifyContent: 'space-between', marginBottom: spacing.sm, }, + metaInfoMain: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + flex: 1, + minWidth: 0, + }, metaInfoText: { fontSize: fontSizes.sm, color: colors.text.hint, @@ -1563,13 +1646,28 @@ const styles = StyleSheet.create({ deleteButtonInline: { flexDirection: 'row', alignItems: 'center', - marginLeft: 'auto', + marginLeft: spacing.sm, padding: spacing.xs, }, deleteButtonText: { marginLeft: 2, fontSize: fontSizes.sm, }, + metaActions: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: spacing.sm, + flexShrink: 0, + }, + editButtonInline: { + flexDirection: 'row', + alignItems: 'center', + padding: spacing.xs, + }, + editButtonText: { + marginLeft: 2, + fontSize: fontSizes.sm, + }, imagesContainer: { flexDirection: 'row', flexWrap: 'wrap', diff --git a/src/screens/message/GroupInfoScreen.tsx b/src/screens/message/GroupInfoScreen.tsx index 95f512d..677c5dd 100644 --- a/src/screens/message/GroupInfoScreen.tsx +++ b/src/screens/message/GroupInfoScreen.tsx @@ -117,8 +117,8 @@ const GroupInfoScreen: React.FC = () => { // 并行加载群组信息和成员列表 const [groupData, membersData] = await Promise.all([ - groupManager.getGroup(groupId), - groupManager.getMembers(groupId, 1, 100), + groupManager.getGroup(groupId, true), + groupManager.getMembers(groupId, 1, 100, true), ]); setGroup(groupData); diff --git a/src/screens/message/GroupMembersScreen.tsx b/src/screens/message/GroupMembersScreen.tsx index 8f14bcf..3612f99 100644 --- a/src/screens/message/GroupMembersScreen.tsx +++ b/src/screens/message/GroupMembersScreen.tsx @@ -92,11 +92,16 @@ const GroupMembersScreen: React.FC = () => { const isAdmin = currentMember?.role === 'admin' || isOwner; // 加载成员列表 - const loadMembers = useCallback(async (pageNum: number = 1, refresh: boolean = false) => { + const loadMembers = useCallback( + async ( + pageNum: number = 1, + refresh: boolean = false, + forceRefresh: boolean = false + ) => { if (!hasMore && !refresh) return; try { - const response = await groupManager.getMembers(groupId, pageNum, 50); + const response = await groupManager.getMembers(groupId, pageNum, 50, forceRefresh); if (refresh) { setMembers(response.list); @@ -121,14 +126,14 @@ const GroupMembersScreen: React.FC = () => { // 初始加载 useEffect(() => { - loadMembers(1, true); + loadMembers(1, true, true); }, [groupId]); // 下拉刷新 const onRefresh = useCallback(() => { setRefreshing(true); setHasMore(true); - loadMembers(1, true); + loadMembers(1, true, true); }, [loadMembers]); // 加载更多 @@ -246,6 +251,8 @@ const GroupMembersScreen: React.FC = () => { } return m; })); + // 强制刷新远端状态,避免命中旧缓存导致解禁后仍显示禁言 + await loadMembers(1, true, true); setActionModalVisible(false); Alert.alert('成功', `已${actionText}`); diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index 1aa88cf..67ee381 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -72,9 +72,10 @@ const truncateDisplayName = (name: string, maxLength: number = MAX_CONVERSATION_ */ const AsyncMessagePreview: React.FC<{ segments?: MessageSegment[]; + status?: string; isGroupChat?: boolean; senderName?: string; -}> = ({ segments, isGroupChat, senderName }) => { +}> = ({ segments, status, isGroupChat, senderName }) => { const [displayText, setDisplayText] = useState(''); const isMountedRef = useRef(true); @@ -82,6 +83,13 @@ const AsyncMessagePreview: React.FC<{ isMountedRef.current = true; const loadPreview = async () => { + if (status === 'recalled') { + if (isMountedRef.current) { + setDisplayText('消息已撤回'); + } + return; + } + const initialText = extractTextFromSegments(segments); if (isMountedRef.current) { setDisplayText(initialText); @@ -112,7 +120,7 @@ const AsyncMessagePreview: React.FC<{ return () => { isMountedRef.current = false; }; - }, [segments]); + }, [segments, status]); if (!displayText) return null; @@ -556,6 +564,7 @@ export const MessageListScreen: React.FC = () => { ) : ( diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 3df6018..2200f19 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -27,9 +27,6 @@ import { uploadService } from '../../../../services/uploadService'; import { ApiError } from '../../../../services/api'; // 【新架构】使用 MessageManager import { useChat, useGroupTyping, useGroupMuted, messageManager } from '../../../../stores'; -import { - websocketService, -} from '../../../../services/websocketService'; import { groupService } from '../../../../services/groupService'; import { userManager } from '../../../../stores/userManager'; import { groupManager } from '../../../../stores/groupManager'; @@ -728,14 +725,7 @@ export const useChatScreen = () => { const segments = buildTextSegments(trimmedText, replyingTo); if (isGroupChat && routeGroupId) { - // 群聊消息发送 - websocketService.sendGroupChatMessage({ - conversationId: conversationId, - groupId: routeGroupId, - segments: segments, - mentionUsers: selectedMentions.length > 0 ? selectedMentions : undefined, - mentionAll: mentionAll || undefined, - }); + await messageService.sendMessageByAction('group', conversationId, segments); setInputText(''); setSelectedMentions([]); @@ -790,12 +780,7 @@ export const useChatScreen = () => { const segments = buildImageSegments(uploadResult.url, uploadResult.url, undefined, undefined, replyingTo); if (isGroupChat && routeGroupId) { - websocketService.sendGroupChatMessage({ - conversationId: conversationId, - groupId: routeGroupId, - mediaUrl: uploadResult.url, - segments: segments, - }); + await messageService.sendMessageByAction('group', conversationId, segments); setReplyingTo(null); } else { // 【新架构】私聊图片通过 MessageManager 发送 @@ -922,12 +907,7 @@ export const useChatScreen = () => { const segments = buildImageSegments(stickerUrl, stickerUrl, undefined, undefined, replyingTo); if (isGroupChat && routeGroupId) { - websocketService.sendGroupChatMessage({ - conversationId: conversationId, - groupId: routeGroupId, - mediaUrl: stickerUrl, - segments: segments, - }); + await messageService.sendMessageByAction('group', conversationId, segments); setReplyingTo(null); } else { // 【新架构】私聊表情通过 MessageManager 发送 @@ -983,7 +963,7 @@ export const useChatScreen = () => { const handleRecall = useCallback(async (messageId: string) => { try { if (isGroupChat && routeGroupId) { - websocketService.sendGroupRecall(routeGroupId, conversationId!, messageId); + await messageService.recallMessage(messageId); } else { await messageService.recallMessage(messageId); } diff --git a/src/screens/message/components/EmbeddedChat.tsx b/src/screens/message/components/EmbeddedChat.tsx index e2ad1a4..9e610b4 100644 --- a/src/screens/message/components/EmbeddedChat.tsx +++ b/src/screens/message/components/EmbeddedChat.tsx @@ -145,7 +145,7 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack {item.sender?.nickname || item.sender?.username} )} - {extractTextFromSegments(item.segments)} + {item.status === 'recalled' ? '消息已撤回' : extractTextFromSegments(item.segments)} {isMe && ( diff --git a/src/services/api.ts b/src/services/api.ts index b397646..96f28e1 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -8,7 +8,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { CommonActions } from '@react-navigation/native'; import Constants from 'expo-constants'; - +// 生产地址 https://bbs.littlelan.cn const getBaseUrl = () => { const configuredBaseUrl = Constants.expoConfig?.extra?.apiBaseUrl; if (typeof configuredBaseUrl === 'string' && configuredBaseUrl.trim().length > 0) { @@ -17,16 +17,8 @@ const getBaseUrl = () => { return 'https://bbs.littlelan.cn/api/v1'; }; -const getWsUrl = () => { - const configuredWsUrl = Constants.expoConfig?.extra?.wsUrl; - if (typeof configuredWsUrl === 'string' && configuredWsUrl.trim().length > 0) { - return configuredWsUrl; - } - return 'wss://bbs.littlelan.cn/ws'; -}; - const BASE_URL = getBaseUrl(); -const WS_URL = getWsUrl(); +const SSE_URL = `${BASE_URL.replace(/\/+$/, '')}/realtime/sse`; // Token 存储键 const TOKEN_KEY = 'auth_token'; @@ -187,15 +179,45 @@ class ApiClient { return this.request(method, path, params, body); } - // 解析响应 - const data: ApiResponse = await response.json(); + // 解析响应(兼容非 JSON 返回,避免 SyntaxError 被误判为网络错误) + const contentType = response.headers.get('content-type') || ''; + let parsedBody: any = null; + let rawText = ''; - // 处理业务错误 - if (data.code !== 0) { - throw new ApiError(data.code, data.message); + if (contentType.includes('application/json')) { + parsedBody = await response.json(); + } else { + rawText = await response.text(); + if (rawText) { + try { + parsedBody = JSON.parse(rawText); + } catch { + parsedBody = null; + } + } } - return data; + // 优先处理标准 API 结构 + if (parsedBody && typeof parsedBody === 'object' && 'code' in parsedBody) { + const data = parsedBody as ApiResponse; + if (data.code !== 0) { + throw new ApiError(data.code, data.message || '请求失败'); + } + return data; + } + + // 非标准结构:先按 HTTP 状态处理失败 + if (!response.ok) { + const fallbackMessage = rawText || response.statusText || `请求失败(${response.status})`; + throw new ApiError(response.status, fallbackMessage); + } + + // 非标准结构但 HTTP 成功:兜底为成功响应(兼容部分纯文本成功接口) + return { + code: 0, + message: 'success', + data: (parsedBody as T) ?? (undefined as T), + }; } catch (error) { // 如果是 ApiError,直接抛出 if (error instanceof ApiError) { @@ -312,5 +334,4 @@ class ApiClient { // 导出 API 客户端实例 export const api = new ApiClient(BASE_URL); -// 导出 WebSocket URL -export { WS_URL, TOKEN_KEY, REFRESH_TOKEN_KEY }; +export { SSE_URL, TOKEN_KEY, REFRESH_TOKEN_KEY }; diff --git a/src/services/backgroundService.ts b/src/services/backgroundService.ts index 9e8770c..3db82c5 100644 --- a/src/services/backgroundService.ts +++ b/src/services/backgroundService.ts @@ -12,11 +12,11 @@ import { AppState, AppStateStatus, Platform } from 'react-native'; import * as BackgroundFetch from 'expo-background-fetch'; import * as TaskManager from 'expo-task-manager'; import * as Haptics from 'expo-haptics'; -import { websocketService } from './websocketService'; +import { sseService } from './sseService'; // 后台任务名称 const BACKGROUND_FETCH_TASK = 'background-fetch-keepalive'; -const WEBSOCKET_KEEPALIVE_TASK = 'websocket-keepalive'; +const REALTIME_KEEPALIVE_TASK = 'realtime-keepalive'; // 后台任务间隔(Android 最小 15 分钟,iOS 最小 15 分钟) const BACKGROUND_INTERVAL = 15; // 15 分钟 @@ -48,8 +48,8 @@ let appStateSubscription: any = null; TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { try { // 检查 WebSocket 连接状态 - if (!websocketService.isConnected()) { - await websocketService.connect(); + if (!sseService.isConnected()) { + await sseService.connect(); } // 返回收到新数据 @@ -61,14 +61,14 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { }); // WebSocket 保活任务 -TaskManager.defineTask(WEBSOCKET_KEEPALIVE_TASK, async () => { +TaskManager.defineTask(REALTIME_KEEPALIVE_TASK, async () => { try { - if (!websocketService.isConnected()) { - await websocketService.connect(); + if (!sseService.isConnected()) { + await sseService.connect(); } return BackgroundFetch.BackgroundFetchResult.NewData; } catch (error) { - console.error('[BackgroundService] WebSocket 保活失败:', error); + console.error('[BackgroundService] SSE 保活失败:', error); return BackgroundFetch.BackgroundFetchResult.Failed; } }); @@ -175,9 +175,9 @@ async function registerBackgroundTasks(): Promise { } // 注册 WebSocket 保活任务 - const isWsKeepaliveRegistered = await TaskManager.isTaskRegisteredAsync(WEBSOCKET_KEEPALIVE_TASK); + const isWsKeepaliveRegistered = await TaskManager.isTaskRegisteredAsync(REALTIME_KEEPALIVE_TASK); if (!isWsKeepaliveRegistered) { - await BackgroundFetch.registerTaskAsync(WEBSOCKET_KEEPALIVE_TASK, { + await BackgroundFetch.registerTaskAsync(REALTIME_KEEPALIVE_TASK, { minimumInterval: 60, // 1 分钟检查一次 stopOnTerminate: false, startOnBoot: true, @@ -194,7 +194,7 @@ async function registerBackgroundTasks(): Promise { async function unregisterBackgroundTasks(): Promise { try { await BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK); - await BackgroundFetch.unregisterTaskAsync(WEBSOCKET_KEEPALIVE_TASK); + await BackgroundFetch.unregisterTaskAsync(REALTIME_KEEPALIVE_TASK); } catch (error) { console.error('[BackgroundService] 取消后台任务失败:', error); } @@ -212,8 +212,8 @@ function setupAppStateListener(): void { void nextAppState; if (nextAppState === 'active') { // App 回到前台,确保连接 - if (!websocketService.isConnected()) { - websocketService.connect(); + if (!sseService.isConnected()) { + sseService.connect(); } } }); diff --git a/src/services/database.ts b/src/services/database.ts index c4f8202..60dbd4e 100644 --- a/src/services/database.ts +++ b/src/services/database.ts @@ -459,9 +459,21 @@ export const deleteMessage = async (messageId: string): Promise => { }; // 更新消息状态(如撤回) -export const updateMessageStatus = async (messageId: string, status: string): Promise => { +// clearContent=true 时,会同时清空本地存储的消息内容与 segments,仅保留状态占位 +export const updateMessageStatus = async ( + messageId: string, + status: string, + clearContent: boolean = false +): Promise => { await enqueueWrite(async () => { const database = await getDb(); + if (clearContent) { + await database.runAsync( + `UPDATE messages SET status = ?, content = '', segments = '[]' WHERE id = ?`, + [status, messageId] + ); + return; + } await database.runAsync( `UPDATE messages SET status = ? WHERE id = ?`, [status, messageId] diff --git a/src/services/index.ts b/src/services/index.ts index 11f56f9..27ea301 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -4,7 +4,7 @@ */ // API 客户端 -export { api, WS_URL, TOKEN_KEY, REFRESH_TOKEN_KEY } from './api'; +export { api, SSE_URL, TOKEN_KEY, REFRESH_TOKEN_KEY } from './api'; export type { ApiResponse, PaginatedData, ApiError } from './api'; // 认证服务 @@ -45,8 +45,8 @@ export { pushService, registerDevice, getDevices, unregisterDevice, updateDevice // 投票服务 export { voteService } from './voteService'; -// WebSocket 服务 -export { websocketService } from './websocketService'; +// SSE 实时服务 +export { sseService } from './sseService'; export type { WSMessage, WSMessageType, @@ -63,7 +63,7 @@ export type { WSGroupMentionMessage, WSGroupReadMessage, WSGroupRecallMessage -} from './websocketService'; +} from './sseService'; // 系统通知服务 export { systemNotificationService, getNotificationTitle } from './systemNotificationService'; diff --git a/src/services/messageService.ts b/src/services/messageService.ts index 05e1cf6..ac194a7 100644 --- a/src/services/messageService.ts +++ b/src/services/messageService.ts @@ -441,6 +441,16 @@ class MessageService { }); } + /** + * 上报输入状态 + * POST /api/v1/conversations/typing + */ + async sendTyping(conversationId: string): Promise { + await api.post('/conversations/typing', { + conversation_id: conversationId, + }); + } + /** * 获取未读总数 * GET /api/v1/conversations/unread/count diff --git a/src/services/sseService.ts b/src/services/sseService.ts new file mode 100644 index 0000000..45b4fe0 --- /dev/null +++ b/src/services/sseService.ts @@ -0,0 +1,463 @@ +import { AppState, AppStateStatus } from 'react-native'; +import EventSource from 'react-native-sse'; + +import { api, SSE_URL } from './api'; +import { MessageCategory, SystemMessageType, SystemMessageExtraData, MessageSegment } from '../types/dto'; +import { systemNotificationService } from './systemNotificationService'; +import { vibrateOnMessage } from './backgroundService'; + +export type WSMessageType = + | 'chat' + | 'message' + | 'read' + | 'typing' + | 'recall' + | 'notification' + | 'announcement' + | 'group_message' + | 'group_typing' + | 'group_notice' + | 'group_mention' + | 'group_read' + | 'group_recall' + | 'notice' + | 'request' + | 'meta' + | 'private' + | 'group' + | 'follow' + | 'like' + | 'comment' + | 'heartbeat'; + +export interface WSChatMessage { + type: 'chat'; + conversation_id: string; + id: string; + sender_id: string; + seq: number; + segments?: MessageSegment[]; + created_at: string; +} + +export interface WSReadMessage { + type: 'read'; + conversation_id: string; + user_id: string; + seq: number; +} + +export interface WSTypingMessage { + type: 'typing'; + conversation_id: string; + user_id: string; + is_typing: boolean; +} + +export interface WSRecallMessage { + type: 'recall'; + conversation_id: string; + message_id: string; +} + +export interface WSNotificationMessage { + type: 'notification'; + id: number | string; + sender_id?: string; + receiver_id?: string; + content: string; + category?: MessageCategory; + system_type?: SystemMessageType; + extra_data?: SystemMessageExtraData; + created_at: string; +} + +export interface WSAnnouncementMessage { + type: 'announcement'; + id: number | string; + sender_id?: string; + receiver_id?: string; + content: string; + category?: MessageCategory; + system_type?: SystemMessageType; + extra_data?: SystemMessageExtraData; + created_at: string; +} + +export interface WSGroupMentionMessage { + type: 'group_mention'; + group_id: number | string; + conversation_id: string; + message_id: string; + from_user_id: string; + content: string; + mention_all: boolean; + created_at: string; +} + +export interface WSGroupChatMessage { + type: 'group_message'; + conversation_id: string; + group_id: number | string; + id: string; + sender_id: string; + seq: number; + segments?: MessageSegment[]; + created_at: string; +} + +export interface WSGroupTypingMessage { + type: 'group_typing'; + group_id: number | string; + user_id: string; + is_typing: boolean; +} + +export type GroupNoticeType = 'member_join' | 'member_leave' | 'member_removed' | 'role_changed' | 'muted' | 'unmuted'; + +export interface WSGroupNoticeMessage { + type: 'group_notice'; + notice_type: GroupNoticeType; + group_id: number | string; + data: { + user_id?: string; + operator_id?: string; + role?: string; + [key: string]: any; + }; + timestamp: number; + message_id?: string; + seq?: number; +} + +export interface WSGroupReadMessage { + type: 'group_read'; + group_id: number | string; + conversation_id: string; + user_id: string; + seq: number; +} + +export interface WSGroupRecallMessage { + type: 'group_recall'; + group_id: number | string; + conversation_id: string; + message_id: string; +} + +export type WSMessage = + | WSChatMessage + | WSReadMessage + | WSTypingMessage + | WSRecallMessage + | WSNotificationMessage + | WSAnnouncementMessage + | WSGroupChatMessage + | WSGroupTypingMessage + | WSGroupNoticeMessage + | WSGroupMentionMessage + | WSGroupReadMessage + | WSGroupRecallMessage; + +type MessageHandler = (message: T) => void; +type ConnectionHandler = () => void; + +interface SSEEnvelope { + event_id?: number; + event?: string; + ts?: number; + payload?: any; +} + +class SSEService { + private source: EventSource | null = null; + private isConnecting = false; + private reconnectAttempts = 0; + private maxReconnectAttempts = 20; + private reconnectDelay = 3000; + private reconnectTimer: NodeJS.Timeout | null = null; + private messageHandlers: Map = new Map(); + private connectionHandlers: ConnectionHandler[] = []; + private disconnectionHandlers: ConnectionHandler[] = []; + private appStateSubscription: any = null; + private lastAppState: AppStateStatus = 'active'; + private lastEventId = ''; + + private toSSEUrl(): string { + return `${SSE_URL}?last_event_id=${encodeURIComponent(this.lastEventId)}`; + } + + async connect(): Promise { + if (this.isConnecting || this.isConnected()) return true; + this.isConnecting = true; + try { + const token = await api.getToken(); + if (!token) { + this.isConnecting = false; + return false; + } + const url = this.toSSEUrl(); + this.source = new EventSource(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + this.source.addEventListener('open', () => { + this.isConnecting = false; + this.reconnectAttempts = 0; + this.connectionHandlers.forEach(h => h()); + }); + + this.source.addEventListener('error', () => { + this.isConnecting = false; + this.disconnectionHandlers.forEach(h => h()); + this.scheduleReconnect(); + }); + + const events = ['chat_message', 'message_read', 'typing', 'system_notification', 'group_notice', 'message_recall', 'heartbeat']; + events.forEach(eventName => { + this.source?.addEventListener(eventName as any, (evt: any) => this.handleIncoming(eventName, evt)); + }); + + return true; + } catch { + this.isConnecting = false; + this.scheduleReconnect(); + return false; + } + } + + private handleIncoming(eventName: string, evt: any): void { + const rawData = typeof evt?.data === 'string' ? evt.data : '{}'; + const lastEventId = evt?.lastEventId; + if (lastEventId) { + this.lastEventId = String(lastEventId); + } + let payload: any = {}; + try { + payload = JSON.parse(rawData); + } catch { + payload = {}; + } + console.log('[SSE] 收到消息:', { + event: eventName, + lastEventId: this.lastEventId, + payload, + }); + this.dispatchEvent(eventName, payload); + } + + private dispatchEvent(eventName: string, payload: any): void { + if (eventName === 'chat_message') { + const detailType = payload?.detail_type || 'private'; + const m = payload?.message || payload; + if (detailType === 'group') { + const gm: WSGroupChatMessage = { + type: 'group_message', + conversation_id: m.conversation_id, + group_id: m.group_id || '', + id: m.id, + sender_id: m.sender_id, + seq: Number(m.seq || 0), + segments: m.segments || [], + created_at: m.created_at || new Date().toISOString(), + }; + this.emit('group_message', gm); + vibrateOnMessage('group_message').catch(() => {}); + } else { + const cm: WSChatMessage = { + type: 'chat', + conversation_id: m.conversation_id, + id: m.id, + sender_id: m.sender_id, + seq: Number(m.seq || 0), + segments: m.segments || [], + created_at: m.created_at || new Date().toISOString(), + }; + this.emit('chat', cm); + vibrateOnMessage('chat').catch(() => {}); + } + return; + } + + if (eventName === 'message_read') { + const detailType = payload?.detail_type || 'private'; + if (detailType === 'group') { + const m: WSGroupReadMessage = { + type: 'group_read', + group_id: payload.group_id || '', + conversation_id: payload.conversation_id, + user_id: payload.user_id, + seq: Number(payload.seq || 0), + }; + this.emit('group_read', m); + } else { + const m: WSReadMessage = { + type: 'read', + conversation_id: payload.conversation_id, + user_id: payload.user_id, + seq: Number(payload.seq || 0), + }; + this.emit('read', m); + } + return; + } + + if (eventName === 'typing') { + const detailType = payload?.detail_type || 'private'; + if (detailType === 'group') { + const m: WSGroupTypingMessage = { + type: 'group_typing', + group_id: payload.group_id || '', + user_id: payload.user_id, + is_typing: payload.is_typing !== false, + }; + this.emit('group_typing', m); + } else { + const m: WSTypingMessage = { + type: 'typing', + conversation_id: payload.conversation_id, + user_id: payload.user_id, + is_typing: payload.is_typing !== false, + }; + this.emit('typing', m); + } + return; + } + + if (eventName === 'message_recall') { + const detailType = payload?.detail_type || 'private'; + if (detailType === 'group') { + const m: WSGroupRecallMessage = { + type: 'group_recall', + group_id: payload.group_id || '', + conversation_id: payload.conversation_id, + message_id: payload.message_id, + }; + this.emit('group_recall', m); + } else { + const m: WSRecallMessage = { + type: 'recall', + conversation_id: payload.conversation_id, + message_id: payload.message_id, + }; + this.emit('recall', m); + } + return; + } + + if (eventName === 'group_notice') { + const m: WSGroupNoticeMessage = { + type: 'group_notice', + notice_type: payload.notice_type, + group_id: payload.group_id, + data: payload.data || {}, + timestamp: payload.timestamp || Date.now(), + message_id: payload.message_id, + seq: payload.seq, + }; + this.emit('group_notice', m); + return; + } + + if (eventName === 'system_notification') { + const m: WSNotificationMessage = { + type: 'notification', + id: payload.id || '', + content: payload.content || '', + created_at: payload.created_at || new Date().toISOString(), + }; + this.emit('notification', m); + vibrateOnMessage('notification').catch(() => {}); + systemNotificationService.handleWSMessage(m as any).catch(() => {}); + } + } + + private emit(type: T, message: Extract) { + const handlers = this.messageHandlers.get(type) || []; + handlers.forEach(h => h(message as WSMessage)); + } + + disconnect(): void { + if (this.source) { + this.source.close(); + this.source = null; + } + this.stopReconnect(); + } + + private scheduleReconnect(): void { + if (this.reconnectAttempts >= this.maxReconnectAttempts) return; + this.stopReconnect(); + this.reconnectTimer = setTimeout(() => { + this.reconnectAttempts += 1; + this.connect(); + }, this.reconnectDelay); + } + + private stopReconnect() { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + isConnected(): boolean { + return this.source != null; + } + + on(type: T, handler: MessageHandler>): () => void { + const list = this.messageHandlers.get(type) || []; + list.push(handler as MessageHandler); + this.messageHandlers.set(type, list); + return () => { + const current = this.messageHandlers.get(type) || []; + const idx = current.indexOf(handler as MessageHandler); + if (idx >= 0) current.splice(idx, 1); + }; + } + + onConnect(handler: ConnectionHandler): () => void { + this.connectionHandlers.push(handler); + return () => { + const i = this.connectionHandlers.indexOf(handler); + if (i >= 0) this.connectionHandlers.splice(i, 1); + }; + } + + onDisconnect(handler: ConnectionHandler): () => void { + this.disconnectionHandlers.push(handler); + return () => { + const i = this.disconnectionHandlers.indexOf(handler); + if (i >= 0) this.disconnectionHandlers.splice(i, 1); + }; + } + + private setupAppStateListener(): void { + if (this.appStateSubscription) return; + this.lastAppState = AppState.currentState; + this.appStateSubscription = AppState.addEventListener('change', (nextState: AppStateStatus) => { + if (this.lastAppState.match(/inactive|background/) && nextState === 'active' && !this.isConnected()) { + this.reconnectAttempts = 0; + this.connect(); + } + this.lastAppState = nextState; + }); + } + + async start(): Promise { + this.setupAppStateListener(); + return this.connect(); + } + + stop(): void { + if (this.appStateSubscription) { + this.appStateSubscription.remove(); + this.appStateSubscription = null; + } + this.disconnect(); + } +} + +export const sseService = new SSEService(); diff --git a/src/services/systemNotificationService.ts b/src/services/systemNotificationService.ts index f98169d..9555219 100644 --- a/src/services/systemNotificationService.ts +++ b/src/services/systemNotificationService.ts @@ -6,7 +6,7 @@ import * as Notifications from 'expo-notifications'; import { Platform, AppState, AppStateStatus } from 'react-native'; -import type { WSChatMessage, WSNotificationMessage, WSAnnouncementMessage } from './websocketService'; +import type { WSChatMessage, WSNotificationMessage, WSAnnouncementMessage } from './sseService'; import { extractTextFromSegments } from '../types/dto'; // 通知渠道配置 @@ -169,9 +169,9 @@ class SystemNotificationService { data: { type: message.type, id: String(message.id), - senderId: message.sender_id, - receiverId: message.receiver_id, - systemType: message.system_type, + senderId: message.sender_id || '', + receiverId: message.receiver_id || '', + systemType: message.system_type || '', extraData: JSON.stringify(message.extra_data || {}), }, type, diff --git a/src/services/websocketService.ts b/src/services/websocketService.ts deleted file mode 100644 index 4fd8c8d..0000000 --- a/src/services/websocketService.ts +++ /dev/null @@ -1,1090 +0,0 @@ -/** - * WebSocket 服务 - * 处理实时消息推送、聊天等功能 - * 支持心跳检测和自动重连 - * - * 针对移动端优化: - * - 增加心跳间隔到 55 秒,配合后端 60 秒超时 - * - 静默处理错误事件,避免控制台大量输出 - * - 监听 App 状态变化,从后台恢复时自动重连 - * - 增加重连次数上限 - * - 保活机制:心跳响应检测,超时自动重连 - * - 消息震动提示 - */ - -import { AppState, AppStateStatus } from 'react-native'; -import * as Haptics from 'expo-haptics'; -import { api, WS_URL } from './api'; -import { MessageCategory, SystemMessageType, SystemMessageExtraData, MessageSegment, WSEvent, WSEventType, WSDetailType } from '../types/dto'; -import { systemNotificationService } from './systemNotificationService'; -import { vibrateOnMessage } from './backgroundService'; - -// WebSocket 消息类型 - 与后端保持一致 -export type WSMessageType = 'chat' | 'message' | 'read' | 'typing' | 'recall' | 'notification' | 'announcement' | 'group_message' | 'group_typing' | 'group_notice' | 'group_mention' | 'group_read' | 'group_recall' | 'notice' | 'request' | 'meta' | 'private' | 'group' | 'follow' | 'like' | 'comment' | 'heartbeat'; - -// ==================== 私聊消息类型 ==================== - -// 聊天消息 - 与后端 ChatMessage 结构一致 -export interface WSChatMessage { - type: 'chat'; - conversation_id: string; - id: string; // 雪花算法ID (string类型) - sender_id: string; // UUID字符串 - seq: number; // 消息序号 - segments?: MessageSegment[]; // 消息链 - created_at: string; -} - -// 后端发送的消息结构(包装在 data 字段中) -export interface WSMessageData { - id: string; - conversationId: string; - senderId: string; - seq: number; - contentType: string; - content: string; - mediaUrl?: string; - replyToId?: string; - type: string; // 消息子类型 (text, image 等) - createdAt: number; -} - -// 已读回执消息 -export interface WSReadMessage { - type: 'read'; - conversation_id: string; - user_id: string; // UUID字符串 - seq: number; // 已读到的seq位置 -} - -// 正在输入消息 -export interface WSTypingMessage { - type: 'typing'; - conversation_id: string; - user_id: string; // UUID字符串 - is_typing: boolean; -} - -// 消息撤回 -export interface WSRecallMessage { - type: 'recall'; - conversation_id: string; - message_id: string; -} - -// 系统通知消息(如点赞、评论、关注等) -export interface WSNotificationMessage { - type: 'notification'; - id: number; - sender_id: string; // UUID字符串 - receiver_id: string; // UUID字符串 - content: string; - category: MessageCategory; - system_type: SystemMessageType; - extra_data?: SystemMessageExtraData; - created_at: string; -} - -// 系统公告消息 -export interface WSAnnouncementMessage { - type: 'announcement'; - id: number; - sender_id: string; // UUID字符串 - receiver_id: string; // UUID字符串 - content: string; - category: MessageCategory; - system_type: SystemMessageType; - extra_data?: SystemMessageExtraData; - created_at: string; -} - -// ==================== 群聊消息类型 ==================== - -// 群聊消息 -export interface WSGroupChatMessage { - type: 'group_message'; - conversation_id: string; - group_id: number | string; // 支持number和string类型 - id: string; // 雪花算法ID (string类型) - sender_id: string; // UUID字符串 - seq: number; // 消息序号 - segments?: MessageSegment[]; // 消息链 - created_at: string; -} - -// 群聊正在输入 -export interface WSGroupTypingMessage { - type: 'group_typing'; - group_id: number | string; // 支持number和string类型 - user_id: string; // UUID字符串 - is_typing: boolean; -} - -// 群通知类型 -export type GroupNoticeType = 'member_join' | 'member_leave' | 'member_removed' | 'role_changed' | 'muted' | 'unmuted'; - -// 群通知消息 -export interface WSGroupNoticeMessage { - type: 'group_notice'; - notice_type: GroupNoticeType; - group_id: number | string; // 支持number和string类型 - data: { - user_id?: string; - operator_id?: string; - role?: string; - [key: string]: any; - }; - timestamp: number; - message_id?: string; // 消息ID(如果通知保存为消息) - seq?: number; // 消息序号(如果通知保存为消息) -} - -// 群@消息 -export interface WSGroupMentionMessage { - type: 'group_mention'; - group_id: number | string; // 支持number和string类型 - conversation_id: string; - message_id: string; - from_user_id: string; - content: string; - mention_all: boolean; - created_at: string; -} - -// 群已读回执 -export interface WSGroupReadMessage { - type: 'group_read'; - group_id: number | string; // 支持number和string类型 - conversation_id: string; - user_id: string; // UUID字符串 - seq: number; // 已读到的seq位置 -} - -// 群消息撤回 -export interface WSGroupRecallMessage { - type: 'group_recall'; - group_id: number | string; // 支持number和string类型 - conversation_id: string; - message_id: string; -} - -// Ack 消息确认(消息发送成功后返回给发送者) -export interface WSAckMessage { - type: 'meta'; - detail_type: 'ack'; - conversation_id: string; - group_id?: number | string; // 群聊时有此字段 - id: string; // 消息ID - sender_id: string; // 发送者ID - seq: number; // 消息序号 - segments?: MessageSegment[]; // 消息链 - created_at: number; // 创建时间戳 -} - -// ==================== 新事件格式接口 ==================== - -// 新事件格式 - 后端推送的通用事件 -export interface WSEventMessage { - type: WSEventType; // 事件类型: message, notice, request, meta - detail_type: WSDetailType; // 详细类型: private, group, follow, like 等 - id: string; // 事件唯一ID - time: number; // 事件时间戳(毫秒) - seq: string; // 消息序号 - message?: MessageSegment[]; // 消息内容数组 - conversation_id: string; // 会话ID - user_id?: string; // 用户ID(部分事件有) -} - -// meta 元事件 - 心跳、输入、已读等 -export interface WSMetaEvent { - type: 'meta'; - detail_type: 'heartbeat' | 'typing' | 'read'; - id: string; - time: number; - conversation_id?: string; - user_id?: string; - seq?: string; -} - -// notice 通知事件 - 关注、点赞、评论等 -export interface WSNoticeEvent { - type: 'notice'; - detail_type: 'follow' | 'like' | 'comment'; - id: string; - time: number; - user_id: string; - message?: MessageSegment[]; - seq?: string; -} - -// request 请求事件 - 加群请求等 -export interface WSRequestEvent { - type: 'request'; - detail_type: 'group_join' | 'friend'; - id: string; - time: number; - user_id: string; - message?: MessageSegment[]; - seq?: string; -} - -// 联合类型 -export type WSMessage = - | WSChatMessage - | WSReadMessage - | WSTypingMessage - | WSRecallMessage - | WSNotificationMessage - | WSAnnouncementMessage - | WSGroupChatMessage - | WSGroupTypingMessage - | WSGroupNoticeMessage - | WSGroupMentionMessage - | WSGroupReadMessage - | WSGroupRecallMessage - | WSEventMessage - | WSMetaEvent - | WSNoticeEvent - | WSRequestEvent; - -// WebSocket 事件监听器 -type MessageHandler = (message: T) => void; -type ConnectionHandler = () => void; - -// 震动配置 -interface VibrationConfig { - enabled: boolean; // 是否启用震动 - onChatMessage: boolean; // 私聊消息震动 - onGroupMessage: boolean; // 群聊消息震动 - onNotification: boolean; // 系统通知震动 - style: Haptics.ImpactFeedbackStyle; // 震动样式 -} - -// WebSocket 服务类 -class WebSocketService { - private socket: WebSocket | null = null; - private url: string = WS_URL; - private reconnectAttempts: number = 0; - private maxReconnectAttempts: number = 20; // 增加重连次数 - private reconnectDelay: number = 3000; - private heartbeatInterval: number = 55000; // 增加到 55 秒,配合后端 60 秒超时 - private heartbeatTimeout: number = 10000; // 心跳响应超时时间 10 秒 - private heartbeatTimer: NodeJS.Timeout | null = null; - private heartbeatResponseTimer: NodeJS.Timeout | null = null; - private reconnectTimer: NodeJS.Timeout | null = null; - private messageHandlers: Map = new Map(); - private connectionHandlers: ConnectionHandler[] = []; - private disconnectionHandlers: ConnectionHandler[] = []; - private isConnecting: boolean = false; - private isManualClose: boolean = false; - private appStateSubscription: any = null; - private lastAppState: string = 'active'; - private lastHeartbeatSent: number = 0; // 上次发送心跳的时间戳 - private lastHeartbeatReceived: number = 0; // 上次收到心跳响应的时间戳 - private pendingHeartbeat: boolean = false; // 是否有待响应的心跳 - private vibrationConfig: VibrationConfig = { - enabled: true, - onChatMessage: true, - onGroupMessage: true, - onNotification: true, - style: Haptics.ImpactFeedbackStyle.Light, - }; - - // 连接 WebSocket - async connect(): Promise { - if (this.socket?.readyState === WebSocket.OPEN || this.isConnecting) { - return true; - } - - this.isConnecting = true; - this.isManualClose = false; - - try { - // 获取认证 token - const token = await api.getToken(); - if (!token) { - console.error('WebSocket 连接失败: 未登录'); - this.isConnecting = false; - return false; - } - - // 构建 WebSocket URL - const wsUrl = `${this.url}?token=${encodeURIComponent(token)}`; - - // 创建 WebSocket 连接 - this.socket = new WebSocket(wsUrl); - - // 设置事件处理器 - this.socket.onopen = () => { - this.isConnecting = false; - this.reconnectAttempts = 0; - this.startHeartbeat(); - this.connectionHandlers.forEach(handler => handler()); - }; - - this.socket.onmessage = (event) => { - try { - const message: WSMessage = JSON.parse(event.data); - this.handleMessage(message); - } catch (error) { - console.error('解析 WebSocket 消息失败:', error); - } - }; - - this.socket.onerror = () => { - // 静默处理错误,不打印完整错误对象 - // WebSocket 错误通常会在 onclose 中处理,这里只标记状态 - this.isConnecting = false; - }; - - this.socket.onclose = (event) => { - this.isConnecting = false; - this.stopHeartbeat(); - - if (!this.isManualClose) { - this.handleReconnect(); - } - - this.disconnectionHandlers.forEach(handler => handler()); - }; - - return true; - } catch (error) { - console.error('WebSocket 连接失败:', error); - this.isConnecting = false; - return false; - } - } - - // 断开 WebSocket 连接 - disconnect(): void { - this.isManualClose = true; - this.stopHeartbeat(); - this.stopReconnect(); - - if (this.socket) { - this.socket.close(); - this.socket = null; - } - } - - // 发送消息(内部方法) - private sendRaw(data: { type: string; [key: string]: any }): boolean { - if (this.socket?.readyState !== WebSocket.OPEN) { - console.error('WebSocket 未连接,无法发送消息'); - return false; - } - - try { - // 提取 type,将其余字段作为 data - const { type, ...rest } = data; - const message = { - type: type, - data: rest, - timestamp: Date.now(), - }; - this.socket.send(JSON.stringify(message)); - return true; - } catch (error) { - console.error('发送 WebSocket 消息失败:', error); - return false; - } - } - - // 发送聊天消息 - sendChatMessage(message: Omit): boolean { - return this.sendRaw({ - type: 'chat', - ...message, - }); - } - - // 使用 action/params 格式发送消息(新格式) - // 请求格式: { "action": "send_message", "params": { "detail_type": "private", "conversation_id": "xxx", "message": [...] } } - sendMessage( - detailType: 'private' | 'group', - conversationId: string, - message: MessageSegment[] - ): boolean { - if (this.socket?.readyState !== WebSocket.OPEN) { - console.error('WebSocket 未连接,无法发送消息'); - return false; - } - - try { - const payload = { - action: 'send_message', - params: { - detail_type: detailType, - conversation_id: conversationId, - segments: message, - }, - }; - this.socket.send(JSON.stringify(payload)); - return true; - } catch (error) { - console.error('发送 WebSocket 消息失败:', error); - return false; - } - } - - // 发送正在输入状态 - sendTyping(conversationId: number, isTyping: boolean): boolean { - return this.sendRaw({ - type: 'typing', - conversation_id: conversationId, - is_typing: isTyping, - }); - } - - // 发送已读回执 - sendReadReceipt(conversationId: number, seq: number): boolean { - return this.sendRaw({ - type: 'read', - conversation_id: conversationId, - seq, - }); - } - - // 发送消息撤回 - sendRecall(conversationId: number, messageId: number): boolean { - return this.sendRaw({ - type: 'recall', - conversation_id: conversationId, - message_id: messageId, - }); - } - - // 处理收到的消息 -private handleMessage(rawMessage: any): void { - // 忽略心跳消息的日志,减少噪音 - if (rawMessage.type !== 'ping' && rawMessage.type !== 'pong') { - } - - // 处理心跳消息 - 不需要特殊处理,只需不报错 - if (rawMessage.type === 'ping' || rawMessage.type === 'pong') { - return; - } - - // 统一处理所有消息类型:后端发送格式为 { type, data },需要提取 data 字段 - let message = rawMessage; - if (rawMessage.data) { - message = { - type: rawMessage.type, - ...rawMessage.data, // 将 data 的内容展开到顶层 - }; - } - - // 检测新事件格式:通过 detail_type 字段判断 - if (message.detail_type && (message.type === 'message' || message.type === 'notice' || message.type === 'request' || message.type === 'meta')) { - this.handleNewEventFormat(message); - return; - } - - // 处理后端发送的 "message" 类型(私聊消息),转换为前端期望的 "chat" 类型 - // 【注意】只有没有detail_type的旧格式消息才在这里处理,新格式消息在上面已经处理 - if (message.type === 'message' && !message.detail_type) { - // 如果有 group_id,说明这是后端同时发送的群聊消息旧格式帧,忽略它 - // 群聊消息会通过另一帧 type:'group_message' 到达,那里有完整的 sender_id - if (message.group_id) { - return; - } - - // 解析 segments(只支持 segments 字段) - let segments = message.segments; - if (segments && typeof segments === 'string') { - try { - segments = JSON.parse(segments); - } catch (e) { - console.error('[WebSocket] 解析 segments 失败:', e); - } - } - - - // 转换为前端 WSChatMessage 格式 - const chatMessage: WSChatMessage = { - type: 'chat', - conversation_id: message.conversation_id, - id: message.id, - sender_id: message.user_id, - seq: typeof message.seq === 'string' ? parseInt(message.seq, 10) : message.seq, - segments: segments || [], - created_at: message.time ? new Date(message.time).toISOString() : new Date().toISOString(), - }; - - // 调用 chat 类型的处理器 - const handlers = this.messageHandlers.get('chat'); - if (handlers) { - handlers.forEach(handler => handler(chatMessage)); - } - - // 收到聊天消息时触发震动 - vibrateOnMessage('chat').catch(err => { - console.error('[WebSocket] 震动反馈失败:', err); - }); - - // 收到聊天消息时也调用系统通知服务 - systemNotificationService.handleWSMessage(chatMessage).catch(err => { - console.error('[WebSocket] 聊天消息通知显示失败:', err); - }); - - return; - } - - // 处理 "read" 已读回执消息 - 转换格式后路由到处理器 - if (message.type === 'read') { - const readHandlers = this.messageHandlers.get('read'); - if (readHandlers) { - // 转换为前端期望的格式(保持string类型) - // 后端现在使用 snake_case 命名,优先使用 snake_case 字段 - const readMessage: WSReadMessage = { - type: 'read', - conversation_id: message.conversation_id || message.conversationId, - user_id: message.user_id || message.userId, - seq: typeof message.seq === 'string' ? parseInt(message.seq, 10) : message.seq, - }; - readHandlers.forEach(handler => handler(readMessage)); - } - return; - } - - // 处理其他类型的消息(包括 group_message, notification, announcement 等) - let processedMessage = message; - - // 如果 segments 是 JSON 字符串,解析为对象数组 - if (message.type === 'group_message' && message.segments && typeof message.segments === 'string') { - try { - const parsedSegments = JSON.parse(message.segments); - processedMessage = { ...message, segments: parsedSegments }; - } catch (e) { - console.error('[WebSocket] 解析 group_message segments 失败:', e); - } - } - - const handlers = this.messageHandlers.get(message.type); - if (handlers) { - // 针对群聊消息和群通知添加详细日志 - if (message.type === 'group_message') { - // 收到群聊消息时触发震动 - vibrateOnMessage('group_message').catch(err => { - console.error('[WebSocket] 群聊消息震动反馈失败:', err); - }); - } - if (message.type === 'group_notice') { - } - handlers.forEach(handler => handler(processedMessage)); - } else { - } - - // 处理通知和公告消息时调用系统通知服务 - if (message.type === 'notification' || message.type === 'announcement') { - // 收到通知时触发震动 - vibrateOnMessage('notification').catch(err => { - console.error('[WebSocket] 通知震动反馈失败:', err); - }); - systemNotificationService.handleWSMessage(message as WSNotificationMessage | WSAnnouncementMessage).catch(err => { - console.error('[WebSocket] 系统通知显示失败:', err); - }); - } -} - -// 处理新事件格式 -private handleNewEventFormat(message: any): void { - const { type, detail_type, id, time, seq, message: segments, conversation_id, user_id } = message; - - - // 根据 type 和 detail_type 路由消息 - switch (type) { - case 'message': - // 消息事件:私聊或群聊 - if (detail_type === 'private' || detail_type === 'group') { - this.handleNewMessageEvent(message); - } - break; - - case 'notice': - // 通知事件:关注、点赞、评论等 - this.handleNewNoticeEvent(message); - break; - - case 'request': - // 请求事件:加群请求等 - this.handleNewRequestEvent(message); - break; - - case 'meta': - // 元事件:心跳、输入、已读等 - this.handleNewMetaEvent(message); - break; - - default: - } -} - -// 处理新格式的消息事件 -private handleNewMessageEvent(message: any): void { - const { detail_type, id, time, seq, message: segments, conversation_id, user_id, created_at, sender_id } = message; - - const parsedSeq = typeof seq === 'string' ? parseInt(seq, 10) : seq; - const parsedTime = created_at || time - ? new Date(created_at || time).toISOString() - : new Date().toISOString(); - - if (detail_type === 'group') { - // 群聊消息 - 转换为 WSGroupChatMessage 格式,分发到 group_message 处理器 - const groupChatMessage: WSGroupChatMessage = { - type: 'group_message', - conversation_id: conversation_id, - group_id: message.group_id || '', - id: id, - sender_id: sender_id || user_id || '', - seq: parsedSeq, - segments: segments, - created_at: parsedTime, - }; - - const groupHandlers = this.messageHandlers.get('group_message'); - if (groupHandlers) { - groupHandlers.forEach(handler => handler(groupChatMessage)); - } - - vibrateOnMessage('group_message').catch(err => { - console.error('[WebSocket] 群聊消息震动反馈失败:', err); - }); - - systemNotificationService.handleWSMessage(groupChatMessage as any).catch(err => { - console.error('[WebSocket] 群聊消息通知显示失败:', err); - }); - } else { - // 私聊消息 - 转换为 WSChatMessage 格式,分发到 chat 处理器 - const chatMessage: WSChatMessage = { - type: 'chat', - conversation_id: conversation_id, - id: id, - sender_id: sender_id || user_id || '', - seq: parsedSeq, - segments: segments, - created_at: parsedTime, - }; - - const chatHandlers = this.messageHandlers.get('chat'); - if (chatHandlers) { - chatHandlers.forEach(handler => handler(chatMessage)); - } - - vibrateOnMessage('chat').catch(err => { - console.error('[WebSocket] 新格式消息震动反馈失败:', err); - }); - - systemNotificationService.handleWSMessage(chatMessage).catch(err => { - console.error('[WebSocket] 聊天消息通知显示失败:', err); - }); - } -} - -// 处理新格式的通知事件 -private handleNewNoticeEvent(message: any): void { - const { detail_type, id, time, user_id, message: segments } = message; - - - // 调用 notice 类型的处理器 - const handlers = this.messageHandlers.get('notice'); - if (handlers) { - const noticeEvent: WSNoticeEvent = { - type: 'notice', - detail_type: detail_type as 'follow' | 'like' | 'comment', - id: id, - time: time, - user_id: user_id, - message: segments, - }; - handlers.forEach(handler => handler(noticeEvent)); - } -} - -// 处理新格式的请求事件 -private handleNewRequestEvent(message: any): void { - const { detail_type, id, time, user_id, message: segments } = message; - - - // 调用 request 类型的处理器 - const handlers = this.messageHandlers.get('request'); - if (handlers) { - const requestEvent: WSRequestEvent = { - type: 'request', - detail_type: detail_type as 'group_join' | 'friend', - id: id, - time: time, - user_id: user_id, - message: segments, - }; - handlers.forEach(handler => handler(requestEvent)); - } -} - -// 处理新格式的元事件 -private handleNewMetaEvent(message: any): void { - const { detail_type, id, time, conversation_id, user_id, seq } = message; - - - switch (detail_type) { - case 'heartbeat': - // 心跳事件,不需要特殊处理 - break; - - case 'typing': - // 输入事件 - const typingHandlers = this.messageHandlers.get('typing'); - if (typingHandlers) { - const typingMessage: WSTypingMessage = { - type: 'typing', - conversation_id: conversation_id, - user_id: user_id, - is_typing: message.is_typing !== undefined ? message.is_typing : true, - }; - typingHandlers.forEach(handler => handler(typingMessage)); - } - break; - - case 'read': - // 已读事件 - const readHandlers = this.messageHandlers.get('read'); - if (readHandlers) { - const readMessage: WSReadMessage = { - type: 'read', - conversation_id: conversation_id, - user_id: user_id, - seq: typeof seq === 'string' ? parseInt(seq, 10) : (seq || 0), - }; - readHandlers.forEach(handler => handler(readMessage)); - } - break; - - case 'ack': - // 消息发送确认事件 - 转换为对应消息格式并路由到处理器 - - // 【重要】ACK消息是发送确认,不应该增加未读数 - // 在转换为消息格式时,添加标记以便MessageManager识别 - const isAckMessage = true; - - // 解析 segments(只支持 segments 字段) - let ackSegments = message.segments; - if (ackSegments && typeof ackSegments === 'string') { - try { - ackSegments = JSON.parse(ackSegments); - } catch (e) { - console.error('[WebSocket] 解析 ack segments 失败:', e); - } - } - - if (message.group_id) { - // 群聊消息确认 - 转换为群聊消息格式 - const ackHandlers = this.messageHandlers.get('group_message'); - if (ackHandlers) { - const ackAsGroupMessage: WSGroupChatMessage & { _isAck?: boolean } = { - type: 'group_message', - conversation_id: message.conversation_id, - group_id: message.group_id, - id: message.id, - sender_id: message.sender_id, - seq: typeof message.seq === 'string' ? parseInt(message.seq, 10) : message.seq, - segments: ackSegments, - created_at: message.created_at ? new Date(message.created_at).toISOString() : new Date().toISOString(), - _isAck: true, // 标记这是ACK消息 - }; - ackHandlers.forEach(handler => handler(ackAsGroupMessage)); - } - } else if (message.conversation_id) { - // 私聊消息确认 - 转换为私聊消息格式 - const chatHandlers = this.messageHandlers.get('chat'); - if (chatHandlers) { - const ackAsChatMessage: WSChatMessage & { _isAck?: boolean } = { - type: 'chat', - conversation_id: message.conversation_id, - id: message.id, - sender_id: message.user_id || message.sender_id, - seq: typeof message.seq === 'string' ? parseInt(message.seq, 10) : message.seq, - segments: ackSegments, - created_at: message.created_at ? new Date(message.created_at).toISOString() : new Date().toISOString(), - _isAck: true, // 标记这是ACK消息 - }; - chatHandlers.forEach(handler => handler(ackAsChatMessage)); - } - } else { - } - break; - - default: - } -} - -// 从消息段中提取纯文本内容 -private extractTextFromSegments(segments?: any[]): string { - if (!segments || !Array.isArray(segments)) { - return ''; - } - return segments - .filter(seg => seg.type === 'text') - .map(seg => seg.data?.text || '') - .join(''); -} - - // 注册消息处理器 - on( - type: T, - handler: MessageHandler> - ): () => void { - const handlers = this.messageHandlers.get(type) || []; - handlers.push(handler as MessageHandler); - this.messageHandlers.set(type, handlers); - - // 返回取消订阅的函数 - return () => { - const currentHandlers = this.messageHandlers.get(type) || []; - const index = currentHandlers.indexOf(handler as MessageHandler); - if (index > -1) { - currentHandlers.splice(index, 1); - } - }; - } - - // 注册连接成功处理器 - onConnect(handler: ConnectionHandler): () => void { - this.connectionHandlers.push(handler); - return () => { - const index = this.connectionHandlers.indexOf(handler); - if (index > -1) { - this.connectionHandlers.splice(index, 1); - } - }; - } - - // 注册断开连接处理器 - onDisconnect(handler: ConnectionHandler): () => void { - this.disconnectionHandlers.push(handler); - return () => { - const index = this.disconnectionHandlers.indexOf(handler); - if (index > -1) { - this.disconnectionHandlers.splice(index, 1); - } - }; - } - - // 处理聊天消息 - onChatMessage(handler: (message: WSChatMessage) => void): () => void { - return this.on('chat', handler); - } - - // 处理已读回执 - onRead(handler: (message: WSReadMessage) => void): () => void { - return this.on('read', handler); - } - - // 处理正在输入 - onTyping(handler: (message: WSTypingMessage) => void): () => void { - return this.on('typing', handler); - } - - // 处理消息撤回 - onRecall(handler: (message: WSRecallMessage) => void): () => void { - return this.on('recall', handler); - } - - // 处理系统通知消息 - onNotification(handler: (message: WSNotificationMessage) => void): () => void { - return this.on('notification', handler); - } - - // 处理系统公告消息 - onAnnouncement(handler: (message: WSAnnouncementMessage) => void): () => void { - return this.on('announcement', handler); - } - - // ==================== 群聊消息处理方法 ==================== - - // 发送群聊消息 - sendGroupChatMessage(message: { - conversationId: string; - groupId: number | string; - mediaUrl?: string; - replyToId?: string; - mentionUsers?: string[]; - mentionAll?: boolean; - segments: MessageSegment[]; // 消息链格式(必需) - }): boolean { - return this.sendRaw({ - type: 'group_message', - conversation_id: message.conversationId, - group_id: message.groupId, - media_url: message.mediaUrl, - reply_to_id: message.replyToId, - mention_users: message.mentionUsers, - mention_all: message.mentionAll, - segments: message.segments, - }); - } - - // 发送群聊正在输入状态 - sendGroupTyping(groupId: number, isTyping: boolean): boolean { - return this.sendRaw({ - type: 'group_typing', - group_id: groupId, - is_typing: isTyping, - }); - } - - // 发送群聊已读回执 - sendGroupReadReceipt(groupId: number, conversationId: string, seq: number): boolean { - return this.sendRaw({ - type: 'group_read', - group_id: groupId, - conversation_id: conversationId, - seq, - }); - } - - // 发送群消息撤回 - sendGroupRecall(groupId: number, conversationId: string, messageId: string): boolean { - return this.sendRaw({ - type: 'group_recall', - group_id: groupId, - conversation_id: conversationId, - message_id: messageId, - }); - } - - // 处理群聊消息 - onGroupChatMessage(handler: (message: WSGroupChatMessage) => void): () => void { - return this.on('group_message', handler); - } - - // 处理群聊正在输入 - onGroupTyping(handler: (message: WSGroupTypingMessage) => void): () => void { - return this.on('group_typing', handler); - } - - // 处理群通知 - onGroupNotice(handler: (message: WSGroupNoticeMessage) => void): () => void { - return this.on('group_notice', handler); - } - - // 处理群@消息 - onGroupMention(handler: (message: WSGroupMentionMessage) => void): () => void { - return this.on('group_mention', handler); - } - - // 处理群已读回执 - onGroupRead(handler: (message: WSGroupReadMessage) => void): () => void { - return this.on('group_read', handler); - } - - // 处理群消息撤回 - onGroupRecall(handler: (message: WSGroupRecallMessage) => void): () => void { - return this.on('group_recall', handler); - } - - // 开始心跳检测 - private startHeartbeat(): void { - this.stopHeartbeat(); - this.heartbeatTimer = setInterval(() => { - if (this.socket?.readyState === WebSocket.OPEN) { - // 发送 ping 作为心跳 - this.socket.send(JSON.stringify({ type: 'ping' })); - } - }, this.heartbeatInterval); - } - - // 停止心跳检测 - private stopHeartbeat(): void { - if (this.heartbeatTimer) { - clearInterval(this.heartbeatTimer); - this.heartbeatTimer = null; - } - } - - // 处理重连 - private handleReconnect(): void { - if (this.reconnectAttempts >= this.maxReconnectAttempts) { - console.error('WebSocket 重连次数已达上限'); - return; - } - - this.stopReconnect(); - - this.reconnectTimer = setTimeout(() => { - this.reconnectAttempts++; - this.connect(); - }, this.reconnectDelay); - } - - // 停止重连 - private stopReconnect(): void { - if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - } - - // 获取连接状态 - isConnected(): boolean { - return this.socket?.readyState === WebSocket.OPEN; - } - - // 设置重连参数 - setReconnectOptions(options: { - maxAttempts?: number; - delay?: number; - heartbeatInterval?: number; - }): void { - if (options.maxAttempts !== undefined) { - this.maxReconnectAttempts = options.maxAttempts; - } - if (options.delay !== undefined) { - this.reconnectDelay = options.delay; - } - if (options.heartbeatInterval !== undefined) { - this.heartbeatInterval = options.heartbeatInterval; - } - } - - // 初始化 App 状态监听(从后台恢复时自动重连) - private setupAppStateListener(): void { - if (this.appStateSubscription) { - return; // 已经初始化过 - } - - this.lastAppState = AppState.currentState; - this.appStateSubscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => { - // 从后台/非活跃状态恢复到前台时,检查连接并重连 - if ( - this.lastAppState.match(/inactive|background/) && - nextAppState === 'active' - ) { - if (!this.isConnected()) { - // 重置重连计数,允许重新开始重连 - this.reconnectAttempts = 0; - this.connect(); - } - } - this.lastAppState = nextAppState; - }); - } - - // 启动服务(连接并监听 App 状态) - async start(): Promise { - this.setupAppStateListener(); - return this.connect(); - } - - // 停止服务(断开连接并清理监听) - stop(): void { - if (this.appStateSubscription) { - this.appStateSubscription.remove(); - this.appStateSubscription = null; - } - this.disconnect(); - } -} - -// 导出 WebSocket 服务实例 -export const websocketService = new WebSocketService(); diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts index d131a04..7fbc59e 100644 --- a/src/stores/authStore.ts +++ b/src/stores/authStore.ts @@ -13,7 +13,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { create } from 'zustand'; import { User } from '../types'; -import { authService, resolveAuthApiError, websocketService, LoginRequest, RegisterRequest } from '../services'; +import { authService, resolveAuthApiError, LoginRequest, RegisterRequest } from '../services'; +import { sseService } from '../services/sseService'; import { initDatabase, closeDatabase, @@ -91,12 +92,12 @@ function resolveLoginError(error: any): string { return '登录失败,请稍后重试'; } -// ── 启动 WebSocket 服务 ── -async function startWebSocket(): Promise { +// ── 启动 SSE 实时服务 ── +async function startRealtime(): Promise { try { - await websocketService.start(); + await sseService.start(); } catch (error) { - console.error('[AuthStore] 启动 WebSocket 服务失败:', error); + console.error('[AuthStore] 启动 SSE 服务失败:', error); } } @@ -153,8 +154,8 @@ export const useAuthStore = create((set) => ({ error: null, }); - // 5. 启动 WebSocket - await startWebSocket(); + // 5. 启动 SSE + await startRealtime(); return true; } catch (error: any) { @@ -191,7 +192,7 @@ export const useAuthStore = create((set) => ({ error: null, }); - await startWebSocket(); + await startRealtime(); return true; } catch (error: any) { @@ -210,8 +211,8 @@ export const useAuthStore = create((set) => ({ try { // 1. 通知服务端(Token 清理在 authService 内部完成) await authService.logout(); - // 2. 停止 WebSocket - websocketService.stop(); + // 2. 停止 SSE + sseService.stop(); // 3. 清除 DB 中的用户缓存(DB 此时一定已初始化) await clearCurrentUserCache().catch(() => {}); // 4. 关闭数据库连接 @@ -270,8 +271,8 @@ export const useAuthStore = create((set) => ({ isLoading: false, }); - // 6. 启动 WebSocket - await startWebSocket(); + // 6. 启动 SSE + await startRealtime(); } else { // Token 已失效或不存在 await clearUserId(); diff --git a/src/stores/messageManager.ts b/src/stores/messageManager.ts index af7c6ab..818797c 100644 --- a/src/stores/messageManager.ts +++ b/src/stores/messageManager.ts @@ -16,7 +16,7 @@ import { ConversationResponse, MessageResponse, MessageSegment, UserDTO } from '../types/dto'; import { messageService } from '../services/messageService'; import { - websocketService, + sseService, WSChatMessage, WSGroupChatMessage, WSReadMessage, @@ -26,7 +26,7 @@ import { WSGroupTypingMessage, WSGroupNoticeMessage, GroupNoticeType, -} from '../services/websocketService'; +} from '../services/sseService'; import { saveMessage, saveMessagesBatch, @@ -39,7 +39,7 @@ import { CachedMessage, getUserCache, saveUserCache, - deleteMessage as deleteMessageFromDb, + updateMessageStatus, deleteConversation as deleteConversationFromDb, } from '../services/database'; import { api } from '../services/api'; @@ -326,47 +326,47 @@ class MessageManager { // 监听私聊消息 - websocketService.on('chat', (message: WSChatMessage) => { + sseService.on('chat', (message: WSChatMessage) => { this.handleNewMessage(message); }); // 监听群聊消息 - websocketService.on('group_message', (message: WSGroupChatMessage) => { + sseService.on('group_message', (message: WSGroupChatMessage) => { this.handleNewMessage(message); }); // 监听私聊已读回执 - websocketService.on('read', (message: WSReadMessage) => { + sseService.on('read', (message: WSReadMessage) => { this.handleReadReceipt(message); }); // 监听群聊已读回执 - websocketService.on('group_read', (message: WSGroupReadMessage) => { + sseService.on('group_read', (message: WSGroupReadMessage) => { this.handleGroupReadReceipt(message); }); // 监听私聊消息撤回 - websocketService.on('recall', (message: WSRecallMessage) => { + sseService.on('recall', (message: WSRecallMessage) => { this.handleRecallMessage(message); }); // 监听群聊消息撤回 - websocketService.on('group_recall', (message: WSGroupRecallMessage) => { + sseService.on('group_recall', (message: WSGroupRecallMessage) => { this.handleGroupRecallMessage(message); }); // 监听群聊输入状态 - websocketService.on('group_typing', (message: WSGroupTypingMessage) => { + sseService.on('group_typing', (message: WSGroupTypingMessage) => { this.handleGroupTyping(message); }); // 监听群通知 - websocketService.on('group_notice', (message: WSGroupNoticeMessage) => { + sseService.on('group_notice', (message: WSGroupNoticeMessage) => { this.handleGroupNotice(message); }); // 监听连接状态 - websocketService.onConnect(() => { + sseService.onConnect(() => { this.state.isWebSocketConnected = true; this.notifySubscribers({ type: 'connection_changed', @@ -389,7 +389,7 @@ class MessageManager { } }); - websocketService.onDisconnect(() => { + sseService.onDisconnect(() => { this.state.isWebSocketConnected = false; this.notifySubscribers({ type: 'connection_changed', @@ -761,36 +761,93 @@ class MessageManager { private handleGroupReadReceipt(message: WSGroupReadMessage): void { } + /** + * 将指定消息标记为已撤回(保留占位,不删除) + */ + private markMessageAsRecalled(conversationId: string, messageId: string): void { + const normalizedConversationId = this.normalizeConversationId(conversationId); + const messages = this.state.messagesMap.get(normalizedConversationId); + if (!messages) { + return; + } + + let changed = false; + const updatedMessages: MessageResponse[] = messages.map((m): MessageResponse => { + if (String(m.id) !== String(messageId) || m.status === 'recalled') { + return m; + } + changed = true; + return { + ...m, + status: 'recalled' as MessageResponse['status'], + segments: [], + }; + }); + + if (!changed) { + return; + } + + this.state.messagesMap.set(normalizedConversationId, updatedMessages); + this.notifySubscribers({ + type: 'messages_updated', + payload: { conversationId: normalizedConversationId, messages: updatedMessages }, + timestamp: Date.now(), + }); + } + + /** + * 如果撤回的是会话最后一条消息,同步会话列表中的 last_message 状态 + */ + private syncConversationLastMessageOnRecall(conversationId: string, messageId: string): void { + const normalizedConversationId = this.normalizeConversationId(conversationId); + const conversation = this.state.conversations.get(normalizedConversationId); + if (!conversation?.last_message) { + return; + } + if (String(conversation.last_message.id) !== String(messageId)) { + return; + } + if (conversation.last_message.status === 'recalled') { + return; + } + + const updatedConversation: ConversationResponse = { + ...conversation, + last_message: { + ...conversation.last_message, + status: 'recalled', + }, + }; + this.state.conversations.set(normalizedConversationId, updatedConversation); + this.updateConversationList(); + this.notifySubscribers({ + type: 'conversations_updated', + payload: { conversations: this.state.conversationList }, + timestamp: Date.now(), + }); + } + /** * 处理私聊消息撤回 */ private handleRecallMessage(message: WSRecallMessage): void { const { conversation_id, message_id } = message; + const normalizedConversationId = this.normalizeConversationId(conversation_id); - // 从消息列表中移除被撤回的消息 - const messages = this.state.messagesMap.get(conversation_id); - if (messages) { - const updatedMessages = messages.filter(m => m.id !== message_id); - if (updatedMessages.length !== messages.length) { - this.state.messagesMap.set(conversation_id, updatedMessages); - this.notifySubscribers({ - type: 'messages_updated', - payload: { conversationId: conversation_id, messages: updatedMessages }, - timestamp: Date.now(), - }); - } - } + this.markMessageAsRecalled(normalizedConversationId, message_id); + this.syncConversationLastMessageOnRecall(normalizedConversationId, message_id); // 通知订阅者消息被撤回 this.notifySubscribers({ type: 'message_recalled', - payload: { conversationId: conversation_id, messageId: message_id }, + payload: { conversationId: normalizedConversationId, messageId: message_id }, timestamp: Date.now(), }); - // 从本地数据库删除 - deleteMessageFromDb(message_id).catch(error => { - console.error('[MessageManager] 删除本地消息失败:', error); + // 同步本地数据库状态,避免冷启动后撤回占位丢失 + updateMessageStatus(message_id, 'recalled', true).catch(error => { + console.error('[MessageManager] 更新本地消息撤回状态失败:', error); }); } @@ -799,31 +856,21 @@ class MessageManager { */ private handleGroupRecallMessage(message: WSGroupRecallMessage): void { const { conversation_id, message_id } = message; + const normalizedConversationId = this.normalizeConversationId(conversation_id); - // 从消息列表中移除被撤回的消息 - const messages = this.state.messagesMap.get(conversation_id); - if (messages) { - const updatedMessages = messages.filter(m => m.id !== message_id); - if (updatedMessages.length !== messages.length) { - this.state.messagesMap.set(conversation_id, updatedMessages); - this.notifySubscribers({ - type: 'messages_updated', - payload: { conversationId: conversation_id, messages: updatedMessages }, - timestamp: Date.now(), - }); - } - } + this.markMessageAsRecalled(normalizedConversationId, message_id); + this.syncConversationLastMessageOnRecall(normalizedConversationId, message_id); // 通知订阅者消息被撤回 this.notifySubscribers({ type: 'message_recalled', - payload: { conversationId: conversation_id, messageId: message_id, isGroup: true }, + payload: { conversationId: normalizedConversationId, messageId: message_id, isGroup: true }, timestamp: Date.now(), }); - // 从本地数据库删除 - deleteMessageFromDb(message_id).catch(error => { - console.error('[MessageManager] 删除本地消息失败:', error); + // 同步本地数据库状态,避免冷启动后撤回占位丢失 + updateMessageStatus(message_id, 'recalled', true).catch(error => { + console.error('[MessageManager] 更新本地消息撤回状态失败:', error); }); } diff --git a/src/types/dto.ts b/src/types/dto.ts index c836f56..cd625fa 100644 --- a/src/types/dto.ts +++ b/src/types/dto.ts @@ -62,6 +62,7 @@ export interface PostDTO { title: string; content: string; images: PostImageDTO[]; + status?: string; likes_count: number; comments_count: number; favorites_count: number; @@ -71,6 +72,7 @@ export interface PostDTO { is_locked: boolean; is_vote: boolean; created_at: string; + updated_at?: string; author: UserDTO | null; is_liked: boolean; is_favorited: boolean;