Migrate frontend realtime messaging to SSE.

Switch service integrations and screen/store consumers from websocket events to SSE, and ignore generated dist-web artifacts.

Made-with: Cursor
This commit is contained in:
2026-03-10 12:58:23 +08:00
parent 63e32b15a3
commit be84c01abd
25 changed files with 974 additions and 1305 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ node_modules/
# Expo # Expo
.expo/ .expo/
dist/ dist/
dist-web/
web-build/ web-build/
expo-env.d.ts expo-env.d.ts

View File

@@ -2,10 +2,8 @@ const appJson = require('./app.json');
const isDevVariant = process.env.APP_VARIANT === 'dev'; const isDevVariant = process.env.APP_VARIANT === 'dev';
const releaseApiBaseUrl = 'https://bbs.littlelan.cn/api/v1'; const releaseApiBaseUrl = 'https://bbs.littlelan.cn/api/v1';
const releaseWsUrl = 'wss://bbs.littlelan.cn/ws';
const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn'; const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1'; 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) { function toManifestUrl(baseUrl, portOverride) {
const parsed = new URL(baseUrl); const parsed = new URL(baseUrl);
@@ -46,7 +44,6 @@ module.exports = {
...(expo.extra || {}), ...(expo.extra || {}),
appVariant: isDevVariant ? 'dev' : 'release', appVariant: isDevVariant ? 'dev' : 'release',
apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl, apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl,
wsUrl: isDevVariant ? devWsUrl : releaseWsUrl,
updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl, updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
}, },
}; };

7
package-lock.json generated
View File

