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:
@@ -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,60 +1110,60 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
// 构建消息段
|
||||
const segments: MessageSegment[] = (trimmedText || replyingTo)
|
||||
? [...buildTextSegments(trimmedText, replyingTo)]
|
||||
: [];
|
||||
for (const url of uploadedUrls) {
|
||||
segments.push({
|
||||
type: 'image',
|
||||
data: {
|
||||
url,
|
||||
thumbnail_url: url,
|
||||
} as ImageSegmentData,
|
||||
});
|
||||
}
|
||||
|
||||
if (segments.length === 0) {
|
||||
Alert.alert('无法发送', '消息内容为空');
|
||||
return;
|
||||
}
|
||||
|
||||
// 乐观更新:立即清空输入框并滚动到底部
|
||||
const savedReplyTo = replyingTo;
|
||||
setInputText('');
|
||||
setSelectedMentions([]);
|
||||
setMentionAll(false);
|
||||
setReplyingTo(null);
|
||||
setPendingAttachments([]);
|
||||
|
||||
// 立即滚动到底部显示新消息
|
||||
setTimeout(() => {
|
||||
scrollToLatest(false, false, hasPending ? 'send-mixed' : 'send-text');
|
||||
}, 50);
|
||||
|
||||
// 后台发送消息(私聊与群聊统一走 MessageSendService 的乐观更新):
|
||||
// 1. 立即在 store 中插入 pending 临时消息
|
||||
// 2. 成功 → 替换为正式消息、更新会话 last_message、写入本地数据库
|
||||
// 3. 失败 → 标记为 failed,供用户点击重试
|
||||
try {
|
||||
// 有文本或回复时才构建文本段,纯图片时不塞空 text
|
||||
const segments: MessageSegment[] = (trimmedText || replyingTo)
|
||||
? [...buildTextSegments(trimmedText, replyingTo)]
|
||||
: [];
|
||||
for (const url of uploadedUrls) {
|
||||
segments.push({
|
||||
type: 'image',
|
||||
data: {
|
||||
url,
|
||||
thumbnail_url: url,
|
||||
} as ImageSegmentData,
|
||||
});
|
||||
}
|
||||
|
||||
if (segments.length === 0) {
|
||||
Alert.alert('无法发送', '消息内容为空');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGroupChat && effectiveGroupId) {
|
||||
await messageService.sendMessageByAction('group', conversationId, segments);
|
||||
|
||||
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);
|
||||
await sendMessageViaManager(segments, {
|
||||
replyToId: savedReplyTo?.id,
|
||||
detailType: isGroupChat ? 'group' : 'private',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试'));
|
||||
} finally {
|
||||
setSending(false);
|
||||
// MessageSendService 已将消息置为 failed 并在 UI 上显示状态,
|
||||
// 这里仅弹窗提示用户(与原行为保持一致)。
|
||||
if (isGroupChat) {
|
||||
Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试'));
|
||||
}
|
||||
}
|
||||
}, [
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user