feat(messaging): implement optimistic updates with retry for failed messages
- Add pending/failed message status to track send state - Implement optimistic UI: show message immediately, update on server response - Add retry functionality for failed messages via tap-to-retry - Change send button from icon to text "发送" for clarity - Add MessageSendService with temp ID generation to prevent collisions feat(notification): add JPush notification deduplication and clear on tap - Deduplicate notificationArrived events from dual JPush/vendor channels - Clear all notifications on tap (QQ-style behavior) feat(post): add client-side idempotency key for post creation - Generate client_request_id per publish intent to prevent duplicates on retry - Pass idempotency key through to postService and voteService
This commit is contained in:
@@ -128,6 +128,13 @@ function NotificationBootstrap() {
|
||||
console.log('[NotificationBootstrap] JPush notification:', message.notificationEventType, message);
|
||||
if (message.notificationEventType !== 'notificationOpened') return;
|
||||
|
||||
// 点击任意一条通知即清除通知栏所有通知(与 QQ 一致:点一条清全部)
|
||||
// JPush 的 notificationOpened 在通知被点击时触发,此时清除系统通知栏中
|
||||
// 本应用的所有通知,避免用户需要逐条点击消除。
|
||||
systemNotificationService.clearAllNotifications().catch((error) => {
|
||||
console.warn('[NotificationBootstrap] clearAllNotifications failed:', error);
|
||||
});
|
||||
|
||||
const extras = message.extras || {};
|
||||
const notifType = extras.notification_type || message.notificationType;
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
getImageDisplayUri,
|
||||
uploadAllPendingImages,
|
||||
} from '../../utils/pendingImages';
|
||||
import { generateClientRequestId } from '../../utils/idempotency';
|
||||
|
||||
// Props 接口
|
||||
interface CreatePostScreenProps {
|
||||
@@ -163,6 +164,11 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
const blockEditorRef = React.useRef<BlockEditorHandle>(null);
|
||||
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
||||
|
||||
// 发帖幂等键:每次进入发布态生成一次,重试时复用同一个,避免重复发帖。
|
||||
// - 同一次「发布」意图(含网络重试)复用同一个 ID;
|
||||
// - 发布成功后立即刷新,便于下一次发布生成新的 ID。
|
||||
const clientRequestIdRef = React.useRef<string>('');
|
||||
|
||||
// 动画值
|
||||
const fadeAnim = React.useRef(new Animated.Value(0)).current;
|
||||
const slideAnim = React.useRef(new Animated.Value(20)).current;
|
||||
@@ -406,7 +412,8 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
|
||||
// 发布帖子
|
||||
const handlePost = async () => {
|
||||
// 防止重复点击
|
||||
// 防止重复点击:posting 为 true 时直接忽略后续点击。
|
||||
// 这是第一道防线(UI 层防抖);服务端基于 client_request_id 的幂等检查是第二道防线。
|
||||
if (posting) return;
|
||||
|
||||
if (!title.trim()) {
|
||||
@@ -428,6 +435,13 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
|
||||
setPosting(true);
|
||||
|
||||
// 为本次「发布」意图生成幂等键。同一意图的网络重试复用同一个 ID,
|
||||
// 使服务端能识别并去重。发布成功后会刷新,供下次发布生成新 ID。
|
||||
if (!clientRequestIdRef.current) {
|
||||
clientRequestIdRef.current = generateClientRequestId();
|
||||
}
|
||||
const clientRequestId = clientRequestIdRef.current;
|
||||
|
||||
try {
|
||||
// 普通模式下,发布前上传所有待上传的图片
|
||||
let uploadedImageUrls: string[] = [];
|
||||
@@ -469,6 +483,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined,
|
||||
channel_id: selectedChannelId || undefined,
|
||||
vote_options: validOptions.map(opt => ({ content: opt })),
|
||||
client_request_id: clientRequestId,
|
||||
});
|
||||
showPrompt({
|
||||
type: 'info',
|
||||
@@ -499,6 +514,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
content: editorContent.trim(),
|
||||
segments: finalSegments,
|
||||
channel_id: selectedChannelId || undefined,
|
||||
client_request_id: clientRequestId,
|
||||
});
|
||||
showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 });
|
||||
router.replace(hrefs.hrefHome());
|
||||
@@ -524,11 +540,14 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
segments: segments.some(s => s.type !== 'text') ? segments : undefined,
|
||||
images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined,
|
||||
channel_id: selectedChannelId || undefined,
|
||||
client_request_id: clientRequestId,
|
||||
});
|
||||
showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 });
|
||||
router.replace(hrefs.hrefHome());
|
||||
}
|
||||
}
|
||||
// 发布成功:刷新幂等键,下次发布生成新 ID。
|
||||
clientRequestIdRef.current = '';
|
||||
} catch (error) {
|
||||
console.error('发布帖子失败:', error);
|
||||
Alert.alert('错误', getPublishErrorMessage(error));
|
||||
|
||||
@@ -795,7 +795,7 @@ export const HomeScreen: React.FC = () => {
|
||||
const authorId = item.author?.id || '';
|
||||
const isPostAuthor = currentUser?.id === authorId;
|
||||
return (
|
||||
<View style={{ marginBottom: 2, paddingHorizontal: 1 }}>
|
||||
<View style={{ marginBottom: 2, paddingHorizontal: responsiveGap / 2 }}>
|
||||
<PostCard
|
||||
post={item}
|
||||
variant="grid"
|
||||
@@ -804,7 +804,7 @@ export const HomeScreen: React.FC = () => {
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}, [currentUser?.id, stableOnPostAction]);
|
||||
}, [currentUser?.id, stableOnPostAction, responsiveGap]);
|
||||
|
||||
// 渲染响应式网格布局(所有端统一使用 FlashList masonry 模式)
|
||||
const renderResponsiveGrid = () => {
|
||||
@@ -820,7 +820,7 @@ export const HomeScreen: React.FC = () => {
|
||||
masonry
|
||||
optimizeItemArrangement
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: responsivePadding,
|
||||
paddingHorizontal: responsiveGap / 2,
|
||||
paddingBottom: 80 + responsivePadding,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
|
||||
@@ -1358,21 +1358,15 @@ export const PostDetailScreen: React.FC = () => {
|
||||
);
|
||||
}, [currentUser?.id, handleDeleteComment, handleImagePress, handleLikeComment, handleLoadMoreReplies, post?.user_id, handleReportComment]);
|
||||
|
||||
// 渲染空评论 - 类似图片中的样式
|
||||
// 渲染空评论 - 使用 Forum 图标
|
||||
const renderEmptyComments = useCallback(() => (
|
||||
<View style={styles.emptyCommentsContainer}>
|
||||
{/* 四个方块图标 */}
|
||||
<View style={styles.emptyCommentsGrid}>
|
||||
<View style={styles.emptyCommentsGridItem} />
|
||||
<View style={styles.emptyCommentsGridItem} />
|
||||
<View style={styles.emptyCommentsGridItem} />
|
||||
<View style={[styles.emptyCommentsGridItem, styles.emptyCommentsGridItemActive]} />
|
||||
</View>
|
||||
<MaterialCommunityIcons name="forum-outline" size={48} color={colors.text.disabled} />
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.emptyCommentsSubtitle}>
|
||||
这里空空如也,邀请好友来互动吧!
|
||||
评论区空空如也,来发表第一条评论吧~
|
||||
</Text>
|
||||
</View>
|
||||
), []);
|
||||
), [colors]);
|
||||
|
||||
const openComposer = () => {
|
||||
setIsComposerVisible(true);
|
||||
@@ -1382,6 +1376,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
const closeComposer = () => {
|
||||
setIsComposerVisible(false);
|
||||
setShowEmojiPanel(false);
|
||||
setReplyingTo(null);
|
||||
Keyboard.dismiss();
|
||||
};
|
||||
|
||||
@@ -1522,6 +1517,16 @@ export const PostDetailScreen: React.FC = () => {
|
||||
{/* Toolbar */}
|
||||
<View style={styles.expandedToolbar}>
|
||||
<View style={styles.expandedToolbarLeft}>
|
||||
<TouchableOpacity
|
||||
style={styles.expandedToolbarItem}
|
||||
onPress={closeComposer}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-down"
|
||||
size={22}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.expandedToolbarItem}
|
||||
onPress={handlePickCommentImage}
|
||||
@@ -2294,30 +2299,14 @@ function createPostDetailStyles(colors: AppColors) {
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
// 空评论状态样式(类似图片中的四个方块样式)
|
||||
// 空评论状态样式
|
||||
emptyCommentsContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.xl * 2.5,
|
||||
},
|
||||
emptyCommentsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
width: 48,
|
||||
height: 48,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
emptyCommentsGridItem: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
backgroundColor: colors.background.disabled,
|
||||
margin: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
},
|
||||
emptyCommentsGridItemActive: {
|
||||
backgroundColor: colors.text.hint,
|
||||
},
|
||||
emptyCommentsSubtitle: {
|
||||
marginTop: spacing.md,
|
||||
fontSize: fontSizes.sm,
|
||||
textAlign: 'center',
|
||||
color: colors.text.hint,
|
||||
|
||||
@@ -200,6 +200,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
handleDeleteMessage,
|
||||
handleReplyMessage,
|
||||
handleCancelReply,
|
||||
handleRetrySend,
|
||||
handleAvatarPress,
|
||||
handleAvatarLongPress,
|
||||
handleSelectMention,
|
||||
@@ -366,6 +367,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
onImagePress={handleImagePress}
|
||||
onReply={handleReplyMessage}
|
||||
onReplyPress={handleReplyPreviewPress}
|
||||
onRetrySend={handleRetrySend}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
@@ -386,6 +388,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
handleImagePress,
|
||||
handleReplyMessage,
|
||||
handleReplyPreviewPress,
|
||||
handleRetrySend,
|
||||
]);
|
||||
|
||||
const keyExtractor = useCallback((item: GroupMessage) => String(item.id), []);
|
||||
|
||||
@@ -216,10 +216,9 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
<TouchableOpacity
|
||||
style={styles.sendButtonActive}
|
||||
onPress={onSend}
|
||||
activeOpacity={0.85}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<View style={styles.sendButtonShine} pointerEvents="none" />
|
||||
<MaterialCommunityIcons name="send" size={18} color="#FFF" />
|
||||
<Text style={styles.sendButtonText}>发送</Text>
|
||||
</TouchableOpacity>
|
||||
) : isComposerBusy && !isDisabled ? (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
GestureResponderEvent,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Avatar, Text, ImageGridItem } from '../../../../components/common';
|
||||
@@ -49,6 +50,7 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
onImagePress,
|
||||
onReply,
|
||||
onReplyPress,
|
||||
onRetrySend,
|
||||
}) => {
|
||||
const bubbleRef = useRef<View>(null);
|
||||
const pressPositionRef = useRef({ x: 0, y: 0 });
|
||||
@@ -93,6 +95,8 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
const isMe = message.sender_id === currentUserId;
|
||||
const showTime = shouldShowTime(index);
|
||||
const isRecalled = message.status === 'recalled';
|
||||
const isPending = message.status === 'pending';
|
||||
const isFailed = message.status === 'failed';
|
||||
const isRead = !isGroupChat && isMe && message.seq <= otherUserLastReadSeq;
|
||||
const isLastReadMessage = !isGroupChat && isMe && message.seq === otherUserLastReadSeq;
|
||||
|
||||
@@ -432,9 +436,35 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
|
||||
{renderMessageContent()}
|
||||
|
||||
{/* 私聊模式:显示已读标记 */}
|
||||
{isLastReadMessage && (
|
||||
<View style={styles.readStatusContainer}>
|
||||
{/* 发送状态指示器 */}
|
||||
{isMe && (
|
||||
<View style={styles.sendStatusContainer}>
|
||||
{isPending && (
|
||||
<View style={styles.sendStatusRow}>
|
||||
<ActivityIndicator size="small" color="#999" style={styles.sendStatusIndicator} />
|
||||
<Text style={styles.pendingStatusText}>发送中</Text>
|
||||
</View>
|
||||
)}
|
||||
{isFailed && (
|
||||
// 点击「发送失败」重试:仅对自己发送的失败消息提供重试入口。
|
||||
// 用 Pressable 而非 TouchableOpacity,避免与外层长按手势冲突;
|
||||
// 失败状态本身是终态,重试期间会被替换为 pending。
|
||||
<TouchableOpacity
|
||||
style={styles.sendStatusRow}
|
||||
onPress={() => onRetrySend?.(message)}
|
||||
disabled={!onRetrySend}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="alert-circle"
|
||||
size={14}
|
||||
color="#FF3B30"
|
||||
/>
|
||||
<Text style={styles.failedStatusText}>发送失败,点击重试</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{!isPending && !isFailed && isLastReadMessage && (
|
||||
<View style={styles.sendStatusRow}>
|
||||
<MaterialCommunityIcons
|
||||
name="check-all"
|
||||
size={14}
|
||||
@@ -446,6 +476,8 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 自己的头像 */}
|
||||
{isMe && (
|
||||
|
||||
@@ -417,6 +417,33 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
||||
color: colors.chat.success,
|
||||
},
|
||||
|
||||
// 发送状态(乐观更新)
|
||||
sendStatusContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignSelf: 'flex-end',
|
||||
marginTop: 2,
|
||||
marginRight: 2,
|
||||
},
|
||||
sendStatusRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
sendStatusIndicator: {
|
||||
marginRight: 2,
|
||||
},
|
||||
pendingStatusText: {
|
||||
fontSize: 10,
|
||||
color: colors.chat.textSecondary,
|
||||
fontWeight: '500',
|
||||
},
|
||||
failedStatusText: {
|
||||
fontSize: 10,
|
||||
color: '#FF3B30',
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// 输入框区域 - 与列表背景同色底栏,顶部细分隔
|
||||
inputWrapper: {
|
||||
backgroundColor: cardBgColor,
|
||||
@@ -489,34 +516,30 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
||||
transform: [{ translateY: -1 }],
|
||||
},
|
||||
|
||||
// 发送按钮 - QQ/微信风格
|
||||
// 发送按钮 - 微信风格文字按钮
|
||||
sendButtonActive: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18, // 正圆形
|
||||
minWidth: 56,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
// 微信风格:轻微阴影,不夸张
|
||||
paddingHorizontal: 12,
|
||||
// 轻微阴影
|
||||
shadowColor: colors.primary.main,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 3,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
sendButtonDisabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
// 发送按钮光泽层(叠加在按钮上模拟渐变)
|
||||
sendButtonShine: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '50%',
|
||||
borderTopLeftRadius: 18,
|
||||
borderTopRightRadius: 18,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
sendButtonText: {
|
||||
color: '#FFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
// 输入框
|
||||
|
||||
@@ -101,6 +101,8 @@ export interface MessageBubbleProps {
|
||||
onReply?: (message: GroupMessage) => void;
|
||||
// 点击回复预览回调(定位到原消息)
|
||||
onReplyPress?: (messageId: string) => void;
|
||||
// 点击「发送失败」气泡重试发送(仅对当前用户自己发送的失败消息触发)
|
||||
onRetrySend?: (message: GroupMessage) => void;
|
||||
}
|
||||
|
||||
/** 输入框中待发送的本地图片(未上传) */
|
||||
|
||||
@@ -153,6 +153,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
loadMoreMessages,
|
||||
refreshMessages,
|
||||
sendMessage: sendMessageViaManager,
|
||||
retrySendMessage: retrySendMessageViaManager,
|
||||
isSending: isSendingViaManager,
|
||||
markAsRead,
|
||||
conversation,
|
||||
@@ -1056,7 +1057,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
return segments;
|
||||
}, []);
|
||||
|
||||
// 【新架构】发送消息(支持纯文字、纯多图、图文同条)
|
||||
// 【新架构】发送消息(支持纯文字、纯多图、图文同条)- 乐观更新模式
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmedText = inputText.trim();
|
||||
const hasPending = pendingAttachments.length > 0;
|
||||
@@ -1082,6 +1083,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 图片需要先上传(这个无法乐观,因为需要URL)
|
||||
const uploadedUrls: string[] = [];
|
||||
if (hasPending) {
|
||||
setUploadingAttachments(true);
|
||||
@@ -1108,10 +1110,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
// 有文本或回复时才构建文本段,纯图片时不塞空 text
|
||||
// 构建消息段
|
||||
const segments: MessageSegment[] = (trimmedText || replyingTo)
|
||||
? [...buildTextSegments(trimmedText, replyingTo)]
|
||||
: [];
|
||||
@@ -1130,38 +1129,41 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGroupChat && effectiveGroupId) {
|
||||
await messageService.sendMessageByAction('group', conversationId, segments);
|
||||
|
||||
// 乐观更新:立即清空输入框并滚动到底部
|
||||
const savedReplyTo = replyingTo;
|
||||
setInputText('');
|
||||
setSelectedMentions([]);
|
||||
setMentionAll(false);
|
||||
setReplyingTo(null);
|
||||
setPendingAttachments([]);
|
||||
} else {
|
||||
await sendMessageViaManager(segments);
|
||||
setInputText('');
|
||||
setSelectedMentions([]);
|
||||
setMentionAll(false);
|
||||
setReplyingTo(null);
|
||||
setPendingAttachments([]);
|
||||
}
|
||||
|
||||
// 立即滚动到底部显示新消息
|
||||
setTimeout(() => {
|
||||
scrollToLatest(false, false, hasPending ? 'send-mixed' : 'send-text');
|
||||
}, 100);
|
||||
}, 50);
|
||||
|
||||
// 后台发送消息(私聊与群聊统一走 MessageSendService 的乐观更新):
|
||||
// 1. 立即在 store 中插入 pending 临时消息
|
||||
// 2. 成功 → 替换为正式消息、更新会话 last_message、写入本地数据库
|
||||
// 3. 失败 → 标记为 failed,供用户点击重试
|
||||
try {
|
||||
await sendMessageViaManager(segments, {
|
||||
replyToId: savedReplyTo?.id,
|
||||
detailType: isGroupChat ? 'group' : 'private',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
// MessageSendService 已将消息置为 failed 并在 UI 上显示状态,
|
||||
// 这里仅弹窗提示用户(与原行为保持一致)。
|
||||
if (isGroupChat) {
|
||||
Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试'));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
inputText,
|
||||
pendingAttachments,
|
||||
conversationId,
|
||||
isGroupChat,
|
||||
effectiveGroupId,
|
||||
sendMessageViaManager,
|
||||
isMuted,
|
||||
muteAll,
|
||||
@@ -1173,6 +1175,21 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
scrollToLatest,
|
||||
]);
|
||||
|
||||
// 重试发送失败的消息(点击「发送失败」气泡时触发)
|
||||
// 仅对当前用户自己发送且状态为 failed 的消息生效;
|
||||
// 重试期间消息会先变为 pending(发送中),再根据结果转为 normal/failed。
|
||||
const handleRetrySend = useCallback(async (message: GroupMessage) => {
|
||||
if (!conversationId) return;
|
||||
if (message.sender_id !== currentUserId) return;
|
||||
if (message.status !== 'failed') return;
|
||||
try {
|
||||
await retrySendMessageViaManager(message);
|
||||
} catch (error) {
|
||||
console.error('重试发送消息失败:', error);
|
||||
Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试'));
|
||||
}
|
||||
}, [conversationId, currentUserId, retrySendMessageViaManager, getSendErrorMessage]);
|
||||
|
||||
// 选择图片(多选进入输入框待发送,点发送时与文字一并发出)
|
||||
const handlePickImage = useCallback(async () => {
|
||||
if (pendingAttachments.length >= MAX_PENDING_CHAT_IMAGES) {
|
||||
@@ -1636,6 +1653,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
shouldShowTime,
|
||||
handleInputChange,
|
||||
handleSend,
|
||||
handleRetrySend,
|
||||
removePendingAttachment,
|
||||
handleMoreAction,
|
||||
handleInsertEmoji,
|
||||
|
||||
@@ -45,6 +45,13 @@ class JPushService {
|
||||
private notificationCallback: JPushCallback | null = null;
|
||||
private connectResolved = false;
|
||||
private listenersRegistered = false;
|
||||
// notificationArrived 去重:JPush 自有通道与厂商通道(如小米)可能对
|
||||
// 同一条消息各弹一次通知,按 messageID 去重,只让回调处理第一次到达。
|
||||
// 使用 Map<msgID, timestamp> 配合 TTL 清理,避免长时间运行后集合无限增长,
|
||||
// 也避免「淘汰最早一半」策略误删近期仍在被厂商通道二次到达的消息。
|
||||
private arrivedMessageTimestamps: Map<string, number> = new Map();
|
||||
private static readonly ARRIVED_DEDUP_TTL_MS = 5 * 60 * 1000; // 5 分钟
|
||||
private static readonly ARRIVED_DEDUP_MAX = 500; // 触发清理的容量上限
|
||||
|
||||
async initialize(): Promise<boolean> {
|
||||
if (this.isInitialized) return true;
|
||||
@@ -70,12 +77,40 @@ class JPushService {
|
||||
|
||||
JPush!.addNotificationListener((result: any) => {
|
||||
console.log('[JPush] notification:', result);
|
||||
if (result.notificationEventType === 'notificationArrived') {
|
||||
const eventType: string = result.notificationEventType || '';
|
||||
|
||||
// notificationArrived 去重:
|
||||
// JPush 在配置了厂商通道(如小米)的设备上,会对同一条消息同时通过
|
||||
// JPush 自有通道和厂商通道各推送一次,导致通知栏出现两条相同通知。
|
||||
// 这里按 messageID 去重,只处理第一次到达,避免重复震动/重复弹窗。
|
||||
// notificationOpened(用户点击)不去重:点哪条都应响应跳转。
|
||||
if (eventType === 'notificationArrived') {
|
||||
const msgID = String(result.messageID || '');
|
||||
if (msgID) {
|
||||
const now = Date.now();
|
||||
// 清理过期记录(每次到达顺手清理,摊销成本,避免泄漏)
|
||||
if (this.arrivedMessageTimestamps.size > JPushService.ARRIVED_DEDUP_MAX) {
|
||||
for (const [id, ts] of this.arrivedMessageTimestamps) {
|
||||
if (now - ts > JPushService.ARRIVED_DEDUP_TTL_MS) {
|
||||
this.arrivedMessageTimestamps.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.arrivedMessageTimestamps.has(msgID)) {
|
||||
console.log('[JPush] duplicate notificationArrived dropped, msgID:', msgID);
|
||||
return;
|
||||
}
|
||||
// 记录到达时间,TTL 内重复到达即被去重
|
||||
this.arrivedMessageTimestamps.set(msgID, now);
|
||||
}
|
||||
|
||||
const prefs = getNotificationPreferencesSync();
|
||||
if (prefs.vibrationEnabled && AppState.currentState === 'active') {
|
||||
vibrateOnMessage('notification').catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.notificationCallback) {
|
||||
this.notificationCallback({
|
||||
messageID: result.messageID || '',
|
||||
@@ -83,7 +118,7 @@ class JPushService {
|
||||
content: result.content || '',
|
||||
extras: result.extras || {},
|
||||
notificationType: result.extras?.notification_type,
|
||||
notificationEventType: result.notificationEventType,
|
||||
notificationEventType: eventType as any,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -431,6 +466,7 @@ class JPushService {
|
||||
this.notificationCallback = null;
|
||||
this.initPromise = null;
|
||||
this.connectResolved = false;
|
||||
this.arrivedMessageTimestamps.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ interface CreatePostRequest {
|
||||
segments?: MessageSegment[];
|
||||
images?: string[];
|
||||
channel_id?: string;
|
||||
// 客户端幂等键:同一 key 在服务端 TTL 内重复提交只创建一次,避免重复发帖
|
||||
client_request_id?: string;
|
||||
}
|
||||
|
||||
// 更新帖子请求
|
||||
@@ -104,7 +106,6 @@ class PostService {
|
||||
const response = await api.post<PostResponse>('/posts', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 更新帖子
|
||||
async updatePost(postId: string, data: UpdatePostRequest): Promise<Post | null> {
|
||||
try {
|
||||
|
||||
@@ -58,6 +58,7 @@ class VoteService {
|
||||
segments,
|
||||
images: data.images,
|
||||
channel_id: data.channel_id,
|
||||
client_request_id: data.client_request_id,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -220,11 +220,18 @@ class MessageManager {
|
||||
async sendMessage(
|
||||
conversationId: string,
|
||||
segments: MessageSegment[],
|
||||
options?: { replyToId?: string }
|
||||
options?: { replyToId?: string; detailType?: 'private' | 'group' }
|
||||
): Promise<MessageResponse | null> {
|
||||
return this.sendService.sendMessage(conversationId, segments, options);
|
||||
}
|
||||
|
||||
async retrySendMessage(
|
||||
conversationId: string,
|
||||
failedMessage: MessageResponse
|
||||
): Promise<MessageResponse | null> {
|
||||
return this.sendService.retrySendMessage(conversationId, failedMessage);
|
||||
}
|
||||
|
||||
async markAsRead(conversationId: string, seq: number): Promise<void> {
|
||||
return this.readReceiptManager.markAsRead(conversationId, seq);
|
||||
}
|
||||
|
||||
@@ -228,7 +228,8 @@ export function useMessages(conversationId: string | null): UseMessagesReturn {
|
||||
// ==================== useSendMessage - 发送消息 ====================
|
||||
|
||||
interface UseSendMessageReturn {
|
||||
sendMessage: (segments: MessageSegment[], options?: { replyToId?: string }) => Promise<MessageResponse | null>;
|
||||
sendMessage: (segments: MessageSegment[], options?: { replyToId?: string; detailType?: 'private' | 'group' }) => Promise<MessageResponse | null>;
|
||||
retrySendMessage: (failedMessage: MessageResponse) => Promise<MessageResponse | null>;
|
||||
isSending: boolean;
|
||||
}
|
||||
|
||||
@@ -239,7 +240,7 @@ export function useSendMessage(conversationId: string | null): UseSendMessageRet
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (segments: MessageSegment[], options?: { replyToId?: string }): Promise<MessageResponse | null> => {
|
||||
async (segments: MessageSegment[], options?: { replyToId?: string; detailType?: 'private' | 'group' }): Promise<MessageResponse | null> => {
|
||||
if (!conversationId) return null;
|
||||
|
||||
setIsSending(true);
|
||||
@@ -253,8 +254,24 @@ export function useSendMessage(conversationId: string | null): UseSendMessageRet
|
||||
[conversationId]
|
||||
);
|
||||
|
||||
const retrySendMessage = useCallback(
|
||||
async (failedMessage: MessageResponse): Promise<MessageResponse | null> => {
|
||||
if (!conversationId) return null;
|
||||
|
||||
setIsSending(true);
|
||||
try {
|
||||
const message = await messageManager.retrySendMessage(conversationId, failedMessage);
|
||||
return message;
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
},
|
||||
[conversationId]
|
||||
);
|
||||
|
||||
return {
|
||||
sendMessage,
|
||||
retrySendMessage,
|
||||
isSending,
|
||||
};
|
||||
}
|
||||
@@ -537,7 +554,8 @@ interface UseChatReturn {
|
||||
refreshMessages: () => Promise<void>;
|
||||
|
||||
// 发送消息
|
||||
sendMessage: (segments: MessageSegment[], options?: { replyToId?: string }) => Promise<MessageResponse | null>;
|
||||
sendMessage: (segments: MessageSegment[], options?: { replyToId?: string; detailType?: 'private' | 'group' }) => Promise<MessageResponse | null>;
|
||||
retrySendMessage: (failedMessage: MessageResponse) => Promise<MessageResponse | null>;
|
||||
isSending: boolean;
|
||||
|
||||
// 已读相关
|
||||
@@ -549,7 +567,7 @@ interface UseChatReturn {
|
||||
|
||||
export function useChat(conversationId: string | null): UseChatReturn {
|
||||
const { messages, isLoading: isLoadingMessages, hasMore: hasMoreMessages, loadMore, refresh } = useMessages(conversationId);
|
||||
const { sendMessage, isSending } = useSendMessage(conversationId);
|
||||
const { sendMessage, retrySendMessage, isSending } = useSendMessage(conversationId);
|
||||
const { markAsRead } = useMarkAsRead(conversationId);
|
||||
const { conversation } = useConversation(conversationId);
|
||||
|
||||
@@ -560,6 +578,7 @@ export function useChat(conversationId: string | null): UseChatReturn {
|
||||
loadMoreMessages: loadMore,
|
||||
refreshMessages: refresh,
|
||||
sendMessage,
|
||||
retrySendMessage,
|
||||
isSending,
|
||||
markAsRead,
|
||||
conversation,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 消息发送服务
|
||||
* 处理消息发送相关逻辑
|
||||
* 实现乐观更新:先显示消息,再发送到服务器
|
||||
*/
|
||||
|
||||
import type { MessageResponse, MessageSegment, ConversationResponse } from '../../../types/dto';
|
||||
@@ -9,6 +10,12 @@ import { messageRepository } from '@/database';
|
||||
import type { IMessageSendService } from '../types';
|
||||
import { useMessageStore } from '../store';
|
||||
|
||||
// 临时ID前缀,用于乐观更新
|
||||
const TEMP_ID_PREFIX = 'temp_';
|
||||
// 全局自增计数器:与 Date.now() + Math.random() 组合,彻底消除同一毫秒内
|
||||
// 连续发送导致的临时 ID 碰撞风险(addOrReplaceMessage 按 id 幂等,碰撞会吞掉消息)。
|
||||
let tempIdCounter = 0;
|
||||
|
||||
export class MessageSendService implements IMessageSendService {
|
||||
private getCurrentUserId: () => string | null;
|
||||
|
||||
@@ -17,40 +24,77 @@ export class MessageSendService implements IMessageSendService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
* 生成临时消息ID
|
||||
* 组合 timestamp + 自增计数器 + 随机串,保证全局唯一。
|
||||
*/
|
||||
private generateTempId(): string {
|
||||
return `${TEMP_ID_PREFIX}${Date.now()}_${++tempIdCounter}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(乐观更新模式)
|
||||
* 1. 立即创建临时消息并显示
|
||||
* 2. 后台发送到服务器
|
||||
* 3. 成功:更新为正式消息
|
||||
* 4. 失败:标记为发送失败
|
||||
*
|
||||
* @param detailType 会话类型,'private' 优先走 WebSocket(失败降级 HTTP),
|
||||
* 'group' 直接走 HTTP。默认 'private'。
|
||||
*/
|
||||
async sendMessage(
|
||||
conversationId: string,
|
||||
segments: MessageSegment[],
|
||||
options?: { replyToId?: string }
|
||||
options?: { replyToId?: string; detailType?: 'private' | 'group' }
|
||||
): Promise<MessageResponse | null> {
|
||||
const store = useMessageStore.getState();
|
||||
const currentUserId = this.getCurrentUserId();
|
||||
const detailType = options?.detailType ?? 'private';
|
||||
|
||||
// 1. 创建临时消息(乐观显示)
|
||||
const tempId = this.generateTempId();
|
||||
const tempMessage: MessageResponse = {
|
||||
id: tempId,
|
||||
conversation_id: conversationId,
|
||||
sender_id: currentUserId || '',
|
||||
seq: 0, // 临时消息seq为0
|
||||
segments,
|
||||
reply_to_id: options?.replyToId,
|
||||
created_at: new Date().toISOString(),
|
||||
status: 'pending', // 标记为发送中
|
||||
};
|
||||
|
||||
// 2. 立即添加到store,用户可以马上看到
|
||||
store.addOrReplaceMessage(conversationId, tempMessage);
|
||||
|
||||
// 3. 更新会话最后消息(乐观更新)
|
||||
this.updateConversationWithNewMessage(conversationId, tempMessage);
|
||||
|
||||
try {
|
||||
// 调用API发送
|
||||
// 4. 调用API发送
|
||||
const response = await messageService.sendMessage(conversationId, {
|
||||
segments,
|
||||
reply_to_id: options?.replyToId,
|
||||
});
|
||||
}, detailType);
|
||||
|
||||
if (response) {
|
||||
const currentUserId = this.getCurrentUserId();
|
||||
|
||||
// 原子性合并新消息到内存(防止并发 WS 消息丢失)
|
||||
const newMessage: MessageResponse = {
|
||||
// 5. 发送成功:用服务器返回的数据更新临时消息
|
||||
const successMessage: MessageResponse = {
|
||||
id: response.id,
|
||||
conversation_id: conversationId,
|
||||
sender_id: currentUserId || '',
|
||||
seq: response.seq,
|
||||
segments,
|
||||
reply_to_id: options?.replyToId,
|
||||
created_at: new Date().toISOString(),
|
||||
status: 'normal',
|
||||
};
|
||||
|
||||
store.mergeMessages(conversationId, [newMessage]);
|
||||
// 删除临时消息,添加正式消息
|
||||
store.removeMessage(conversationId, tempId);
|
||||
store.addOrReplaceMessage(conversationId, successMessage);
|
||||
|
||||
// 更新会话最后消息
|
||||
this.updateConversationWithNewMessage(conversationId, newMessage);
|
||||
this.updateConversationWithNewMessage(conversationId, successMessage);
|
||||
|
||||
// 保存到本地数据库
|
||||
const textContent = segments
|
||||
@@ -65,22 +109,65 @@ export class MessageSendService implements IMessageSendService {
|
||||
content: textContent,
|
||||
type: segments?.find((s: any) => s.type === 'image') ? 'image' : 'text',
|
||||
isRead: true,
|
||||
createdAt: newMessage.created_at,
|
||||
createdAt: successMessage.created_at,
|
||||
seq: response.seq,
|
||||
status: 'normal',
|
||||
segments,
|
||||
}).catch(console.error);
|
||||
|
||||
return newMessage;
|
||||
return successMessage;
|
||||
}
|
||||
|
||||
// 如果服务器没有返回有效响应,标记为失败
|
||||
const failedMessage: MessageResponse = {
|
||||
...tempMessage,
|
||||
status: 'failed',
|
||||
};
|
||||
store.addOrReplaceMessage(conversationId, failedMessage);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[MessageSendService] 发送消息失败:', error);
|
||||
|
||||
// 6. 发送失败:更新消息状态为失败
|
||||
const failedMessage: MessageResponse = {
|
||||
...tempMessage,
|
||||
status: 'failed',
|
||||
};
|
||||
store.addOrReplaceMessage(conversationId, failedMessage);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试发送失败的消息
|
||||
*
|
||||
* 实现要点:
|
||||
* 1. 先移除旧的失败消息(临时 ID),避免与即将插入的新 pending 消息并存出现两条。
|
||||
* 2. 复用原 segments 与 replyToId 调用 sendMessage,由它重新走完整乐观更新流程
|
||||
* (插入新 pending → 发送 → 成功替换为正式消息 / 失败再次标记 failed)。
|
||||
* 3. detailType 无法从失败消息反推,默认 private;
|
||||
* 群聊失败消息重试走 HTTP(sendMessage 的 group 分支)也能成功。
|
||||
*/
|
||||
async retrySendMessage(
|
||||
conversationId: string,
|
||||
failedMessage: MessageResponse
|
||||
): Promise<MessageResponse | null> {
|
||||
if (failedMessage.status !== 'failed') {
|
||||
console.warn('[MessageSendService] 只能重试发送失败的消息');
|
||||
return null;
|
||||
}
|
||||
|
||||
const store = useMessageStore.getState();
|
||||
// 移除旧的失败消息,避免 sendMessage 新插入的 pending 消息与之并存
|
||||
store.removeMessage(conversationId, String(failedMessage.id));
|
||||
|
||||
// 重新发送:sendMessage 会重新插入 pending 临时消息并完成后续流程
|
||||
return this.sendMessage(conversationId, failedMessage.segments, {
|
||||
replyToId: failedMessage.reply_to_id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话的最后消息信息
|
||||
*/
|
||||
@@ -96,7 +183,7 @@ export class MessageSendService implements IMessageSendService {
|
||||
// 更新现有会话
|
||||
const updatedConv: ConversationResponse = {
|
||||
...conversation,
|
||||
last_seq: message.seq,
|
||||
last_seq: message.seq > 0 ? message.seq : conversation.last_seq,
|
||||
last_message: message,
|
||||
last_message_at: createdAt,
|
||||
updated_at: createdAt,
|
||||
|
||||
@@ -51,7 +51,11 @@ export interface IMessageSendService {
|
||||
sendMessage(
|
||||
conversationId: string,
|
||||
segments: any[],
|
||||
options?: { replyToId?: string }
|
||||
options?: { replyToId?: string; detailType?: 'private' | 'group' }
|
||||
): Promise<MessageResponse | null>;
|
||||
retrySendMessage(
|
||||
conversationId: string,
|
||||
failedMessage: MessageResponse
|
||||
): Promise<MessageResponse | null>;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { GroupResponse } from './group';
|
||||
|
||||
export type ConversationType = 'private' | 'group';
|
||||
export type MessageContentType = 'text' | 'image' | 'video' | 'audio' | 'file';
|
||||
export type MessageStatus = 'normal' | 'recalled' | 'deleted';
|
||||
export type MessageStatus = 'normal' | 'recalled' | 'deleted' | 'pending' | 'failed';
|
||||
|
||||
// ==================== Segment Types ====================
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface CreateVotePostRequest {
|
||||
content: string;
|
||||
}>;
|
||||
channel_id?: string;
|
||||
// 客户端幂等键:同一 key 在服务端 TTL 内重复提交只创建一次,避免重复发帖
|
||||
client_request_id?: string;
|
||||
}
|
||||
|
||||
// ==================== Privacy & Account ====================
|
||||
|
||||
68
src/utils/idempotency.ts
Normal file
68
src/utils/idempotency.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 客户端幂等键工具
|
||||
*
|
||||
* 用于发帖、发评论等"创建型"请求,避免因重复点击 / 网络重试导致重复创建。
|
||||
* 客户端为每次"用户意图"生成一个唯一 ID(client_request_id),
|
||||
* 后端基于 userID + client_request_id 做幂等去重:
|
||||
* - 同一 key 在 TTL 内的重复请求直接返回首次创建的资源。
|
||||
*
|
||||
* 实现说明:
|
||||
* - React Native 的 `crypto` 全局由 @livekit/react-native 的 registerGlobals 注入
|
||||
* (见 src/polyfills.ts)。优先使用 crypto.randomUUID(),回退到基于
|
||||
* getRandomValues 的 v4 UUID,再回退到 timestamp+random 拼接。
|
||||
*/
|
||||
|
||||
const HEX_CHARS = '0123456789abcdef';
|
||||
|
||||
/**
|
||||
* 生成一个 RFC4122 v4 风格的 UUID 字符串。
|
||||
* 优先使用平台 crypto.randomUUID;不可用时手写 v4。
|
||||
*/
|
||||
export function generateUUID(): string {
|
||||
const g = globalThis as any;
|
||||
|
||||
// 1) 现代 RN / Web:crypto.randomUUID
|
||||
try {
|
||||
if (g?.crypto?.randomUUID) {
|
||||
const id = g.crypto.randomUUID();
|
||||
if (typeof id === 'string' && id.length > 0) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fallthrough
|
||||
}
|
||||
|
||||
// 2) crypto.getRandomValues:手写 v4
|
||||
try {
|
||||
if (g?.crypto?.getRandomValues) {
|
||||
const bytes = new Uint8Array(16);
|
||||
g.crypto.getRandomValues(bytes);
|
||||
// v4: version 0100, variant 10xx
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
let out = '';
|
||||
for (let i = 0; i < 16; i++) {
|
||||
out += HEX_CHARS[bytes[i] >> 4] + HEX_CHARS[bytes[i] & 0x0f];
|
||||
if (i === 3 || i === 5 || i === 7 || i === 9) {
|
||||
out += '-';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
} catch {
|
||||
// fallthrough
|
||||
}
|
||||
|
||||
// 3) 退化兜底:timestamp + Math.random(碰撞概率极低,仅用于无 crypto 的极端环境)
|
||||
const rnd = () => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0');
|
||||
const now = Date.now().toString(16).padStart(12, '0');
|
||||
return `${now.slice(0, 8)}-${now.slice(8, 12)}-${rnd()}-${rnd()}-${rnd()}${rnd()}${rnd()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成发帖请求的幂等键。每次发帖意图生成一次,重试时复用同一个。
|
||||
*/
|
||||
export function generateClientRequestId(): string {
|
||||
return generateUUID();
|
||||
}
|
||||
Reference in New Issue
Block a user