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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
7
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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('已复制', '帖子链接已复制到剪贴板');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 删除帖子
|
// 删除帖子
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
463
src/services/sseService.ts
Normal 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();
|
||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user