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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
// 输入框
|
||||
|
||||
@@ -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,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