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

@@ -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), []);

View File

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

View File

@@ -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;
@@ -431,18 +435,46 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
)}
{renderMessageContent()}
{/* 私聊模式:显示已读标记 */}
{isLastReadMessage && (
<View style={styles.readStatusContainer}>
<MaterialCommunityIcons
name="check-all"
size={14}
color="#34C759"
/>
<Text style={[styles.readStatusText, styles.readStatusTextRead]}>
</Text>
{/* 发送状态指示器 */}
{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}
color="#34C759"
/>
<Text style={[styles.readStatusText, styles.readStatusTextRead]}>
</Text>
</View>
)}
</View>
)}
</View>

View File

@@ -416,6 +416,33 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
readStatusTextRead: {
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: {
@@ -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,
},
// 输入框

View File

@@ -101,6 +101,8 @@ export interface MessageBubbleProps {
onReply?: (message: GroupMessage) => void;
// 点击回复预览回调(定位到原消息)
onReplyPress?: (messageId: string) => void;
// 点击「发送失败」气泡重试发送(仅对当前用户自己发送的失败消息触发)
onRetrySend?: (message: GroupMessage) => void;
}
/** 输入框中待发送的本地图片(未上传) */

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,