feat(messaging): implement optimistic updates with retry for failed messages
All checks were successful
Frontend CI / ota (android) (push) Successful in 3m24s
Frontend CI / ota (ios) (push) Successful in 3m22s
Frontend CI / build-and-push-web (push) Successful in 21m12s
Frontend CI / build-android-apk (push) Successful in 41m40s

- 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:
lafay
2026-06-26 17:01:09 +08:00
parent 2bad59afbb
commit be77a9d04c
20 changed files with 451 additions and 134 deletions

View File

@@ -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,