@@ -46,6 +46,7 @@
"react-native-reanimated": "^4.2.1", "react-native-reanimated": "^4.2.1",
"react-native-safe-area-context": "~5.6.2", "react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0", "react-native-screens": "~4.23.0",
"react-native-sse": "^1.2.1",
"react-native-web": "^0.21.0", "react-native-web": "^0.21.0",
"react-native-worklets": "0.7.2", "react-native-worklets": "0.7.2",
"zod": "^4.3.6", "zod": "^4.3.6",
@@ -9374,6 +9375,12 @@
"react-native": "*" "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": { "node_modules/react-native-tab-view": {
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmmirror.com/react-native-tab-view/-/react-native-tab-view-4.2.2.tgz", "resolved": "https://registry.npmmirror.com/react-native-tab-view/-/react-native-tab-view-4.2.2.tgz",

View File

@@ -52,6 +52,7 @@
"react-native-reanimated": "^4.2.1", "react-native-reanimated": "^4.2.1",
"react-native-safe-area-context": "~5.6.2", "react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0", "react-native-screens": "~4.23.0",
"react-native-sse": "^1.2.1",
"react-native-web": "^0.21.0", "react-native-web": "^0.21.0",
"react-native-worklets": "0.7.2", "react-native-worklets": "0.7.2",
"zod": "^4.3.6", "zod": "^4.3.6",

View File

@@ -13,8 +13,6 @@ import {
useWindowDimensions, useWindowDimensions,
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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 { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { Post } from '../../types'; import { Post } from '../../types';
import Text from '../common/Text'; import Text from '../common/Text';
@@ -128,16 +126,20 @@ const PostCard: React.FC<PostCardProps> = ({
return 0; // 移动端无额外内边距 return 0; // 移动端无额外内边距
}, [isWideScreen, isDesktop, isTablet]); }, [isWideScreen, isDesktop, isTablet]);
const formatTime = (dateString: string | undefined | null): string => { const formatDateTime = (dateString?: string | null): string => {
if (!dateString) return ''; if (!dateString) return '';
try { const date = new Date(dateString);
return formatDistanceToNow(new Date(dateString), { if (Number.isNaN(date.getTime())) return '';
addSuffix: true, const pad = (num: number) => String(num).padStart(2, '0');
locale: zhCN, return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}); };
} catch {
return ''; 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 => { const getTruncatedContent = (content: string | undefined | null, maxLength: number = 100): string => {
@@ -497,8 +499,13 @@ const PostCard: React.FC<PostCardProps> = ({
</View> </View>
<View style={styles.postMeta}> <View style={styles.postMeta}>
<Text variant="caption" color={colors.text.hint} style={styles.timeText}> <Text variant="caption" color={colors.text.hint} style={styles.timeText}>
{formatTime(post.created_at || '')} {formatDateTime(post.created_at)}
</Text> </Text>
{isPostEdited(post.created_at, post.updated_at) && (
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
{' · 修改 '}{formatDateTime(post.updated_at)}
</Text>
)}
</View> </View>
</View> </View>
{post.is_pinned && ( {post.is_pinned && (

View File

@@ -72,7 +72,12 @@ export type RootStackParamList = {
Auth: undefined; Auth: undefined;
PostDetail: { postId: string; scrollToComments?: boolean }; PostDetail: { postId: string; scrollToComments?: boolean };
UserProfile: { userId: string }; UserProfile: { userId: string };
CreatePost: undefined; CreatePost:
| undefined
| {
mode?: 'create' | 'edit';
postId?: string;
};
Chat: { Chat: {
conversationId: string; conversationId: string;
userId?: string; userId?: string;

View File

@@ -21,7 +21,7 @@ import {
Image, Image,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; 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 { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
@@ -31,6 +31,7 @@ import { ApiError } from '../../services/api';
import { uploadService } from '../../services/uploadService'; import { uploadService } from '../../services/uploadService';
import VoteEditor from '../../components/business/VoteEditor'; import VoteEditor from '../../components/business/VoteEditor';
import { useResponsive, useResponsiveValue } from '../../hooks'; import { useResponsive, useResponsiveValue } from '../../hooks';
import { RootStackParamList } from '../../navigation/types';
const MAX_TITLE_LENGTH = 100; const MAX_TITLE_LENGTH = 100;
const MAX_CONTENT_LENGTH = 2000; 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 = () => { export const CreatePostScreen: React.FC = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const route = useRoute<RouteProp<RootStackParamList, 'CreatePost'>>();
const isEditMode = route.params?.mode === 'edit' && !!route.params?.postId;
const editPostID = route.params?.postId || '';
// 响应式布局 // 响应式布局
const { isWideScreen, width } = useResponsive(); const { isWideScreen, width } = useResponsive();
@@ -86,6 +90,7 @@ export const CreatePostScreen: React.FC = () => {
const [showTagInput, setShowTagInput] = useState(false); const [showTagInput, setShowTagInput] = useState(false);
const [showEmojiPanel, setShowEmojiPanel] = useState(false); const [showEmojiPanel, setShowEmojiPanel] = useState(false);
const [posting, setPosting] = useState(false); const [posting, setPosting] = useState(false);
const [loadingPost, setLoadingPost] = useState(false);
// 投票相关状态 // 投票相关状态
const [isVotePost, setIsVotePost] = useState(false); const [isVotePost, setIsVotePost] = useState(false);
@@ -122,6 +127,41 @@ export const CreatePostScreen: React.FC = () => {
]).start(); ]).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 handlePickImage = async () => {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
@@ -323,19 +363,37 @@ export const CreatePostScreen: React.FC = () => {
}); });
navigation.goBack(); navigation.goBack();
} else { } else {
// 创建普通帖子 if (isEditMode && editPostID) {
await postService.createPost({ const updated = await postService.updatePost(editPostID, {
title: title.trim() || '无标题', title: title.trim() || '无标题',
content: content.trim(), content: content.trim(),
images: imageUrls, images: imageUrls,
}); });
showPrompt({ if (!updated) {
type: 'info', throw new Error('更新帖子失败');
title: '审核中', }
message: '帖子已提交,内容审核中,稍后展示', showPrompt({
duration: 2600, type: 'success',
}); title: '修改成功',
navigation.goBack(); 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) { } catch (error) {
console.error('发布帖子失败:', error); console.error('发布帖子失败:', error);
@@ -631,7 +689,7 @@ export const CreatePostScreen: React.FC = () => {
<MaterialCommunityIcons name="loading" size={18} color={colors.primary.contrast} /> <MaterialCommunityIcons name="loading" size={18} color={colors.primary.contrast} />
) : ( ) : (
<Text variant="body" color={colors.primary.contrast} style={styles.postButtonText}> <Text variant="body" color={colors.primary.contrast} style={styles.postButtonText}>
{isEditMode ? '保存' : '发布'}
</Text> </Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -677,10 +735,24 @@ export const CreatePostScreen: React.FC = () => {
> >
{isWideScreen ? ( {isWideScreen ? (
<ResponsiveContainer maxWidth={800}> <ResponsiveContainer maxWidth={800}>
{renderMainContent()} {loadingPost ? (
<View style={styles.loadingContainer}>
<MaterialCommunityIcons name="loading" size={28} color={colors.primary.main} />
<Text variant="body" color={colors.text.secondary} style={styles.loadingText}>...</Text>
</View>
) : (
renderMainContent()
)}
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
renderMainContent() loadingPost ? (
<View style={styles.loadingContainer}>
<MaterialCommunityIcons name="loading" size={28} color={colors.primary.main} />
<Text variant="body" color={colors.text.secondary} style={styles.loadingText}>...</Text>
</View>
) : (
renderMainContent()
)
)} )}
</KeyboardAvoidingView> </KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>
@@ -934,6 +1006,15 @@ const styles = StyleSheet.create({
emojiText: { emojiText: {
fontSize: 24, fontSize: 24,
}, },
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
gap: spacing.md,
},
loadingText: {
fontSize: fontSizes.md,
},
}); });
export default CreatePostScreen; export default CreatePostScreen;

View File

@@ -15,6 +15,8 @@ import {
TouchableOpacity, TouchableOpacity,
NativeScrollEvent, NativeScrollEvent,
NativeSyntheticEvent, NativeSyntheticEvent,
Alert,
Clipboard,
} from 'react-native'; } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
@@ -357,8 +359,16 @@ export const HomeScreen: React.FC = () => {
}; };
// 分享帖子 // 分享帖子
const handleShare = (post: Post) => { const handleShare = async (post: Post) => {
void 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('已复制', '帖子链接已复制到剪贴板');
}; };
// 删除帖子 // 删除帖子

View File

@@ -20,14 +20,13 @@ import {
Alert, Alert,
ScrollView, ScrollView,
Image, Image,
Clipboard,
} from 'react-native'; } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker'; 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 { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { Post, Comment, VoteResultDTO } from '../../types'; import { Post, Comment, VoteResultDTO } from '../../types';
import { useUserStore } from '../../stores'; import { useUserStore } from '../../stores';
@@ -35,11 +34,11 @@ import { useCurrentUser } from '../../stores/authStore';
import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services'; import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services';
import { CommentItem, VoteCard } from '../../components/business'; import { CommentItem, VoteCard } from '../../components/business';
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout } from '../../components/common'; 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'; import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive';
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'PostDetail'>; type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'PostDetail'>;
type PostDetailRouteProp = RouteProp<HomeStackParamList, 'PostDetail'>; type PostDetailRouteProp = RouteProp<RootStackParamList, 'PostDetail'>;
export const PostDetailScreen: React.FC = () => { export const PostDetailScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>(); const navigation = useNavigation<NavigationProp>();
@@ -181,6 +180,13 @@ export const PostDetailScreen: React.FC = () => {
loadPostDetail(true); loadPostDetail(true);
}, [loadPostDetail]); }, [loadPostDetail]);
useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => {
loadPostDetail(false);
});
return unsubscribe;
}, [navigation, loadPostDetail]);
// 如果是从评论按钮跳转过来的,加载完成后滚动到评论区 // 如果是从评论按钮跳转过来的,加载完成后滚动到评论区
useEffect(() => { useEffect(() => {
if (shouldScrollToComments && !loading && comments.length > 0) { if (shouldScrollToComments && !loading && comments.length > 0) {
@@ -262,16 +268,44 @@ export const PostDetailScreen: React.FC = () => {
setRefreshing(false); setRefreshing(false);
}, [loadPostDetail]); }, [loadPostDetail]);
// 格式化时间 const formatDateTime = (dateString?: string | null): string => {
const formatTime = (dateString: string): string => { if (!dateString) return '';
try { const date = new Date(dateString);
return formatDistanceToNow(new Date(dateString), { if (Number.isNaN(date.getTime())) return '';
addSuffix: true, const pad = (num: number) => String(num).padStart(2, '0');
locale: zhCN, return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}); };
} catch {
return ''; 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]); }, [post, favoritePost, unfavoritePost]);
// 分享帖子 // 分享帖子
const handleShare = useCallback(() => { const handleShare = useCallback(async () => {
// TODO: 实现分享功能 if (!post?.id) return;
void post; 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]); }, [post?.id]);
// 投票处理函数 // 投票处理函数
@@ -484,6 +525,14 @@ export const PostDetailScreen: React.FC = () => {
); );
}, [post, isDeleting, currentUser?.id, navigation]); }, [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) => { const handleImagePress = useCallback((images: ImageGridItem[], index: number) => {
setAllImages(images); setAllImages(images);
@@ -1012,33 +1061,59 @@ export const PostDetailScreen: React.FC = () => {
{/* 发帖时间和浏览量 - 放在图片下方 */} {/* 发帖时间和浏览量 - 放在图片下方 */}
<View style={[styles.postMetaInfo, { marginTop: responsiveGap }]}> <View style={[styles.postMetaInfo, { marginTop: responsiveGap }]}>
<Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}> <View style={styles.metaInfoMain}>
{formatTime(post.created_at)} <Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}>
</Text> {formatRelativeTime(post.created_at)}
{post.views_count !== undefined && post.views_count > 0 && ( </Text>
<> {isPostEdited(post.created_at, post.updated_at) && (
<Text style={styles.metaInfoDot}>·</Text> <>
<Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}> <Text style={styles.metaInfoDot}>·</Text>
{formatNumber(post.views_count)} <Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}>
</Text> {formatRelativeTime(post.updated_at)}
</> </Text>
)} </>
{/* 删除按钮 - 只对帖子作者显示 */} )}
{post.views_count !== undefined && post.views_count > 0 && (
<>
<Text style={styles.metaInfoDot}>·</Text>
<Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}>
{formatNumber(post.views_count)}
</Text>
</>
)}
</View>
{currentUser?.id === post.author?.id && ( {currentUser?.id === post.author?.id && (
<TouchableOpacity <View style={styles.metaActions}>
style={styles.deleteButtonInline} <TouchableOpacity
onPress={handleDeletePost} style={styles.editButtonInline}
disabled={isDeleting} onPress={handleEditPost}
> >
<MaterialCommunityIcons <MaterialCommunityIcons
name={isDeleting ? 'loading' : 'delete-outline'} name="pencil-outline"
size={14} size={14}
color={colors.text.hint} color={colors.text.hint}
/> />
<Text variant="caption" color={colors.text.hint} style={styles.deleteButtonText}> <Text variant="caption" color={colors.text.hint} style={styles.editButtonText}>
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{/* 删除按钮 - 只对帖子作者显示 */}
<TouchableOpacity
style={styles.deleteButtonInline}
onPress={handleDeletePost}
disabled={isDeleting}
>
<MaterialCommunityIcons
name={isDeleting ? 'loading' : 'delete-outline'}
size={14}
color={colors.text.hint}
/>
<Text variant="caption" color={colors.text.hint} style={styles.deleteButtonText}>
</Text>
</TouchableOpacity>
</View>
)} )}
</View> </View>
@@ -1107,7 +1182,7 @@ export const PostDetailScreen: React.FC = () => {
</View> </View>
</View> </View>
); );
}, [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<Comment | null>(null); const [replyingTo, setReplyingTo] = useState<Comment | null>(null);
@@ -1548,8 +1623,16 @@ const styles = StyleSheet.create({
postMetaInfo: { postMetaInfo: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.sm, marginBottom: spacing.sm,
}, },
metaInfoMain: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
flex: 1,
minWidth: 0,
},
metaInfoText: { metaInfoText: {
fontSize: fontSizes.sm, fontSize: fontSizes.sm,
color: colors.text.hint, color: colors.text.hint,
@@ -1563,13 +1646,28 @@ const styles = StyleSheet.create({
deleteButtonInline: { deleteButtonInline: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginLeft: 'auto', marginLeft: spacing.sm,
padding: spacing.xs, padding: spacing.xs,
}, },
deleteButtonText: { deleteButtonText: {
marginLeft: 2, marginLeft: 2,
fontSize: fontSizes.sm, 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: { imagesContainer: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',

View File

@@ -117,8 +117,8 @@ const GroupInfoScreen: React.FC = () => {
// 并行加载群组信息和成员列表 // 并行加载群组信息和成员列表
const [groupData, membersData] = await Promise.all([ const [groupData, membersData] = await Promise.all([
groupManager.getGroup(groupId), groupManager.getGroup(groupId, true),
groupManager.getMembers(groupId, 1, 100), groupManager.getMembers(groupId, 1, 100, true),
]); ]);
setGroup(groupData); setGroup(groupData);

View File

@@ -92,11 +92,16 @@ const GroupMembersScreen: React.FC = () => {
const isAdmin = currentMember?.role === 'admin' || isOwner; 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; if (!hasMore && !refresh) return;
try { try {
const response = await groupManager.getMembers(groupId, pageNum, 50); const response = await groupManager.getMembers(groupId, pageNum, 50, forceRefresh);
if (refresh) { if (refresh) {
setMembers(response.list); setMembers(response.list);
@@ -121,14 +126,14 @@ const GroupMembersScreen: React.FC = () => {
// 初始加载 // 初始加载
useEffect(() => { useEffect(() => {
loadMembers(1, true); loadMembers(1, true, true);
}, [groupId]); }, [groupId]);
// 下拉刷新 // 下拉刷新
const onRefresh = useCallback(() => { const onRefresh = useCallback(() => {
setRefreshing(true); setRefreshing(true);
setHasMore(true); setHasMore(true);
loadMembers(1, true); loadMembers(1, true, true);
}, [loadMembers]); }, [loadMembers]);
// 加载更多 // 加载更多
@@ -246,6 +251,8 @@ const GroupMembersScreen: React.FC = () => {
} }
return m; return m;
})); }));
// 强制刷新远端状态,避免命中旧缓存导致解禁后仍显示禁言
await loadMembers(1, true, true);
setActionModalVisible(false); setActionModalVisible(false);
Alert.alert('成功', `${actionText}`); Alert.alert('成功', `${actionText}`);

View File

@@ -72,9 +72,10 @@ const truncateDisplayName = (name: string, maxLength: number = MAX_CONVERSATION_
*/ */
const AsyncMessagePreview: React.FC<{ const AsyncMessagePreview: React.FC<{
segments?: MessageSegment[]; segments?: MessageSegment[];
status?: string;
isGroupChat?: boolean; isGroupChat?: boolean;
senderName?: string; senderName?: string;
}> = ({ segments, isGroupChat, senderName }) => { }> = ({ segments, status, isGroupChat, senderName }) => {
const [displayText, setDisplayText] = useState<string>(''); const [displayText, setDisplayText] = useState<string>('');
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
@@ -82,6 +83,13 @@ const AsyncMessagePreview: React.FC<{
isMountedRef.current = true; isMountedRef.current = true;
const loadPreview = async () => { const loadPreview = async () => {
if (status === 'recalled') {
if (isMountedRef.current) {
setDisplayText('消息已撤回');
}
return;
}
const initialText = extractTextFromSegments(segments); const initialText = extractTextFromSegments(segments);
if (isMountedRef.current) { if (isMountedRef.current) {
setDisplayText(initialText); setDisplayText(initialText);
@@ -112,7 +120,7 @@ const AsyncMessagePreview: React.FC<{
return () => { return () => {
isMountedRef.current = false; isMountedRef.current = false;
}; };
}, [segments]); }, [segments, status]);
if (!displayText) return null; if (!displayText) return null;
@@ -556,6 +564,7 @@ export const MessageListScreen: React.FC = () => {
) : ( ) : (
<AsyncMessagePreview <AsyncMessagePreview
segments={item.last_message?.segments} segments={item.last_message?.segments}
status={item.last_message?.status}
isGroupChat={isGroupChat} isGroupChat={isGroupChat}
senderName={getSenderName()} senderName={getSenderName()}
/> />

View File

@@ -27,9 +27,6 @@ import { uploadService } from '../../../../services/uploadService';
import { ApiError } from '../../../../services/api'; import { ApiError } from '../../../../services/api';
// 【新架构】使用 MessageManager // 【新架构】使用 MessageManager
import { useChat, useGroupTyping, useGroupMuted, messageManager } from '../../../../stores'; import { useChat, useGroupTyping, useGroupMuted, messageManager } from '../../../../stores';
import {
websocketService,
} from '../../../../services/websocketService';
import { groupService } from '../../../../services/groupService'; import { groupService } from '../../../../services/groupService';
import { userManager } from '../../../../stores/userManager'; import { userManager } from '../../../../stores/userManager';
import { groupManager } from '../../../../stores/groupManager'; import { groupManager } from '../../../../stores/groupManager';
@@ -728,14 +725,7 @@ export const useChatScreen = () => {
const segments = buildTextSegments(trimmedText, replyingTo); const segments = buildTextSegments(trimmedText, replyingTo);
if (isGroupChat && routeGroupId) { if (isGroupChat && routeGroupId) {
// 群聊消息发送 await messageService.sendMessageByAction('group', conversationId, segments);
websocketService.sendGroupChatMessage({
conversationId: conversationId,
groupId: routeGroupId,
segments: segments,
mentionUsers: selectedMentions.length > 0 ? selectedMentions : undefined,
mentionAll: mentionAll || undefined,
});
setInputText(''); setInputText('');
setSelectedMentions([]); setSelectedMentions([]);
@@ -790,12 +780,7 @@ export const useChatScreen = () => {
const segments = buildImageSegments(uploadResult.url, uploadResult.url, undefined, undefined, replyingTo); const segments = buildImageSegments(uploadResult.url, uploadResult.url, undefined, undefined, replyingTo);
if (isGroupChat && routeGroupId) { if (isGroupChat && routeGroupId) {
websocketService.sendGroupChatMessage({ await messageService.sendMessageByAction('group', conversationId, segments);
conversationId: conversationId,
groupId: routeGroupId,
mediaUrl: uploadResult.url,
segments: segments,
});
setReplyingTo(null); setReplyingTo(null);
} else { } else {
// 【新架构】私聊图片通过 MessageManager 发送 // 【新架构】私聊图片通过 MessageManager 发送
@@ -922,12 +907,7 @@ export const useChatScreen = () => {
const segments = buildImageSegments(stickerUrl, stickerUrl, undefined, undefined, replyingTo); const segments = buildImageSegments(stickerUrl, stickerUrl, undefined, undefined, replyingTo);
if (isGroupChat && routeGroupId) { if (isGroupChat && routeGroupId) {
websocketService.sendGroupChatMessage({ await messageService.sendMessageByAction('group', conversationId, segments);
conversationId: conversationId,
groupId: routeGroupId,
mediaUrl: stickerUrl,
segments: segments,
});
setReplyingTo(null); setReplyingTo(null);
} else { } else {
// 【新架构】私聊表情通过 MessageManager 发送 // 【新架构】私聊表情通过 MessageManager 发送
@@ -983,7 +963,7 @@ export const useChatScreen = () => {
const handleRecall = useCallback(async (messageId: string) => { const handleRecall = useCallback(async (messageId: string) => {
try { try {
if (isGroupChat && routeGroupId) { if (isGroupChat && routeGroupId) {
websocketService.sendGroupRecall(routeGroupId, conversationId!, messageId); await messageService.recallMessage(messageId);
} else { } else {
await messageService.recallMessage(messageId); await messageService.recallMessage(messageId);
} }

View File

@@ -145,7 +145,7 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
<Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text> <Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text>
)} )}
<Text style={[styles.messageText, isMe ? styles.messageTextMe : styles.messageTextOther]}> <Text style={[styles.messageText, isMe ? styles.messageTextMe : styles.messageTextOther]}>
{extractTextFromSegments(item.segments)} {item.status === 'recalled' ? '消息已撤回' : extractTextFromSegments(item.segments)}
</Text> </Text>
</View> </View>
{isMe && ( {isMe && (

View File

@@ -8,7 +8,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { CommonActions } from '@react-navigation/native'; import { CommonActions } from '@react-navigation/native';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
// 生产地址 https://bbs.littlelan.cn
const getBaseUrl = () => { const getBaseUrl = () => {
const configuredBaseUrl = Constants.expoConfig?.extra?.apiBaseUrl; const configuredBaseUrl = Constants.expoConfig?.extra?.apiBaseUrl;
if (typeof configuredBaseUrl === 'string' && configuredBaseUrl.trim().length > 0) { if (typeof configuredBaseUrl === 'string' && configuredBaseUrl.trim().length > 0) {
@@ -17,16 +17,8 @@ const getBaseUrl = () => {
return 'https://bbs.littlelan.cn/api/v1'; 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 BASE_URL = getBaseUrl();
const WS_URL = getWsUrl(); const SSE_URL = `${BASE_URL.replace(/\/+$/, '')}/realtime/sse`;
// Token 存储键 // Token 存储键
const TOKEN_KEY = 'auth_token'; const TOKEN_KEY = 'auth_token';
@@ -187,15 +179,45 @@ class ApiClient {
return this.request(method, path, params, body); return this.request(method, path, params, body);
} }
// 解析响应 // 解析响应(兼容非 JSON 返回,避免 SyntaxError 被误判为网络错误)
const data: ApiResponse<T> = await response.json(); const contentType = response.headers.get('content-type') || '';
let parsedBody: any = null;
let rawText = '';
// 处理业务错误 if (contentType.includes('application/json')) {
if (data.code !== 0) { parsedBody = await response.json();
throw new ApiError(data.code, data.message); } 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<T>;
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) { } catch (error) {
// 如果是 ApiError直接抛出 // 如果是 ApiError直接抛出
if (error instanceof ApiError) { if (error instanceof ApiError) {
@@ -312,5 +334,4 @@ class ApiClient {
// 导出 API 客户端实例 // 导出 API 客户端实例
export const api = new ApiClient(BASE_URL); export const api = new ApiClient(BASE_URL);
// 导出 WebSocket URL export { SSE_URL, TOKEN_KEY, REFRESH_TOKEN_KEY };
export { WS_URL, TOKEN_KEY, REFRESH_TOKEN_KEY };

View File

@@ -12,11 +12,11 @@ import { AppState, AppStateStatus, Platform } from 'react-native';
import * as BackgroundFetch from 'expo-background-fetch'; import * as BackgroundFetch from 'expo-background-fetch';
import * as TaskManager from 'expo-task-manager'; import * as TaskManager from 'expo-task-manager';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { websocketService } from './websocketService'; import { sseService } from './sseService';
// 后台任务名称 // 后台任务名称
const BACKGROUND_FETCH_TASK = 'background-fetch-keepalive'; const BACKGROUND_FETCH_TASK = 'background-fetch-keepalive';
const WEBSOCKET_KEEPALIVE_TASK = 'websocket-keepalive'; const REALTIME_KEEPALIVE_TASK = 'realtime-keepalive';
// 后台任务间隔Android 最小 15 分钟iOS 最小 15 分钟) // 后台任务间隔Android 最小 15 分钟iOS 最小 15 分钟)
const BACKGROUND_INTERVAL = 15; // 15 分钟 const BACKGROUND_INTERVAL = 15; // 15 分钟
@@ -48,8 +48,8 @@ let appStateSubscription: any = null;
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
try { try {
// 检查 WebSocket 连接状态 // 检查 WebSocket 连接状态
if (!websocketService.isConnected()) { if (!sseService.isConnected()) {
await websocketService.connect(); await sseService.connect();
} }
// 返回收到新数据 // 返回收到新数据
@@ -61,14 +61,14 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
}); });
// WebSocket 保活任务 // WebSocket 保活任务
TaskManager.defineTask(WEBSOCKET_KEEPALIVE_TASK, async () => { TaskManager.defineTask(REALTIME_KEEPALIVE_TASK, async () => {
try { try {
if (!websocketService.isConnected()) { if (!sseService.isConnected()) {
await websocketService.connect(); await sseService.connect();
} }
return BackgroundFetch.BackgroundFetchResult.NewData; return BackgroundFetch.BackgroundFetchResult.NewData;
} catch (error) { } catch (error) {
console.error('[BackgroundService] WebSocket 保活失败:', error); console.error('[BackgroundService] SSE 保活失败:', error);
return BackgroundFetch.BackgroundFetchResult.Failed; return BackgroundFetch.BackgroundFetchResult.Failed;
} }
}); });
@@ -175,9 +175,9 @@ async function registerBackgroundTasks(): Promise<void> {
} }
// 注册 WebSocket 保活任务 // 注册 WebSocket 保活任务
const isWsKeepaliveRegistered = await TaskManager.isTaskRegisteredAsync(WEBSOCKET_KEEPALIVE_TASK); const isWsKeepaliveRegistered = await TaskManager.isTaskRegisteredAsync(REALTIME_KEEPALIVE_TASK);
if (!isWsKeepaliveRegistered) { if (!isWsKeepaliveRegistered) {
await BackgroundFetch.registerTaskAsync(WEBSOCKET_KEEPALIVE_TASK, { await BackgroundFetch.registerTaskAsync(REALTIME_KEEPALIVE_TASK, {
minimumInterval: 60, // 1 分钟检查一次 minimumInterval: 60, // 1 分钟检查一次
stopOnTerminate: false, stopOnTerminate: false,
startOnBoot: true, startOnBoot: true,
@@ -194,7 +194,7 @@ async function registerBackgroundTasks(): Promise<void> {
async function unregisterBackgroundTasks(): Promise<void> { async function unregisterBackgroundTasks(): Promise<void> {
try { try {
await BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK); await BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK);
await BackgroundFetch.unregisterTaskAsync(WEBSOCKET_KEEPALIVE_TASK); await BackgroundFetch.unregisterTaskAsync(REALTIME_KEEPALIVE_TASK);
} catch (error) { } catch (error) {
console.error('[BackgroundService] 取消后台任务失败:', error); console.error('[BackgroundService] 取消后台任务失败:', error);
} }
@@ -212,8 +212,8 @@ function setupAppStateListener(): void {
void nextAppState; void nextAppState;
if (nextAppState === 'active') { if (nextAppState === 'active') {
// App 回到前台,确保连接 // App 回到前台,确保连接
if (!websocketService.isConnected()) { if (!sseService.isConnected()) {
websocketService.connect(); sseService.connect();
} }
} }
}); });

View File

@@ -459,9 +459,21 @@ export const deleteMessage = async (messageId: string): Promise<void> => {
}; };
// 更新消息状态(如撤回) // 更新消息状态(如撤回)
export const updateMessageStatus = async (messageId: string, status: string): Promise<void> => { // clearContent=true 时,会同时清空本地存储的消息内容与 segments仅保留状态占位
export const updateMessageStatus = async (
messageId: string,
status: string,
clearContent: boolean = false
): Promise<void> => {
await enqueueWrite(async () => { await enqueueWrite(async () => {
const database = await getDb(); const database = await getDb();
if (clearContent) {
await database.runAsync(
`UPDATE messages SET status = ?, content = '', segments = '[]' WHERE id = ?`,
[status, messageId]
);
return;
}
await database.runAsync( await database.runAsync(
`UPDATE messages SET status = ? WHERE id = ?`, `UPDATE messages SET status = ? WHERE id = ?`,
[status, messageId] [status, messageId]

View File

@@ -4,7 +4,7 @@
*/ */
// API 客户端 // 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'; export type { ApiResponse, PaginatedData, ApiError } from './api';
// 认证服务 // 认证服务
@@ -45,8 +45,8 @@ export { pushService, registerDevice, getDevices, unregisterDevice, updateDevice
// 投票服务 // 投票服务
export { voteService } from './voteService'; export { voteService } from './voteService';
// WebSocket 服务 // SSE 实时服务
export { websocketService } from './websocketService'; export { sseService } from './sseService';
export type { export type {
WSMessage, WSMessage,
WSMessageType, WSMessageType,
@@ -63,7 +63,7 @@ export type {
WSGroupMentionMessage, WSGroupMentionMessage,
WSGroupReadMessage, WSGroupReadMessage,
WSGroupRecallMessage WSGroupRecallMessage
} from './websocketService'; } from './sseService';
// 系统通知服务 // 系统通知服务
export { systemNotificationService, getNotificationTitle } from './systemNotificationService'; export { systemNotificationService, getNotificationTitle } from './systemNotificationService';

View File

@@ -441,6 +441,16 @@ class MessageService {
}); });
} }
/**
* 上报输入状态
* POST /api/v1/conversations/typing
*/
async sendTyping(conversationId: string): Promise<void> {
await api.post('/conversations/typing', {
conversation_id: conversationId,
});
}
/** /**
* 获取未读总数 * 获取未读总数
* GET /api/v1/conversations/unread/count * GET /api/v1/conversations/unread/count

463
src/services/sseService.ts Normal file
View File

@@ -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<T extends WSMessage = WSMessage> = (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<WSMessageType, MessageHandler[]> = 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<boolean> {
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<T extends WSMessageType>(type: T, message: Extract<WSMessage, { type: T }>) {
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<T extends WSMessageType>(type: T, handler: MessageHandler<Extract<WSMessage, { type: T }>>): () => 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<boolean> {
this.setupAppStateListener();
return this.connect();
}
stop(): void {
if (this.appStateSubscription) {
this.appStateSubscription.remove();
this.appStateSubscription = null;
}
this.disconnect();
}
}
export const sseService = new SSEService();

View File

@@ -6,7 +6,7 @@
import * as Notifications from 'expo-notifications'; import * as Notifications from 'expo-notifications';
import { Platform, AppState, AppStateStatus } from 'react-native'; 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'; import { extractTextFromSegments } from '../types/dto';
// 通知渠道配置 // 通知渠道配置
@@ -169,9 +169,9 @@ class SystemNotificationService {
data: { data: {
type: message.type, type: message.type,
id: String(message.id), id: String(message.id),
senderId: message.sender_id, senderId: message.sender_id || '',
receiverId: message.receiver_id, receiverId: message.receiver_id || '',
systemType: message.system_type, systemType: message.system_type || '',
extraData: JSON.stringify(message.extra_data || {}), extraData: JSON.stringify(message.extra_data || {}),
}, },
type, type,

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,8 @@
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { create } from 'zustand'; import { create } from 'zustand';
import { User } from '../types'; 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 { import {
initDatabase, initDatabase,
closeDatabase, closeDatabase,
@@ -91,12 +92,12 @@ function resolveLoginError(error: any): string {
return '登录失败,请稍后重试'; return '登录失败,请稍后重试';
} }
// ── 启动 WebSocket 服务 ── // ── 启动 SSE 实时服务 ──
async function startWebSocket(): Promise<void> { async function startRealtime(): Promise<void> {
try { try {
await websocketService.start(); await sseService.start();
} catch (error) { } catch (error) {
console.error('[AuthStore] 启动 WebSocket 服务失败:', error); console.error('[AuthStore] 启动 SSE 服务失败:', error);
} }
} }
@@ -153,8 +154,8 @@ export const useAuthStore = create<AuthState>((set) => ({
error: null, error: null,
}); });
// 5. 启动 WebSocket // 5. 启动 SSE
await startWebSocket(); await startRealtime();
return true; return true;
} catch (error: any) { } catch (error: any) {
@@ -191,7 +192,7 @@ export const useAuthStore = create<AuthState>((set) => ({
error: null, error: null,
}); });
await startWebSocket(); await startRealtime();
return true; return true;
} catch (error: any) { } catch (error: any) {
@@ -210,8 +211,8 @@ export const useAuthStore = create<AuthState>((set) => ({
try { try {
// 1. 通知服务端Token 清理在 authService 内部完成) // 1. 通知服务端Token 清理在 authService 内部完成)
await authService.logout(); await authService.logout();
// 2. 停止 WebSocket // 2. 停止 SSE
websocketService.stop(); sseService.stop();
// 3. 清除 DB 中的用户缓存DB 此时一定已初始化) // 3. 清除 DB 中的用户缓存DB 此时一定已初始化)
await clearCurrentUserCache().catch(() => {}); await clearCurrentUserCache().catch(() => {});
// 4. 关闭数据库连接 // 4. 关闭数据库连接
@@ -270,8 +271,8 @@ export const useAuthStore = create<AuthState>((set) => ({
isLoading: false, isLoading: false,
}); });
// 6. 启动 WebSocket // 6. 启动 SSE
await startWebSocket(); await startRealtime();
} else { } else {
// Token 已失效或不存在 // Token 已失效或不存在
await clearUserId(); await clearUserId();

View File

@@ -16,7 +16,7 @@
import { ConversationResponse, MessageResponse, MessageSegment, UserDTO } from '../types/dto'; import { ConversationResponse, MessageResponse, MessageSegment, UserDTO } from '../types/dto';
import { messageService } from '../services/messageService'; import { messageService } from '../services/messageService';
import { import {
websocketService, sseService,
WSChatMessage, WSChatMessage,
WSGroupChatMessage, WSGroupChatMessage,
WSReadMessage, WSReadMessage,
@@ -26,7 +26,7 @@ import {
WSGroupTypingMessage, WSGroupTypingMessage,
WSGroupNoticeMessage, WSGroupNoticeMessage,
GroupNoticeType, GroupNoticeType,
} from '../services/websocketService'; } from '../services/sseService';
import { import {
saveMessage, saveMessage,
saveMessagesBatch, saveMessagesBatch,
@@ -39,7 +39,7 @@ import {
CachedMessage, CachedMessage,
getUserCache, getUserCache,
saveUserCache, saveUserCache,
deleteMessage as deleteMessageFromDb, updateMessageStatus,
deleteConversation as deleteConversationFromDb, deleteConversation as deleteConversationFromDb,
} from '../services/database'; } from '../services/database';
import { api } from '../services/api'; import { api } from '../services/api';
@@ -326,47 +326,47 @@ class MessageManager {
// 监听私聊消息 // 监听私聊消息
websocketService.on('chat', (message: WSChatMessage) => { sseService.on('chat', (message: WSChatMessage) => {
this.handleNewMessage(message); this.handleNewMessage(message);
}); });
// 监听群聊消息 // 监听群聊消息
websocketService.on('group_message', (message: WSGroupChatMessage) => { sseService.on('group_message', (message: WSGroupChatMessage) => {
this.handleNewMessage(message); this.handleNewMessage(message);
}); });
// 监听私聊已读回执 // 监听私聊已读回执
websocketService.on('read', (message: WSReadMessage) => { sseService.on('read', (message: WSReadMessage) => {
this.handleReadReceipt(message); this.handleReadReceipt(message);
}); });
// 监听群聊已读回执 // 监听群聊已读回执
websocketService.on('group_read', (message: WSGroupReadMessage) => { sseService.on('group_read', (message: WSGroupReadMessage) => {
this.handleGroupReadReceipt(message); this.handleGroupReadReceipt(message);
}); });
// 监听私聊消息撤回 // 监听私聊消息撤回
websocketService.on('recall', (message: WSRecallMessage) => { sseService.on('recall', (message: WSRecallMessage) => {
this.handleRecallMessage(message); this.handleRecallMessage(message);
}); });
// 监听群聊消息撤回 // 监听群聊消息撤回
websocketService.on('group_recall', (message: WSGroupRecallMessage) => { sseService.on('group_recall', (message: WSGroupRecallMessage) => {
this.handleGroupRecallMessage(message); this.handleGroupRecallMessage(message);
}); });
// 监听群聊输入状态 // 监听群聊输入状态
websocketService.on('group_typing', (message: WSGroupTypingMessage) => { sseService.on('group_typing', (message: WSGroupTypingMessage) => {
this.handleGroupTyping(message); this.handleGroupTyping(message);
}); });
// 监听群通知 // 监听群通知
websocketService.on('group_notice', (message: WSGroupNoticeMessage) => { sseService.on('group_notice', (message: WSGroupNoticeMessage) => {
this.handleGroupNotice(message); this.handleGroupNotice(message);
}); });
// 监听连接状态 // 监听连接状态
websocketService.onConnect(() => { sseService.onConnect(() => {
this.state.isWebSocketConnected = true; this.state.isWebSocketConnected = true;
this.notifySubscribers({ this.notifySubscribers({
type: 'connection_changed', type: 'connection_changed',
@@ -389,7 +389,7 @@ class MessageManager {
} }
}); });
websocketService.onDisconnect(() => { sseService.onDisconnect(() => {
this.state.isWebSocketConnected = false; this.state.isWebSocketConnected = false;
this.notifySubscribers({ this.notifySubscribers({
type: 'connection_changed', type: 'connection_changed',
@@ -761,36 +761,93 @@ class MessageManager {
private handleGroupReadReceipt(message: WSGroupReadMessage): void { 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 { private handleRecallMessage(message: WSRecallMessage): void {
const { conversation_id, message_id } = message; const { conversation_id, message_id } = message;
const normalizedConversationId = this.normalizeConversationId(conversation_id);
// 从消息列表中移除被撤回的消息 this.markMessageAsRecalled(normalizedConversationId, message_id);
const messages = this.state.messagesMap.get(conversation_id); this.syncConversationLastMessageOnRecall(normalizedConversationId, message_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.notifySubscribers({ this.notifySubscribers({
type: 'message_recalled', type: 'message_recalled',
payload: { conversationId: conversation_id, messageId: message_id }, payload: { conversationId: normalizedConversationId, messageId: message_id },
timestamp: Date.now(), timestamp: Date.now(),
}); });
// 本地数据库删除 // 同步本地数据库状态,避免冷启动后撤回占位丢失
deleteMessageFromDb(message_id).catch(error => { updateMessageStatus(message_id, 'recalled', true).catch(error => {
console.error('[MessageManager] 删除本地消息失败:', error); console.error('[MessageManager] 更新本地消息撤回状态失败:', error);
}); });
} }
@@ -799,31 +856,21 @@ class MessageManager {
*/ */
private handleGroupRecallMessage(message: WSGroupRecallMessage): void { private handleGroupRecallMessage(message: WSGroupRecallMessage): void {
const { conversation_id, message_id } = message; const { conversation_id, message_id } = message;
const normalizedConversationId = this.normalizeConversationId(conversation_id);
// 从消息列表中移除被撤回的消息 this.markMessageAsRecalled(normalizedConversationId, message_id);
const messages = this.state.messagesMap.get(conversation_id); this.syncConversationLastMessageOnRecall(normalizedConversationId, message_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.notifySubscribers({ this.notifySubscribers({
type: 'message_recalled', type: 'message_recalled',
payload: { conversationId: conversation_id, messageId: message_id, isGroup: true }, payload: { conversationId: normalizedConversationId, messageId: message_id, isGroup: true },
timestamp: Date.now(), timestamp: Date.now(),
}); });
// 本地数据库删除 // 同步本地数据库状态,避免冷启动后撤回占位丢失
deleteMessageFromDb(message_id).catch(error => { updateMessageStatus(message_id, 'recalled', true).catch(error => {
console.error('[MessageManager] 删除本地消息失败:', error); console.error('[MessageManager] 更新本地消息撤回状态失败:', error);
}); });
} }

View File

@@ -62,6 +62,7 @@ export interface PostDTO {
title: string; title: string;
content: string; content: string;
images: PostImageDTO[]; images: PostImageDTO[];
status?: string;
likes_count: number; likes_count: number;
comments_count: number; comments_count: number;
favorites_count: number; favorites_count: number;
@@ -71,6 +72,7 @@ export interface PostDTO {
is_locked: boolean; is_locked: boolean;
is_vote: boolean; is_vote: boolean;
created_at: string; created_at: string;
updated_at?: string;
author: UserDTO | null; author: UserDTO | null;
is_liked: boolean; is_liked: boolean;
is_favorited: boolean; is_favorited: boolean;