refactor(platform): extract blurActiveElement to infrastructure module
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 4m47s
Frontend CI / ota-android (push) Successful in 10m48s
Frontend CI / build-android-apk (push) Successful in 1h36m37s

- Centralize duplicated Platform.OS === 'web' blur logic across 16 components
- Add blurActiveElement utility to src/infrastructure/platform module
- Optimize MessageBubble and ImageSegment with React.memo custom comparators
- Add useMemo for computed values in MessageSegmentsRenderer
- Update DTO types with is_system_notice and notice_content fields
- Fix keyboard dismissal order in useChatScreen handleDismiss
- Simplify userStore follow/unfollow state updates
- Remove unnecessary TypeScript type assertions throughout codebase
This commit is contained in:
lafay
2026-04-11 22:35:11 +08:00
parent 6f84e17772
commit 4b5ce1ba21
29 changed files with 679 additions and 222 deletions

View File

@@ -38,6 +38,7 @@ import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase';
import { SearchScreen } from './SearchScreen';
import { CreatePostScreen } from '../create/CreatePostScreen';
import * as hrefs from '../../navigation/hrefs';
import { blurActiveElement } from '../../infrastructure/platform';
const TABS = ['关注', '最新', '热门'];
const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire'];
@@ -203,9 +204,8 @@ export const HomeScreen: React.FC = () => {
const [showCreatePost, setShowCreatePost] = useState(false);
useEffect(() => {
if (showCreatePost && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
if (showCreatePost) {
blurActiveElement();
}
}, [showCreatePost]);

View File

@@ -42,8 +42,35 @@ import { useCursorPagination } from '../../hooks/useCursorPagination';
import { CommentItem, VoteCard, ReportDialog } from '../../components/business';
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive';
import { handleError } from '../../services/errorHandler';
import * as hrefs from '../../navigation/hrefs';
function togglePostField(
post: Post,
field: 'is_liked' | 'is_favorited',
countField: 'likes_count' | 'favorites_count',
): Post {
const oldValue = post[field];
const oldCount = post[countField];
return {
...post,
[field]: !oldValue,
[countField]: oldValue ? Math.max(0, oldCount - 1) : oldCount + 1,
};
}
function toggleCommentLikeInTree(comments: Comment[], commentId: string, isLiked: boolean, likesCount: number): Comment[] {
return comments.map(c => {
if (c.id === commentId) {
return { ...c, is_liked: isLiked, likes_count: likesCount };
}
if (c.replies?.length) {
return { ...c, replies: toggleCommentLikeInTree(c.replies, commentId, isLiked, likesCount) };
}
return c;
});
}
export const PostDetailScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createPostDetailStyles(colors), [colors]);
@@ -217,20 +244,17 @@ export const PostDetailScreen: React.FC = () => {
setPost(postData as unknown as Post);
// 初始化关注状态
if (postData.author) {
setIsFollowing((postData.author as any).is_following || false);
setIsFollowingMe((postData.author as any).is_following_me || false);
setIsFollowing(postData.author.is_following || false);
setIsFollowingMe(postData.author.is_following_me || false);
}
// 只在首次加载时记录浏览量
if (recordView && !hasRecordedView.current) {
if (!hasRecordedView.current) {
hasRecordedView.current = true;
// 异步记录浏览量,不阻塞加载
postService.recordView(postId).catch(err => {
console.error('记录浏览量失败:', err);
});
}
// 如果是投票帖子,立即加载投票数据
if ((postData as any).is_vote) {
if (postData.is_vote) {
setIsVoteLoading(true);
try {
const voteData = await voteService.getVoteResult(postId);
@@ -251,8 +275,8 @@ export const PostDetailScreen: React.FC = () => {
setPost(updatedPost);
// 初始化关注状态
if (updatedPost.author) {
setIsFollowing((updatedPost.author as any).is_following || false);
setIsFollowingMe((updatedPost.author as any).is_following_me || false);
setIsFollowing(updatedPost.author.is_following || false);
setIsFollowingMe(updatedPost.author.is_following_me || false);
}
}
}
@@ -260,7 +284,7 @@ export const PostDetailScreen: React.FC = () => {
// 加载评论(使用游标分页刷新)
await refreshComments();
} catch (error) {
console.error('加载帖子详情失败:', error);
handleError(error, { context: '加载帖子详情' });
} finally {
setIsPostInitialLoading(false);
}
@@ -458,65 +482,36 @@ export const PostDetailScreen: React.FC = () => {
const handleLike = useCallback(async () => {
if (!post) return;
// 先保存旧状态用于回滚
const oldIsLiked = post.is_liked;
const oldLikesCount = post.likes_count;
// 乐观更新本地状态
setPost(prev => prev ? {
...prev,
is_liked: !prev.is_liked,
likes_count: prev.is_liked ? prev.likes_count - 1 : prev.likes_count + 1
} : null);
const originalPost = post;
setPost(prev => prev ? togglePostField(prev, 'is_liked', 'likes_count') : null);
try {
if (oldIsLiked) {
if (originalPost.is_liked) {
await processPostUseCase.unlikePost(post.id);
} else {
await processPostUseCase.likePost(post.id);
}
// UseCase 会更新 store本地状态已经是乐观更新的无需再次更新
} catch (error) {
console.error('点赞操作失败:', error);
// 失败时回滚状态
setPost(prev => prev ? {
...prev,
is_liked: oldIsLiked,
likes_count: oldLikesCount
} : null);
handleError(error, { context: '点赞' });
setPost(originalPost);
}
}, [post]);
// 收藏帖子
const handleFavorite = useCallback(async () => {
if (!post) return;
// 先保存旧状态用于回滚
const oldIsFavorited = post.is_favorited;
const oldFavoritesCount = post.favorites_count;
// 乐观更新本地状态
setPost(prev => prev ? {
...prev,
is_favorited: !prev.is_favorited,
favorites_count: prev.is_favorited ? prev.favorites_count - 1 : prev.favorites_count + 1
} : null);
const originalPost = post;
setPost(prev => prev ? togglePostField(prev, 'is_favorited', 'favorites_count') : null);
try {
if (oldIsFavorited) {
if (originalPost.is_favorited) {
await processPostUseCase.unfavoritePost(post.id);
} else {
await processPostUseCase.favoritePost(post.id);
}
// UseCase 会更新 store本地状态已经是乐观更新的无需再次更新
} catch (error) {
console.error('收藏操作失败:', error);
// 失败时回滚状态
setPost(prev => prev ? {
...prev,
is_favorited: oldIsFavorited,
favorites_count: oldFavoritesCount
} : null);
handleError(error, { context: '收藏' });
setPost(originalPost);
}
}, [post]);
@@ -573,24 +568,19 @@ export const PostDetailScreen: React.FC = () => {
setVoteResult(oldVoteResult);
}
} catch (error) {
console.error('投票失败:', error);
// 失败时回滚
handleError(error, { context: '投票' });
setVoteResult(oldVoteResult);
} finally {
setIsVoteLoading(false);
}
}, [post, voteResult, isVoteLoading]);
// 取消投票处理函数
const handleUnvote = useCallback(async () => {
if (!post || !voteResult || isVoteLoading || !voteResult.voted_option_id) return;
const votedOptionId = voteResult.voted_option_id;
// 保存旧状态用于回滚
const oldVoteResult = { ...voteResult };
// 乐观更新
setVoteResult((prev: VoteResultDTO | null) => {
if (!prev) return null;
return {
@@ -611,12 +601,10 @@ export const PostDetailScreen: React.FC = () => {
try {
const success = await voteService.unvote(post.id);
if (!success) {
// 失败时回滚
setVoteResult(oldVoteResult);
}
} catch (error) {
console.error('取消投票失败:', error);
// 失败时回滚
handleError(error, { context: '取消投票' });
setVoteResult(oldVoteResult);
} finally {
setIsVoteLoading(false);
@@ -899,26 +887,15 @@ export const PostDetailScreen: React.FC = () => {
}
};
// 点赞评论(包括回复)- 优化版
const handleLikeComment = async (comment: Comment) => {
const { id, is_liked, likes_count } = comment;
// 乐观更新:切换点赞状态
const newIsLiked = !is_liked;
const oldIsLiked = is_liked ?? false;
const newIsLiked = !oldIsLiked;
const newLikesCount = newIsLiked ? likes_count + 1 : Math.max(0, likes_count - 1);
const originalComments = comments;
// 递归更新评论树中的点赞状态
const updateLikeInTree = (c: Comment): Comment => {
if (c.id === id) {
return { ...c, is_liked: newIsLiked, likes_count: newLikesCount };
}
if (c.replies?.length) {
return { ...c, replies: c.replies.map(r => updateLikeInTree(r)) };
}
return c;
};
setComments(prev => prev.map(c => updateLikeInTree(c)));
setComments(prev => toggleCommentLikeInTree(prev, id, newIsLiked, newLikesCount));
try {
const success = newIsLiked
@@ -929,18 +906,8 @@ export const PostDetailScreen: React.FC = () => {
throw new Error('点赞操作失败');
}
} catch (error) {
console.error('评论点赞操作失败:', error);
// 回滚状态
const rollbackLikeInTree = (c: Comment): Comment => {
if (c.id === id) {
return { ...c, is_liked, likes_count };
}
if (c.replies?.length) {
return { ...c, replies: c.replies.map(r => rollbackLikeInTree(r)) };
}
return c;
};
setComments(prev => prev.map(c => rollbackLikeInTree(c)));
handleError(error, { context: '评论点赞' });
setComments(toggleCommentLikeInTree(originalComments, id, oldIsLiked, likes_count));
}
};

View File

@@ -19,6 +19,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { blurActiveElement } from '../../infrastructure/platform';
import * as ImagePicker from 'expo-image-picker';
import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
import { groupService } from '../../services/groupService';
@@ -47,9 +48,8 @@ const CreateGroupScreen: React.FC = () => {
const [inviteModalVisible, setInviteModalVisible] = useState(false);
useEffect(() => {
if (inviteModalVisible && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
if (inviteModalVisible) {
blurActiveElement();
}
}, [inviteModalVisible]);

View File

@@ -46,6 +46,7 @@ import {
JoinType,
} from '../../types/dto';
import { User } from '../../types';
import { blurActiveElement } from '../../infrastructure/platform';
import { firstRouteParam } from '../../navigation/paramUtils';
import * as hrefs from '../../navigation/hrefs';
import { messageManager } from '../../stores/messageManager';
@@ -119,9 +120,8 @@ const GroupInfoScreen: React.FC = () => {
const [inviting, setInviting] = useState(false);
useEffect(() => {
if ((editModalVisible || announcementModalVisible || transferModalVisible || joinTypeModalVisible || inviteModalVisible) && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
if (editModalVisible || announcementModalVisible || transferModalVisible || joinTypeModalVisible || inviteModalVisible) {
blurActiveElement();
}
}, [editModalVisible, announcementModalVisible, transferModalVisible, joinTypeModalVisible, inviteModalVisible]);

View File

@@ -22,6 +22,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { blurActiveElement } from '../../infrastructure/platform';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import { useAuthStore } from '../../stores';
import { groupService } from '../../services/groupService';
@@ -133,9 +134,8 @@ const GroupMembersScreen: React.FC = () => {
const [newNickname, setNewNickname] = useState('');
useEffect(() => {
if ((actionModalVisible || nicknameModalVisible) && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
if (actionModalVisible || nicknameModalVisible) {
blurActiveElement();
}
}, [actionModalVisible, nicknameModalVisible]);

View File

@@ -40,6 +40,7 @@ import {
} from '../../theme';
import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments } from '../../types/dto';
import { authService } from '../../services';
import { blurActiveElement } from '../../infrastructure/platform';
import { useUserStore, useAuthStore } from '../../stores';
// 【新架构】使用MessageManager hooks会话列表数据源自 MessageManager 游标同步)
import {
@@ -138,9 +139,8 @@ export const MessageListScreen: React.FC = () => {
const [actionMenuVisible, setActionMenuVisible] = useState(false);
useEffect(() => {
if (actionMenuVisible && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
if (actionMenuVisible) {
blurActiveElement();
}
}, [actionMenuVisible]);
const [scannerVisible, setScannerVisible] = useState(false);

View File

@@ -15,15 +15,10 @@ import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive
import { EMOJIS } from './constants';
import { CustomSticker, getCustomStickers, batchDeleteStickers, addStickerFromUrl } from '../../../../services/stickerService';
import { useAppColors, spacing } from '../../../../theme';
import { blurActiveElement } from '../../../../infrastructure/platform';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
const blurActiveElementOnWeb = () => {
if (Platform.OS !== 'web') return;
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
};
// 表情尺寸配置 - 固定宽度 40px使用 flex 布局
const EMOJI_SIZES = {
mobile: { size: 24, itemWidth: 40, itemHeight: 40 },
@@ -182,7 +177,7 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
// 打开管理界面
const handleOpenManage = () => {
blurActiveElementOnWeb();
blurActiveElement();
setShowManageModal(true);
setManageMode(false);
setSelectedStickers(new Set());

View File

@@ -20,6 +20,7 @@ import { LongPressMenuProps } from './types';
import { RECALL_TIME_LIMIT } from './constants';
import { extractTextFromSegments, ImageSegmentData } from '../../../../types/dto';
import { isStickerExists, addStickerFromUrl } from '../../../../services/stickerService';
import { blurActiveElement } from '../../../../infrastructure/platform';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
@@ -37,16 +38,11 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
const styles = useChatScreenStyles();
const scaleAnimation = useRef(new Animated.Value(0)).current;
const opacityAnimation = useRef(new Animated.Value(0)).current;
const blurActiveElementOnWeb = () => {
if (Platform.OS !== 'web') return;
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
};
// 显示动画 - 缩放弹出
useEffect(() => {
if (visible) {
blurActiveElementOnWeb();
blurActiveElement();
Animated.parallel([
Animated.spring(scaleAnimation, {
toValue: 1,
@@ -61,7 +57,7 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
}),
]).start();
} else {
blurActiveElementOnWeb();
blurActiveElement();
Animated.parallel([
Animated.timing(scaleAnimation, {
toValue: 0,
@@ -251,12 +247,12 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
visible={visible}
transparent
animationType="none"
onShow={blurActiveElementOnWeb}
onShow={blurActiveElement}
onRequestClose={onClose}
>
<Pressable
onPress={() => {
blurActiveElementOnWeb();
blurActiveElement();
onClose();
}}
style={styles.qqMenuOverlay}

View File

@@ -31,7 +31,7 @@ const MAX_WIDTH_RATIO = {
desktop: 0.55,
};
export const MessageBubble: React.FC<MessageBubbleProps> = ({
const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
message,
index,
currentUserId,
@@ -481,11 +481,37 @@ const segmentStyles = StyleSheet.create({
minWidth: 120,
},
pureImageBubble: {
// 纯图片消息:去除内边距,让图片紧贴气泡边缘
paddingHorizontal: 0,
paddingVertical: 0,
overflow: 'hidden',
backgroundColor: 'transparent',
},
});
export const MessageBubble = React.memo(MessageBubbleInner, (prev, next) => {
if (prev.message.id !== next.message.id) return false;
if (prev.message.status !== next.message.status) return false;
if (prev.message.segments !== next.message.segments) return false;
if (prev.message.sender_id !== next.message.sender_id) return false;
if (prev.message.is_system_notice !== next.message.is_system_notice) return false;
if (prev.message.category !== next.message.category) return false;
if (prev.message.seq !== next.message.seq) return false;
if (prev.message.sender !== next.message.sender) return false;
if (prev.message.notice_content !== next.message.notice_content) return false;
if (prev.index !== next.index) return false;
if (prev.currentUserId !== next.currentUserId) return false;
if (prev.otherUserLastReadSeq !== next.otherUserLastReadSeq) return false;
if (prev.selectedMessageId !== next.selectedMessageId) return false;
if (prev.isGroupChat !== next.isGroupChat) return false;
if (prev.groupMembers !== next.groupMembers) return false;
if (prev.currentUser !== next.currentUser) return false;
if (prev.otherUser !== next.otherUser) return false;
if (prev.messageMap !== next.messageMap) return false;
if (prev.onLongPress !== next.onLongPress) return false;
if (prev.onImagePress !== next.onImagePress) return false;
if (prev.onReplyPress !== next.onReplyPress) return false;
if (prev.shouldShowTime !== next.shouldShowTime) return false;
return true;
});
export default MessageBubble;

View File

@@ -165,7 +165,7 @@ const renderTextSegment = (data: TextSegmentData, isMe: boolean): React.ReactNod
* 渲染图片 Segment
*/
// 图片Segment组件 - 使用Hook获取实际尺寸
const ImageSegment: React.FC<{
const ImageSegmentInner: React.FC<{
data: ImageSegmentData;
isMe: boolean;
onImagePress?: (url: string) => void;
@@ -284,7 +284,6 @@ const ImageSegment: React.FC<{
style={{
width: IMAGE_FALLBACK_SIZE.width,
height: IMAGE_FALLBACK_SIZE.height,
borderRadius: 8,
backgroundColor: 'rgba(0,0,0,0.1)',
justifyContent: 'center',
alignItems: 'center',
@@ -312,7 +311,6 @@ const ImageSegment: React.FC<{
style={{
width: IMAGE_FALLBACK_SIZE.width,
height: IMAGE_FALLBACK_SIZE.height,
borderRadius: 8,
backgroundColor: 'rgba(0,0,0,0.1)',
}}
/>
@@ -334,7 +332,6 @@ const ImageSegment: React.FC<{
style={{
width: dimensions.width,
height: dimensions.height,
borderRadius: 8,
}}
recyclingKey={stableImageKey}
contentFit="cover"
@@ -345,7 +342,6 @@ const ImageSegment: React.FC<{
setLoadError(false);
}}
onError={() => {
// 缩略图失败时自动回退原图,降低跳转定位后的白块/丢图概率
if (data.thumbnail_url && data.url && imageUrl === data.thumbnail_url) {
setCurrentImageUri(data.url);
return;
@@ -357,6 +353,17 @@ const ImageSegment: React.FC<{
);
};
const ImageSegment = React.memo(ImageSegmentInner, (prev, next) => {
if (prev.data.url !== next.data.url) return false;
if (prev.data.thumbnail_url !== next.data.thumbnail_url) return false;
if (prev.data.width !== next.data.width) return false;
if (prev.data.height !== next.data.height) return false;
if (prev.isMe !== next.isMe) return false;
if (prev.onImagePress !== next.onImagePress) return false;
if (prev.onImageLongPress !== next.onImageLongPress) return false;
return true;
});
const renderImageSegment = (
data: ImageSegmentData,
props: Omit<SegmentRendererProps, 'segment'>
@@ -705,7 +712,7 @@ export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
/**
* 渲染完整的消息链(多个 Segment 组合)
*/
export const MessageSegmentsRenderer: React.FC<{
const MessageSegmentsRendererInner: React.FC<{
segments: MessageSegment[];
isMe: boolean;
currentUserId?: string;
@@ -734,11 +741,11 @@ export const MessageSegmentsRenderer: React.FC<{
const fontSize = useChatSettingsStore((s) => s.fontSize);
const segStyles = useMemo(() => createSegmentStyles(themeColors, fontSize), [themeColors, fontSize]);
const replySegment = segments.find(s => s.type === 'reply');
const otherSegments = segments.filter(s => s.type !== 'reply');
const chunks = partitionMessageSegments(otherSegments);
const replySegment = useMemo(() => segments.find(s => s.type === 'reply'), [segments]);
const otherSegments = useMemo(() => segments.filter(s => s.type !== 'reply'), [segments]);
const chunks = useMemo(() => partitionMessageSegments(otherSegments), [otherSegments]);
const renderProps = {
const renderProps = useMemo(() => ({
isMe,
currentUserId,
memberMap,
@@ -747,7 +754,7 @@ export const MessageSegmentsRenderer: React.FC<{
onImagePress,
onImageLongPress,
onLinkPress,
};
}), [isMe, currentUserId, memberMap, onAtPress, onReplyPress, onImagePress, onImageLongPress, onLinkPress]);
return (
<SegmentStylesContext.Provider value={segStyles}>
@@ -837,13 +844,16 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
},
imagesChunk: {
width: '100%',
overflow: 'hidden',
},
imageStackItem: {
width: '100%',
marginBottom: 6,
marginBottom: 4,
overflow: 'hidden',
},
imageStackItemLast: {
width: '100%',
overflow: 'hidden',
},
blockChunk: {
width: '100%',
@@ -864,14 +874,13 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
color: colors.chat.textPrimary,
},
// @提及 - 微信/QQ 风格:更精致的高亮,支持动态字号
// @提及 - 与普通文本字号一致,仅颜色区分
atText: {
fontSize,
lineHeight: Math.round(fontSize * 1.4),
fontWeight: '500',
lineHeight: Math.round(fontSize * 1.43),
},
atTextMe: {
color: '#1A5F9E', // 深蓝色,在绿色背景上更清晰
color: '#1A5F9E',
},
atTextOther: {
color: colors.chat.link,
@@ -879,25 +888,15 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
atHighlight: {
backgroundColor: 'rgba(74, 136, 199, 0.15)',
borderRadius: 4,
paddingHorizontal: 3,
},
// 图片 - QQ风格保持原始宽高比
imageContainer: {
borderRadius: 12,
overflow: 'hidden',
marginTop: 4,
shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
elevation: 4,
},
imageMe: {
borderBottomRightRadius: 4,
},
imageOther: {
borderBottomLeftRadius: 4,
},
// 移除固定的 image 样式,改为在组件中动态计算
@@ -1143,4 +1142,19 @@ function useSegmentStyles(): SegmentStyles {
return ctx;
}
export const MessageSegmentsRenderer = React.memo(MessageSegmentsRendererInner, (prev, next) => {
if (prev.segments !== next.segments) return false;
if (prev.isMe !== next.isMe) return false;
if (prev.currentUserId !== next.currentUserId) return false;
if (prev.memberMap !== next.memberMap) return false;
if (prev.replyMessage !== next.replyMessage) return false;
if (prev.onAtPress !== next.onAtPress) return false;
if (prev.onReplyPress !== next.onReplyPress) return false;
if (prev.onImagePress !== next.onImagePress) return false;
if (prev.onImageLongPress !== next.onImageLongPress) return false;
if (prev.onLinkPress !== next.onLinkPress) return false;
if (prev.getSenderInfo !== next.getSenderInfo) return false;
return true;
});
export default MessageSegmentsRenderer;

View File

@@ -174,9 +174,9 @@ export const useChatScreen = () => {
status: (m.status || 'normal') as MessageStatus,
category: m.category,
created_at: m.created_at,
sender: (m as any).sender,
is_system_notice: (m as any).is_system_notice,
notice_content: (m as any).notice_content,
sender: m.sender,
is_system_notice: m.is_system_notice,
notice_content: m.notice_content,
}));
}, [messageManagerMessages]);
@@ -258,7 +258,7 @@ export const useChatScreen = () => {
setOtherUser(prev => ({ ...(prev || {}), ...other }));
}
// 获取对方最后阅读位置
setOtherUserLastReadSeq((conversation as any).other_last_read_seq || 0);
setOtherUserLastReadSeq(conversation.other_last_read_seq || 0);
}
}, [conversation, isGroupChat, currentUserId]);
@@ -273,8 +273,8 @@ export const useChatScreen = () => {
const detail = await userManager.getUserById(String(otherUserId), true);
if (!detail || cancelled) return;
setOtherUser(prev => ({ ...(prev || {}), ...detail }));
if (typeof (detail as any).is_following_me === 'boolean') {
setIsFollowedByOther((detail as any).is_following_me);
if (typeof detail.is_following_me === 'boolean') {
setIsFollowedByOther(detail.is_following_me);
} else {
setIsFollowedByOther(null);
}
@@ -360,7 +360,7 @@ export const useChatScreen = () => {
}, [loading, loadingMore, messages.length, scrollToLatest]);
// 新消息跟随策略Telegram/QQ 风格):
// 仅当最新端消息 seq 增长且用户在底部附近时才跟随;
// 仅当"最新端消息 seq 增长"且用户在底部附近时才跟随;
// 历史加载只会增加旧消息,不会提升 latest seq因此不会触发回底。
useEffect(() => {
const currentCount = messages.length;
@@ -371,14 +371,14 @@ export const useChatScreen = () => {
if (loading || loadingMore) return;
if (latestSeq <= prevLatestSeq) return;
if (suppressAutoFollowRef.current) return;
if (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked()) return;
if (!isNearBottom()) return;
const timer = setTimeout(() => {
scrollToLatest(false, false, 'new-message-follow');
}, 0);
return () => clearTimeout(timer);
}, [messages, loading, loadingMore, isNearBottom, scrollToLatest]);
}, [messages, loading, loadingMore, isNearBottom, scrollToLatest, isHistoryLoadingLocked]);
// 获取当前用户信息
useEffect(() => {
@@ -559,10 +559,10 @@ export const useChatScreen = () => {
if (!conversationId) return;
if (!conversation) return;
const unreadCount = Number((conversation as any).unread_count || 0);
const unreadCount = Number(conversation.unread_count || 0);
if (unreadCount <= 0) return;
const latestFromConversation = Number((conversation as any).last_seq || 0);
const latestFromConversation = Number(conversation.last_seq || 0);
const latestFromMessages = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
const targetSeq = Math.max(latestFromConversation, latestFromMessages);
if (targetSeq <= 0) return;
@@ -1305,8 +1305,8 @@ export const useChatScreen = () => {
// 关闭所有面板
const handleDismiss = useCallback(() => {
Keyboard.dismiss();
if (activePanel !== 'none') {
Keyboard.dismiss();
setActivePanel('none');
}
}, [activePanel]);

View File

@@ -16,6 +16,7 @@ import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '
import { authService } from '../../../services/authService';
import { useAuthStore } from '../../../stores';
import { Avatar, EmptyState, Loading, Text } from '../../../components/common';
import { blurActiveElement } from '../../../infrastructure/platform';
import { User } from '../../../types';
type MutualFollowSelectorModalProps = {
@@ -57,10 +58,7 @@ const MutualFollowSelectorModal: React.FC<MutualFollowSelectorModalProps> = ({
useEffect(() => {
if (!visible) return;
if (Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
blurActiveElement();
if (!currentUserId) return;
const nextSelectedIds = new Set(stableInitialSelectedIds);

View File

@@ -482,7 +482,6 @@ export const AboutScreen: React.FC = () => {
const appInfoItems = [
{ key: 'name', icon: 'information-outline', title: '应用名称', value: APP_NAME },
{ key: 'version', icon: 'tag-outline', title: '版本号', value: `v${APP_VERSION} (${BUILD_NUMBER})` },
{ key: 'developer', icon: 'office-building-outline', title: '开发者', value: '青春之旅电子信息科技' },
];
const renderSettingItem = (item: { key: string; icon: string; title: string; value?: string; action?: () => void }, index: number, total: number, showArrow: boolean = false) => (

View File

@@ -22,6 +22,7 @@ import { PanGestureHandler, State } from 'react-native-gesture-handler';
import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { blurActiveElement } from '../../infrastructure/platform';
import { useFocusEffect } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import {
@@ -214,9 +215,8 @@ export const ScheduleScreen: React.FC = () => {
const [isSyncing, setIsSyncing] = useState(false);
useEffect(() => {
if ((isAddModalVisible || isSettingsModalVisible || isSyncModalVisible) && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
if (isAddModalVisible || isSettingsModalVisible || isSyncModalVisible) {
blurActiveElement();
}
}, [isAddModalVisible, isSettingsModalVisible, isSyncModalVisible]);