From de944478441786891ac3d591984ec6ccd41958c6 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Thu, 19 Mar 2026 10:53:09 +0800 Subject: [PATCH 01/36] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E5=A4=8D=20Typ?= =?UTF-8?q?eScript=20=E7=B1=BB=E5=9E=8B=E9=94=99=E8=AF=AF=EF=BC=8C?= =?UTF-8?q?=E4=B8=8E=E5=90=8E=E7=AB=AF=20API=20=E5=93=8D=E5=BA=94=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 UserDTO 类型定义,字段改为非 nullable 类型 - 修复 PostMapper 使用正确的 snake_case 字段名 - 修复 UserMapper 移除不存在的 is_blocked 和 updated_at 字段 - 修复 MessageMapper 使用 segments 替代已移除的字段 - 修复 MessageSyncService 正确处理 API 响应类型 - 修复 LocalDataSource SQLite 参数类型问题 - 修复导航相关类型错误 - 修复 PostDetailScreen 和 EditProfileScreen 的 null/undefined 类型不匹配 --- src/data/datasources/ApiDataSource.ts | 7 +- src/data/datasources/LocalDataSource.ts | 15 +++- src/data/mappers/ConversationMapper.ts | 22 ++++-- src/data/mappers/MessageMapper.ts | 14 ++-- src/data/mappers/PostMapper.ts | 29 ++++--- src/data/mappers/UserMapper.ts | 76 +++++-------------- .../interfaces/IMessageRepository.ts | 2 +- .../navigation/navigationService.ts | 2 +- src/navigation/DesktopNavigator.tsx | 2 +- src/navigation/index.ts | 3 +- src/screens/home/PostDetailScreen.tsx | 14 ++-- src/screens/profile/EditProfileScreen.tsx | 10 +-- src/stores/message/MessageSyncService.ts | 76 ++++++++++++------- src/types/dto.ts | 22 +++--- 14 files changed, 146 insertions(+), 148 deletions(-) diff --git a/src/data/datasources/ApiDataSource.ts b/src/data/datasources/ApiDataSource.ts index f097e94..07009d0 100644 --- a/src/data/datasources/ApiDataSource.ts +++ b/src/data/datasources/ApiDataSource.ts @@ -72,12 +72,7 @@ export class ApiDataSource implements IApiDataSource { async delete(url: string, data?: any): Promise { try { const fullUrl = this.buildUrl(url); - // 处理带 request body 的 DELETE 请求 - if (data) { - const response = await api.request('DELETE', fullUrl, undefined, data); - return response.data; - } - const response = await api.delete(fullUrl); + const response = await api.delete(fullUrl, data); return response.data; } catch (error) { this.handleError(error, 'DELETE'); diff --git a/src/data/datasources/LocalDataSource.ts b/src/data/datasources/LocalDataSource.ts index 90ddc98..1ed7b93 100644 --- a/src/data/datasources/LocalDataSource.ts +++ b/src/data/datasources/LocalDataSource.ts @@ -183,7 +183,10 @@ export class LocalDataSource implements ILocalDataSource { async query(sql: string, params?: any[]): Promise { try { const db = this.ensureDb(); - return await db.getAllAsync(sql, params); + if (params && params.length > 0) { + return await db.getAllAsync(sql, params as any); + } + return await db.getAllAsync(sql); } catch (error) { this.handleError(error, 'QUERY'); } @@ -192,7 +195,10 @@ export class LocalDataSource implements ILocalDataSource { async getFirst(sql: string, params?: any[]): Promise { try { const db = this.ensureDb(); - return await db.getFirstAsync(sql, params); + if (params && params.length > 0) { + return await db.getFirstAsync(sql, params as any); + } + return await db.getFirstAsync(sql); } catch (error) { this.handleError(error, 'GET_FIRST'); } @@ -210,7 +216,10 @@ export class LocalDataSource implements ILocalDataSource { async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> { try { const db = this.ensureDb(); - return await db.runAsync(sql, params); + if (params && params.length > 0) { + return await db.runAsync(sql, params as any); + } + return await db.runAsync(sql); } catch (error) { this.handleError(error, 'RUN'); } diff --git a/src/data/mappers/ConversationMapper.ts b/src/data/mappers/ConversationMapper.ts index 4eaaca0..98246d9 100644 --- a/src/data/mappers/ConversationMapper.ts +++ b/src/data/mappers/ConversationMapper.ts @@ -212,7 +212,7 @@ export class ConversationMapper { id: String(user.id || ''), username: user.username || '', nickname: user.nickname, - avatar: user.avatar, + avatar: user.avatar ?? undefined, }; } @@ -220,19 +220,31 @@ export class ConversationMapper { * 映射群组 API 响应 */ private static mapGroupFromApi(group: GroupResponse): GroupModel { + // 将数字类型的 join_type 转换为字符串类型 + // JoinType: 0 = 允许任何人, 1 = 需要审批, 2 = 不允许 + const mapJoinType = (joinType: number): 'anyone' | 'approval' | 'invite' => { + switch (joinType) { + case 0: return 'anyone'; + case 1: return 'approval'; + case 2: return 'invite'; + default: return 'approval'; + } + }; + return { id: String(group.id || ''), name: group.name || '', avatar: group.avatar, description: group.description, - announcement: group.announcement, + // GroupResponse 没有 announcement 和 updated_at 字段,使用默认值 + announcement: undefined, ownerId: String(group.owner_id || ''), memberCount: group.member_count || 0, - maxMemberCount: group.max_member_count || 500, - joinType: group.join_type || 'approval', + maxMemberCount: group.max_members || 500, + joinType: mapJoinType(group.join_type), isMuted: group.mute_all || false, createdAt: new Date(group.created_at || Date.now()), - updatedAt: new Date(group.updated_at || Date.now()), + updatedAt: new Date(group.created_at || Date.now()), }; } diff --git a/src/data/mappers/MessageMapper.ts b/src/data/mappers/MessageMapper.ts index d6e99ea..44a2742 100644 --- a/src/data/mappers/MessageMapper.ts +++ b/src/data/mappers/MessageMapper.ts @@ -32,13 +32,16 @@ export class MessageMapper { * 将 API 响应转换为应用模型 */ static fromApiResponse(response: MessageResponse): MessageModel { + const textSegment = response.segments?.find(s => s.type === 'text'); + const content = textSegment?.data?.text || textSegment?.data?.content || ''; + return { id: String(response.id || ''), conversationId: String(response.conversation_id || ''), senderId: String(response.sender_id || ''), - content: response.content || '', - type: response.message_type || 'text', - isRead: response.is_read || false, + content, + type: 'text', + isRead: false, createdAt: new Date(response.created_at || Date.now()), seq: response.seq || 0, status: response.status || 'normal', @@ -169,15 +172,12 @@ export class MessageMapper { } } - /** - * 映射发送者信息 - */ private static mapSenderFromApi(sender: UserDTO): UserModel { return { id: String(sender.id || ''), username: sender.username || '', nickname: sender.nickname, - avatar: sender.avatar, + avatar: sender.avatar || undefined, }; } } diff --git a/src/data/mappers/PostMapper.ts b/src/data/mappers/PostMapper.ts index 172a6ac..32b5a52 100644 --- a/src/data/mappers/PostMapper.ts +++ b/src/data/mappers/PostMapper.ts @@ -4,31 +4,28 @@ */ import { PostModel, UserModel } from '../models'; -import type { Post } from '../../types'; +import type { PostDTO } from '../../types/dto'; export class PostMapper { - /** - * 将 API 响应转换为应用模型 - */ - static fromApiResponse(response: Post): PostModel { + static fromApiResponse(response: PostDTO): PostModel { return { id: String(response.id || ''), - authorId: String(response.author_id || ''), + authorId: String(response.user_id || ''), author: response.author ? this.mapAuthorFromApi(response.author) : undefined, title: response.title || '', content: response.content || '', - images: response.images || [], - likeCount: response.like_count || 0, - commentCount: response.comment_count || 0, - shareCount: response.share_count || 0, - viewCount: response.view_count || 0, - favoriteCount: response.favorite_count || 0, + images: response.images?.map(img => img.url) || [], + likeCount: response.likes_count || 0, + commentCount: response.comments_count || 0, + shareCount: response.shares_count || 0, + viewCount: response.views_count || 0, + favoriteCount: response.favorites_count || 0, isLiked: response.is_liked || false, isFavorited: response.is_favorited || false, - isTop: response.is_top || false, - status: response.status || 'published', + isTop: response.is_pinned || false, + status: (response.status || 'published') as 'published' | 'draft' | 'deleted', communityId: response.community_id, - tags: response.tags || [], + tags: [], createdAt: new Date(response.created_at || Date.now()), updatedAt: new Date(response.updated_at || Date.now()), }; @@ -37,7 +34,7 @@ export class PostMapper { /** * 将 API 响应数组转换为应用模型数组 */ - static fromApiResponseList(responses: Post[]): PostModel[] { + static fromApiResponseList(responses: PostDTO[]): PostModel[] { return responses.map(r => this.fromApiResponse(r)); } diff --git a/src/data/mappers/UserMapper.ts b/src/data/mappers/UserMapper.ts index 46168a7..0d8027b 100644 --- a/src/data/mappers/UserMapper.ts +++ b/src/data/mappers/UserMapper.ts @@ -4,7 +4,7 @@ */ import { UserModel } from '../models'; -import type { UserDTO, User } from '../../types/dto'; +import type { UserDTO } from '../../types/dto'; // 数据库用户记录类型 export interface UserDbRecord { @@ -14,9 +14,6 @@ export interface UserDbRecord { } export class UserMapper { - /** - * 将 API DTO 转换为应用模型 - */ static fromDTO(dto: UserDTO): UserModel { return { id: String(dto.id || ''), @@ -32,39 +29,10 @@ export class UserMapper { followingCount: dto.following_count, postsCount: dto.posts_count, isFollowing: dto.is_following, - isBlocked: dto.is_blocked, createdAt: dto.created_at ? new Date(dto.created_at) : undefined, - updatedAt: dto.updated_at ? new Date(dto.updated_at) : undefined, }; } - /** - * 将 User 类型转换为应用模型 - */ - static fromUser(user: User): UserModel { - return { - id: String(user.id || ''), - username: user.username || '', - nickname: user.nickname, - avatar: user.avatar, - bio: user.bio, - website: user.website, - location: user.location, - email: user.email, - phone: user.phone, - followersCount: user.followers_count, - followingCount: user.following_count, - postsCount: user.posts_count, - isFollowing: user.is_following, - isBlocked: user.is_blocked, - createdAt: user.created_at ? new Date(user.created_at) : undefined, - updatedAt: user.updated_at ? new Date(user.updated_at) : undefined, - }; - } - - /** - * 将 DTO 数组转换为应用模型数组 - */ static fromDTOList(dtos: UserDTO[]): UserModel[] { return dtos.map(dto => this.fromDTO(dto)); } @@ -88,20 +56,19 @@ export class UserMapper { const dto: UserDTO = { id: model.id, username: model.username, - nickname: model.nickname, - avatar: model.avatar, - bio: model.bio, - website: model.website, - location: model.location, + nickname: model.nickname || '', + avatar: model.avatar || '', + cover_url: '', + bio: model.bio || '', + website: model.website || '', + location: model.location || '', email: model.email, phone: model.phone, - followers_count: model.followersCount, - following_count: model.followingCount, - posts_count: model.postsCount, + followers_count: model.followersCount || 0, + following_count: model.followingCount || 0, + posts_count: model.postsCount || 0, is_following: model.isFollowing, - is_blocked: model.isBlocked, - created_at: model.createdAt?.toISOString(), - updated_at: model.updatedAt?.toISOString(), + created_at: model.createdAt?.toISOString() || '', }; return { @@ -149,20 +116,19 @@ export class UserMapper { return { id: model.id, username: model.username, - nickname: model.nickname, - avatar: model.avatar, - bio: model.bio, - website: model.website, - location: model.location, + nickname: model.nickname || '', + avatar: model.avatar || '', + cover_url: '', + bio: model.bio || '', + website: model.website || '', + location: model.location || '', email: model.email, phone: model.phone, - followers_count: model.followersCount, - following_count: model.followingCount, - posts_count: model.postsCount, + followers_count: model.followersCount || 0, + following_count: model.followingCount || 0, + posts_count: model.postsCount || 0, is_following: model.isFollowing, - is_blocked: model.isBlocked, - created_at: model.createdAt?.toISOString(), - updated_at: model.updatedAt?.toISOString(), + created_at: model.createdAt?.toISOString() || '', }; } } diff --git a/src/data/repositories/interfaces/IMessageRepository.ts b/src/data/repositories/interfaces/IMessageRepository.ts index 1964401..8619eab 100644 --- a/src/data/repositories/interfaces/IMessageRepository.ts +++ b/src/data/repositories/interfaces/IMessageRepository.ts @@ -3,7 +3,7 @@ * 定义消息数据访问的抽象 */ -import type { Message, Conversation, ConversationType } from '../../types/dto'; +import type { Message, Conversation } from '../../../core/entities/Message'; export interface IMessageRepository { // ==================== 消息操作 ==================== diff --git a/src/infrastructure/navigation/navigationService.ts b/src/infrastructure/navigation/navigationService.ts index f9ca8fa..270f9cd 100644 --- a/src/infrastructure/navigation/navigationService.ts +++ b/src/infrastructure/navigation/navigationService.ts @@ -53,7 +53,7 @@ class NavigationService { if (!this.isNavigationReady()) { return null; } - return this.navigationRef!.getCurrentRoute(); + return this.navigationRef!.getCurrentRoute() ?? null; } /** diff --git a/src/navigation/DesktopNavigator.tsx b/src/navigation/DesktopNavigator.tsx index fd77770..e58db7c 100644 --- a/src/navigation/DesktopNavigator.tsx +++ b/src/navigation/DesktopNavigator.tsx @@ -115,7 +115,7 @@ export function DesktopNavigator({ unreadCount = 0 }: DesktopNavigatorProps) { > diff --git a/src/navigation/index.ts b/src/navigation/index.ts index f7462b5..40e4f9c 100644 --- a/src/navigation/index.ts +++ b/src/navigation/index.ts @@ -15,7 +15,6 @@ export type { NavItemConfig, } from './types'; -// 导航器 export { AuthNavigator } from './AuthNavigator'; export { HomeNavigator } from './HomeNavigator'; export { MessageNavigator } from './MessageNavigator'; @@ -24,7 +23,7 @@ export { ProfileNavigator } from './ProfileNavigator'; export { TabNavigator } from './TabNavigator'; export { DesktopNavigator } from './DesktopNavigator'; export { RootNavigator } from './RootNavigator'; -export { MainNavigator } from './MainNavigator'; +export { default as MainNavigator } from './MainNavigator'; // 移动端简化导航 export { SimpleMobileTabNavigator } from './SimpleMobileTabNavigator'; diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index f7ced6d..af3d886 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -635,13 +635,13 @@ export const PostDetailScreen: React.FC = () => { id: '', username: 'guest', nickname: '游客', - avatar: null, - cover_url: null, - bio: null, - website: null, - location: null, - phone: null, - email: null, + avatar: '', + cover_url: '', + bio: '', + website: '', + location: '', + phone: undefined, + email: undefined, posts_count: 0, followers_count: 0, following_count: 0, diff --git a/src/screens/profile/EditProfileScreen.tsx b/src/screens/profile/EditProfileScreen.tsx index ce6c7b3..c4c5763 100644 --- a/src/screens/profile/EditProfileScreen.tsx +++ b/src/screens/profile/EditProfileScreen.tsx @@ -225,11 +225,11 @@ export const EditProfileScreen: React.FC = () => { if (updatedUser) { updateUser({ nickname: nickname.trim(), - bio: bio.trim() || null, - location: location.trim() || null, - website: website.trim() || null, - phone: phone.trim() || null, - email: email.trim() || null, + bio: bio.trim() || undefined, + location: location.trim() || undefined, + website: website.trim() || undefined, + phone: phone.trim() || undefined, + email: email.trim() || undefined, }); Alert.alert('成功', '资料已更新', [ diff --git a/src/stores/message/MessageSyncService.ts b/src/stores/message/MessageSyncService.ts index 85b43bb..973e4a6 100644 --- a/src/stores/message/MessageSyncService.ts +++ b/src/stores/message/MessageSyncService.ts @@ -6,7 +6,7 @@ import { messageService } from '../../services/messageService'; import { messageRepository } from '../../data/repositories/MessageRepository'; import type { Message, Conversation } from '../../core/entities/Message'; -import type { ConversationResponse, MessageResponse } from '../../types/dto'; +import type { ConversationListResponse, MessageListResponse } from '../../types/dto'; export interface SyncOptions { force?: boolean; @@ -32,8 +32,8 @@ export class MessageSyncService { this.syncingConversations = true; try { - const response = await messageService.getConversations(); - const conversations = this.mapConversations(response); + const response: ConversationListResponse = await messageService.getConversations(); + const conversations = this.mapConversations(response.list || []); return conversations; } catch (error) { console.error('[MessageSyncService] Failed to sync conversations:', error); @@ -59,10 +59,12 @@ export class MessageSyncService { try { const lastSeq = options.lastSeq ?? await messageRepository.getMaxSeq(conversationId); - const response = await messageService.getMessages(conversationId, { - after_seq: lastSeq, - limit: 50, - }); + const response: MessageListResponse = await messageService.getMessages( + conversationId, + lastSeq, + undefined, + 50 + ); const messages = this.mapMessages(response.messages || []); @@ -73,7 +75,7 @@ export class MessageSyncService { return { conversations: [], messages, - hasMore: response.has_more || false, + hasMore: messages.length >= 50, }; } catch (error) { console.error('[MessageSyncService] Failed to sync messages:', error); @@ -89,10 +91,12 @@ export class MessageSyncService { limit: number = 20 ): Promise { try { - const response = await messageService.getMessages(conversationId, { - before_seq: beforeSeq, - limit, - }); + const response: MessageListResponse = await messageService.getMessages( + conversationId, + undefined, + beforeSeq, + limit + ); const messages = this.mapMessages(response.messages || []); @@ -122,31 +126,45 @@ export class MessageSyncService { return messageRepository.getMessagesBeforeSeq(conversationId, beforeSeq, limit); } - private mapConversations(response: ConversationResponse[]): Conversation[] { - return response.map(conv => ({ + private mapConversations(list: ConversationListResponse['list']): Conversation[] { + return list.map(conv => ({ id: conv.id, type: conv.type || 'private', - isPinned: conv.isPinned || false, - lastSeq: conv.lastSeq || conv.last_seq || 0, - lastMessageAt: conv.lastMessageAt || conv.last_message_at || new Date().toISOString(), - unreadCount: conv.unreadCount || conv.unread_count || 0, - participants: conv.participants || [], - group: conv.group, - createdAt: conv.createdAt || conv.created_at || new Date().toISOString(), - updatedAt: conv.updatedAt || conv.updated_at || new Date().toISOString(), + isPinned: conv.is_pinned || false, + lastSeq: conv.last_seq || 0, + lastMessageAt: conv.last_message_at || new Date().toISOString(), + unreadCount: conv.unread_count || 0, + participants: (conv.participants || []).map(p => ({ + id: p.id, + username: p.username, + avatar: p.avatar || undefined, + nickname: p.nickname, + })), + group: conv.group ? { + id: String(conv.group.id), + name: conv.group.name, + avatar: conv.group.avatar, + } : undefined, + createdAt: conv.created_at || new Date().toISOString(), + updatedAt: conv.updated_at || new Date().toISOString(), })); } - private mapMessages(response: MessageResponse[]): Message[] { - return response.map(msg => ({ + private mapMessages(messages: MessageListResponse['messages']): Message[] { + return messages.map(msg => ({ id: msg.id, - conversationId: msg.conversationId || msg.conversation_id || '', - senderId: msg.senderId || msg.sender_id || '', - seq: msg.seq || 0, + conversationId: msg.conversation_id, + senderId: msg.sender_id, + seq: msg.seq, segments: msg.segments || [], - createdAt: msg.createdAt || msg.created_at || new Date().toISOString(), + createdAt: msg.created_at, status: msg.status || 'normal', - sender: msg.sender, + sender: msg.sender ? { + id: msg.sender.id, + username: msg.sender.username, + avatar: msg.sender.avatar || undefined, + nickname: msg.sender.nickname, + } : undefined, })); } diff --git a/src/types/dto.ts b/src/types/dto.ts index 8394b1d..bf6efb3 100644 --- a/src/types/dto.ts +++ b/src/types/dto.ts @@ -9,21 +9,20 @@ export interface UserDTO { id: string; username: string; nickname: string; - avatar: string | null; - cover_url: string | null; // 头图URL - bio: string | null; - website: string | null; - location: string | null; - phone: string | null; - email: string | null; + avatar: string; + cover_url: string; + bio: string; + website: string; + location: string; + phone?: string; + email?: string; email_verified?: boolean; posts_count: number; followers_count: number; following_count: number; created_at: string; - // 额外字段 - is_following?: boolean; // 当前用户是否关注了该用户 - is_following_me?: boolean; // 该用户是否关注了当前用户 + is_following?: boolean; + is_following_me?: boolean; } export interface UserDetailDTO { @@ -246,6 +245,8 @@ export interface ConversationResponse { participants?: UserDTO[]; // 私聊时使用 member_count?: number; // 群聊时使用 group?: GroupResponse; // 群聊会话的群组信息 + my_last_read_seq?: number; // 当前用户的已读位置 + other_last_read_seq?: number; // 对方用户的已读位置 created_at: string; updated_at: string; } @@ -269,6 +270,7 @@ export interface ConversationDetailResponse { participants: UserDTO[]; my_last_read_seq: number; // 当前用户的已读位置 other_last_read_seq: number; // 对方用户的已读位置 + group?: GroupResponse; // 群聊会话的群组信息 created_at: string; updated_at: string; } From 7d9670e973c1e660b6255bcc3366a5cb623f7e99 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Thu, 19 Mar 2026 10:57:24 +0800 Subject: [PATCH 02/36] ci: use android-builder runner for APK build --- .gitea/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index b966310..a52ab8e 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -94,7 +94,7 @@ jobs: test "${REMOTE}" = "${LOCAL}" build-android-apk: - runs-on: ubuntu-latest + runs-on: android-builder container: image: reactnativecommunity/react-native-android:latest env: From 59877e6ae3415c12c2f0e492a82e33f8de7454ae Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Fri, 20 Mar 2026 12:23:26 +0800 Subject: [PATCH 03/36] chore: bump version to 1.0.11 --- app.json | 11 +++++++++-- package.json | 3 ++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app.json b/app.json index ca43d16..2511aa4 100644 --- a/app.json +++ b/app.json @@ -2,10 +2,11 @@ "expo": { "name": "萝卜社区", "slug": "qojo", - "version": "1.0.10", + "version": "1.0.11", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "light", + "scheme": "carrotbbs", "splash": { "image": "./assets/splash-icon.png", "resizeMode": "contain", @@ -33,7 +34,7 @@ }, "predictiveBackGestureEnabled": false, "package": "skin.carrot.bbs", - "versionCode": 5, + "versionCode": 6, "permissions": [ "VIBRATE", "RECEIVE_BOOT_COMPLETED", @@ -68,6 +69,12 @@ } ], "expo-sqlite", + [ + "expo-camera", + { + "cameraPermission": "允许萝卜社区访问您的相机以扫描二维码" + } + ], [ "expo-notifications", { diff --git a/package.json b/package.json index 49baba2..144167c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "carrot_bbs", - "version": "1.0.0", + "version": "1.0.1", "main": "index.ts", "scripts": { "start": "expo start", @@ -26,6 +26,7 @@ "date-fns": "^4.1.0", "expo": "~55.0.4", "expo-background-fetch": "~55.0.9", + "expo-camera": "^55.0.10", "expo-constants": "~55.0.7", "expo-dev-client": "~55.0.10", "expo-file-system": "~55.0.10", From a005fb0a15b7ac736cb8bd05c991a6584b768ee9 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Fri, 20 Mar 2026 19:28:42 +0800 Subject: [PATCH 04/36] feat: add QR code login and enhance chat experience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add QR code login flow with scanner and confirmation screens - Implement swipe-to-reply in chat with SwipeableMessageBubble - Replace FlatList with FlashList for chat performance optimization - Add Schedule stack navigator with CourseDetail screen support - Configure deep linking for QR code login (carrotbbs://qrcode/login/:sessionId) - Update branding from 胡萝卜 to 萝卜社区 - Display dynamic app version in settings --- package-lock.json | 2988 +++++++++-------- screenshots/after-fix.png | Bin 126583 -> 0 bytes screenshots/test-navigation-fix.png | Bin 2877 -> 0 bytes src/components/business/QRCodeScanner.tsx | 251 ++ src/navigation/MainNavigator.tsx | 3 +- src/navigation/RootNavigator.tsx | 10 + src/navigation/SimpleMobileTabNavigator.tsx | 49 +- src/navigation/types.ts | 1 + .../hooks/responsive/MIGRATION.md | 223 -- src/screens/auth/LoginScreen.tsx | 4 +- src/screens/auth/QRCodeConfirmScreen.tsx | 288 ++ src/screens/auth/RegisterScreen.tsx | 8 +- src/screens/message/ChatScreen.tsx | 25 +- src/screens/message/MessageListScreen.tsx | 14 +- .../components/ChatScreen/MessageBubble.tsx | 46 +- .../components/ChatScreen/SegmentRenderer.tsx | 1 + .../ChatScreen/SwipeableMessageBubble.tsx | 157 + .../components/ChatScreen/bubbleStyles.ts | 270 ++ .../message/components/ChatScreen/index.ts | 1 + .../message/components/ChatScreen/types.ts | 10 + .../components/ChatScreen/useChatScreen.ts | 35 +- src/screens/profile/SettingsScreen.tsx | 8 +- src/services/authService.ts | 58 + src/utils/__tests__/optimisticUpdate.test.ts | 287 -- 24 files changed, 2878 insertions(+), 1859 deletions(-) delete mode 100644 screenshots/after-fix.png delete mode 100644 screenshots/test-navigation-fix.png create mode 100644 src/components/business/QRCodeScanner.tsx delete mode 100644 src/presentation/hooks/responsive/MIGRATION.md create mode 100644 src/screens/auth/QRCodeConfirmScreen.tsx create mode 100644 src/screens/message/components/ChatScreen/SwipeableMessageBubble.tsx create mode 100644 src/screens/message/components/ChatScreen/bubbleStyles.ts delete mode 100644 src/utils/__tests__/optimisticUpdate.test.ts diff --git a/package-lock.json b/package-lock.json index 4756a76..586d224 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "date-fns": "^4.1.0", "expo": "~55.0.4", "expo-background-fetch": "~55.0.9", + "expo-camera": "^55.0.10", "expo-constants": "~55.0.7", "expo-dev-client": "~55.0.10", "expo-file-system": "~55.0.10", @@ -112,15 +113,6 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/generator": { "version": "7.29.1", "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", @@ -165,15 +157,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-create-class-features-plugin": { "version": "7.28.6", "resolved": "https://registry.npmmirror.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", @@ -195,15 +178,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-create-regexp-features-plugin": { "version": "7.28.5", "resolved": "https://registry.npmmirror.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", @@ -221,19 +195,10 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.6", - "resolved": "https://registry.npmmirror.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz", - "integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==", + "version": "0.6.8", + "resolved": "https://registry.npmmirror.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", @@ -408,22 +373,22 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -1278,15 +1243,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/plugin-transform-shorthand-properties": { "version": "7.27.1", "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", @@ -1423,9 +1379,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1530,9 +1486,9 @@ } }, "node_modules/@expo-google-fonts/material-symbols": { - "version": "0.4.25", - "resolved": "https://registry.npmmirror.com/@expo-google-fonts/material-symbols/-/material-symbols-0.4.25.tgz", - "integrity": "sha512-MlwOpcYPLYu2+aDAwqv29l3sknNNxA36Jcu07Tg9+MTEvXk2SPcO8eQmwwDeVBbv5Wb6ToD1LmE+e0lLv/9WvA==", + "version": "0.4.27", + "resolved": "https://registry.npmmirror.com/@expo-google-fonts/material-symbols/-/material-symbols-0.4.27.tgz", + "integrity": "sha512-cnb3DZnWUWpezGFkJ8y4MT5f/lw6FcgDzeJzic+T+vpQHLHG1cg3SC3i1w1i8Bk4xKR4HPY3t9iIRNvtr5ml8A==", "license": "MIT AND Apache-2.0" }, "node_modules/@expo/code-signing-certificates": { @@ -1545,15 +1501,15 @@ } }, "node_modules/@expo/config": { - "version": "55.0.8", - "resolved": "https://registry.npmmirror.com/@expo/config/-/config-55.0.8.tgz", - "integrity": "sha512-D7RYYHfErCgEllGxNwdYdkgzLna7zkzUECBV3snbUpf7RvIpB5l1LpCgzuVoc5KVew5h7N1Tn4LnT/tBSUZsQg==", + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/@expo/config/-/config-55.0.10.tgz", + "integrity": "sha512-qCHxo9H1ZoeW+y0QeMtVZ3JfGmumpGrgUFX60wLWMarraoQZSe47ZUm9kJSn3iyoPjUtUNanO3eXQg+K8k4rag==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~55.0.6", + "@expo/config-plugins": "~55.0.7", "@expo/config-types": "^55.0.5", "@expo/json-file": "^10.0.12", - "@expo/require-utils": "^55.0.2", + "@expo/require-utils": "^55.0.3", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", @@ -1564,9 +1520,9 @@ } }, "node_modules/@expo/config-plugins": { - "version": "55.0.6", - "resolved": "https://registry.npmmirror.com/@expo/config-plugins/-/config-plugins-55.0.6.tgz", - "integrity": "sha512-cIox6FjZlFaaX40rbQ3DvP9e87S5X85H9uw+BAxJE5timkMhuByy3GAlOsj1h96EyzSiol7Q6YIGgY1Jiz4M+A==", + "version": "55.0.7", + "resolved": "https://registry.npmmirror.com/@expo/config-plugins/-/config-plugins-55.0.7.tgz", + "integrity": "sha512-XZUoDWrsHEkH3yasnDSJABM/UxP5a1ixzRwU/M+BToyn/f0nTrSJJe/Ay/FpxkI4JSNz2n0e06I23b2bleXKVA==", "license": "MIT", "dependencies": { "@expo/config-types": "^55.0.5", @@ -1584,12 +1540,36 @@ "xml2js": "0.6.0" } }, + "node_modules/@expo/config-plugins/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@expo/config-types": { "version": "55.0.5", "resolved": "https://registry.npmmirror.com/@expo/config-types/-/config-types-55.0.5.tgz", "integrity": "sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg==", "license": "MIT" }, + "node_modules/@expo/config/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@expo/devcert": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/@expo/devcert/-/devcert-1.2.1.tgz", @@ -1656,9 +1636,9 @@ } }, "node_modules/@expo/fingerprint": { - "version": "0.16.5", - "resolved": "https://registry.npmmirror.com/@expo/fingerprint/-/fingerprint-0.16.5.tgz", - "integrity": "sha512-mLrcymtgkW9IJ/G1e8MH1Xt2VIb1MOS86ePY0ePcnV3nVyJqm7gfa/AXD1Hk+eZXvf8XhioYz6QZaamBdEzR3A==", + "version": "0.16.6", + "resolved": "https://registry.npmmirror.com/@expo/fingerprint/-/fingerprint-0.16.6.tgz", + "integrity": "sha512-nRITNbnu3RKSHPvKVehrSU4KG2VY9V8nvULOHBw98ukHCAU4bGrU5APvcblOkX3JAap+xEHsg/mZvqlvkLInmQ==", "license": "MIT", "dependencies": { "@expo/env": "^2.0.11", @@ -1677,6 +1657,18 @@ "fingerprint": "bin/cli.js" } }, + "node_modules/@expo/fingerprint/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@expo/image-utils": { "version": "0.8.12", "resolved": "https://registry.npmmirror.com/@expo/image-utils/-/image-utils-0.8.12.tgz", @@ -1692,6 +1684,18 @@ "semver": "^7.6.0" } }, + "node_modules/@expo/image-utils/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@expo/json-file": { "version": "10.0.12", "resolved": "https://registry.npmmirror.com/@expo/json-file/-/json-file-10.0.12.tgz", @@ -1703,12 +1707,12 @@ } }, "node_modules/@expo/local-build-cache-provider": { - "version": "55.0.6", - "resolved": "https://registry.npmmirror.com/@expo/local-build-cache-provider/-/local-build-cache-provider-55.0.6.tgz", - "integrity": "sha512-4kfdv48sKzokijMqi07fINYA9/XprshmPgSLf8i69XgzIv2YdRyBbb70SzrufB7PDneFoltz8N83icW8gOOj1g==", + "version": "55.0.7", + "resolved": "https://registry.npmmirror.com/@expo/local-build-cache-provider/-/local-build-cache-provider-55.0.7.tgz", + "integrity": "sha512-Qg9uNZn1buv4zJUA4ZQaz+ZnKDCipRgjoEg2Gcp8Qfy+2Gq5yZKX4YN1TThCJ01LJk/pvJsCRxXlXZSwdZppgg==", "license": "MIT", "dependencies": { - "@expo/config": "~55.0.8", + "@expo/config": "~55.0.10", "chalk": "^4.1.2" } }, @@ -1751,6 +1755,41 @@ "metro-transform-worker": "0.83.3" } }, + "node_modules/@expo/metro-config": { + "version": "55.0.11", + "resolved": "https://registry.npmmirror.com/@expo/metro-config/-/metro-config-55.0.11.tgz", + "integrity": "sha512-qGxq7RwWpj0zNvZO/e5aizKrOKYYBrVPShSbxPOVB1EXcexxTPTxnOe4pYFg/gKkLIJe0t3jSSF8IDWlGdaaOg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.5", + "@expo/config": "~55.0.10", + "@expo/env": "~2.1.1", + "@expo/json-file": "~10.0.12", + "@expo/metro": "~54.2.0", + "@expo/spawn-async": "^1.7.2", + "browserslist": "^4.25.0", + "chalk": "^4.1.0", + "debug": "^4.3.2", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "hermes-parser": "^0.32.0", + "jsc-safe-url": "^0.2.4", + "lightningcss": "^1.30.1", + "picomatch": "^4.0.3", + "postcss": "~8.4.32", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "expo": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, "node_modules/@expo/metro-runtime": { "version": "55.0.6", "resolved": "https://registry.npmmirror.com/@expo/metro-runtime/-/metro-runtime-55.0.6.tgz", @@ -1801,6 +1840,166 @@ "resolve-workspace-root": "^2.0.0" } }, + "node_modules/@expo/package-manager/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@expo/package-manager/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/@expo/package-manager/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@expo/package-manager/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/ora": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/ora/-/ora-3.4.0.tgz", + "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-spinners": "^2.0.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@expo/package-manager/node_modules/ora/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "license": "MIT", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@expo/plist": { "version": "0.5.2", "resolved": "https://registry.npmmirror.com/@expo/plist/-/plist-0.5.2.tgz", @@ -1812,10 +2011,43 @@ "xmlbuilder": "^15.1.1" } }, + "node_modules/@expo/prebuild-config": { + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/@expo/prebuild-config/-/prebuild-config-55.0.10.tgz", + "integrity": "sha512-AMylDld5G7YJGfEhEyXtgWRuBB83802QBoewF1vJ6NMDtufukuPhMJzOs9E4UXNsjLTaQcgT4yTWhsAWl7o1AQ==", + "license": "MIT", + "dependencies": { + "@expo/config": "~55.0.10", + "@expo/config-plugins": "~55.0.7", + "@expo/config-types": "^55.0.5", + "@expo/image-utils": "^0.8.12", + "@expo/json-file": "^10.0.12", + "@react-native/normalize-colors": "0.83.2", + "debug": "^4.3.1", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "xml2js": "0.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/@expo/prebuild-config/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@expo/require-utils": { - "version": "55.0.2", - "resolved": "https://registry.npmmirror.com/@expo/require-utils/-/require-utils-55.0.2.tgz", - "integrity": "sha512-dV5oCShQ1umKBKagMMT4B/N+SREsQe3lU4Zgmko5AO0rxKV0tynZT6xXs+e2JxuqT4Rz997atg7pki0BnZb4uw==", + "version": "55.0.3", + "resolved": "https://registry.npmmirror.com/@expo/require-utils/-/require-utils-55.0.3.tgz", + "integrity": "sha512-TS1m5tW45q4zoaTlt6DwmdYHxvFTIxoLrTHKOFrIirHIqIXnHCzpceg8wumiBi+ZXSaGY2gobTbfv+WVhJY6Fw==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -1892,24 +2124,6 @@ "excpretty": "build/cli.js" } }, - "node_modules/@expo/xcpretty/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/@expo/xcpretty/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmmirror.com/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -1952,6 +2166,15 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { "version": "5.3.1", "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz", @@ -1961,6 +2184,71 @@ "node": ">=6" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmmirror.com/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -2769,98 +3057,17 @@ "yaml": "^2.2.1" } }, - "node_modules/@react-native-community/cli-doctor/node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "node_modules/@react-native-community/cli-doctor/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "devOptional": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-native-community/cli-doctor/node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-doctor/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@react-native-community/cli-doctor/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-doctor/node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmmirror.com/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-doctor/node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" } }, "node_modules/@react-native-community/cli-platform-android": { @@ -2921,39 +3128,6 @@ "ws": "^6.2.3" } }, - "node_modules/@react-native-community/cli-server-api/node_modules/is-wsl": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-1.1.0.tgz", - "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/@react-native-community/cli-server-api/node_modules/open": { - "version": "6.4.0", - "resolved": "https://registry.npmmirror.com/open/-/open-6.4.0.tgz", - "integrity": "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "is-wsl": "^1.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-native-community/cli-server-api/node_modules/ws": { - "version": "6.2.3", - "resolved": "https://registry.npmmirror.com/ws/-/ws-6.2.3.tgz", - "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "async-limiter": "~1.0.0" - } - }, "node_modules/@react-native-community/cli-tools": { "version": "20.1.2", "resolved": "https://registry.npmmirror.com/@react-native-community/cli-tools/-/cli-tools-20.1.2.tgz", @@ -2973,176 +3147,17 @@ "semver": "^7.5.2" } }, - "node_modules/@react-native-community/cli-tools/node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "node_modules/@react-native-community/cli-tools/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "devOptional": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "devOptional": true, - "license": "MIT", + "license": "ISC", "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmmirror.com/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" + "semver": "bin/semver.js" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" } }, "node_modules/@react-native-community/cli-types": { @@ -3155,79 +3170,17 @@ "joi": "^17.2.1" } }, - "node_modules/@react-native-community/cli/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmmirror.com/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "node_modules/@react-native-community/cli/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "devOptional": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || >=14" - } - }, - "node_modules/@react-native-community/cli/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@react-native/assets-registry": { @@ -3311,6 +3264,30 @@ "@babel/core": "*" } }, + "node_modules/@react-native/babel-preset/node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.32.0.tgz", + "integrity": "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.32.0" + } + }, + "node_modules/@react-native/babel-preset/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/@react-native/babel-preset/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, "node_modules/@react-native/codegen": { "version": "0.83.2", "resolved": "https://registry.npmmirror.com/@react-native/codegen/-/codegen-0.83.2.tgz", @@ -3369,6 +3346,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@react-native/codegen/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/@react-native/codegen/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, "node_modules/@react-native/codegen/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", @@ -3411,6 +3403,18 @@ } } }, + "node_modules/@react-native/community-cli-plugin/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@react-native/debugger-frontend": { "version": "0.83.2", "resolved": "https://registry.npmmirror.com/@react-native/debugger-frontend/-/debugger-frontend-0.83.2.tgz", @@ -3456,6 +3460,55 @@ "node": ">= 20.19.4" } }, + "node_modules/@react-native/dev-middleware/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmmirror.com/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmmirror.com/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@react-native/gradle-plugin": { "version": "0.83.2", "resolved": "https://registry.npmmirror.com/@react-native/gradle-plugin/-/gradle-plugin-0.83.2.tgz", @@ -3481,17 +3534,17 @@ "license": "MIT" }, "node_modules/@react-navigation/bottom-tabs": { - "version": "7.15.2", - "resolved": "https://registry.npmmirror.com/@react-navigation/bottom-tabs/-/bottom-tabs-7.15.2.tgz", - "integrity": "sha512-xaSumZWE97P3j33guO7bh5dJ5IqR1bWiT+i17SUjsXxoI9xnNXWDm4dkTjzGuuT0BHcUVkzei0tjjCQmNg9cIQ==", + "version": "7.15.6", + "resolved": "https://registry.npmmirror.com/@react-navigation/bottom-tabs/-/bottom-tabs-7.15.6.tgz", + "integrity": "sha512-olB+s0ApMzWN9t5Bk5Mj6ntSlVRz3B8v+1LtwGS/29lyC311G5es0kgxyzpGKE9gy6Ef8W526QH5cIka2jh0kQ==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.8", + "@react-navigation/elements": "^2.9.11", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { - "@react-navigation/native": "^7.1.31", + "@react-navigation/native": "^7.1.34", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", @@ -3499,9 +3552,9 @@ } }, "node_modules/@react-navigation/core": { - "version": "7.15.1", - "resolved": "https://registry.npmmirror.com/@react-navigation/core/-/core-7.15.1.tgz", - "integrity": "sha512-Fqr6qxfZJIC4ewho7LtTa9zz6hcOzohX7D1lcDfrkGaYkS5xBwEZViGNxCJK/czUc74ua8NThyrObQFjB6Q/RQ==", + "version": "7.16.2", + "resolved": "https://registry.npmmirror.com/@react-navigation/core/-/core-7.16.2.tgz", + "integrity": "sha512-0dbCC2aTjNW7MvG1fY7zeq6eYvmmaFCEnBDXPuMPJ8uKgfs9lFGXIQFIfBdmcBVX6vHhS+K213VCsuHSIv5jYw==", "license": "MIT", "dependencies": { "@react-navigation/routers": "^7.5.3", @@ -3517,16 +3570,10 @@ "react": ">= 18.2.0" } }, - "node_modules/@react-navigation/core/node_modules/react-is": { - "version": "19.2.4", - "resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.4.tgz", - "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", - "license": "MIT" - }, "node_modules/@react-navigation/elements": { - "version": "2.9.8", - "resolved": "https://registry.npmmirror.com/@react-navigation/elements/-/elements-2.9.8.tgz", - "integrity": "sha512-3gpwUmVnDJYvK9nFmAA/YXw0hmT/C/lZx8RkRMK+ux9l1T+32EWnQFnn34Wa1BMDX8HN2r64yrlW93DIzKI7Uw==", + "version": "2.9.11", + "resolved": "https://registry.npmmirror.com/@react-navigation/elements/-/elements-2.9.11.tgz", + "integrity": "sha512-O5KiwaVCcEVuqZgQ77xiBFSl1sha77rNMTFlLWYnom33ZHPDarV3bM9WNyVnMZxU8ZVTi02X3+ZhO0fSn5QYyg==", "license": "MIT", "dependencies": { "color": "^4.2.3", @@ -3535,7 +3582,7 @@ }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", - "@react-navigation/native": "^7.1.31", + "@react-navigation/native": "^7.1.34", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" @@ -3547,17 +3594,17 @@ } }, "node_modules/@react-navigation/material-top-tabs": { - "version": "7.4.16", - "resolved": "https://registry.npmmirror.com/@react-navigation/material-top-tabs/-/material-top-tabs-7.4.16.tgz", - "integrity": "sha512-Wy/NP17O948/4uUvzpPs5FkvhOCvojBJ320FxW5kUiUbaxde11LUJf9yhDJhScQ7pjHeT0NsMSc1uhbF8O1oFg==", + "version": "7.4.20", + "resolved": "https://registry.npmmirror.com/@react-navigation/material-top-tabs/-/material-top-tabs-7.4.20.tgz", + "integrity": "sha512-a4QToiT0gIfqjG8J/OaCSuEovFUfMyOo4VOMt2haHsymnM9cMXR7UQL4j7MsUOhaPo1wGsJ9QazeA4TcVJWRFA==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.8", + "@react-navigation/elements": "^2.9.11", "color": "^4.2.3", - "react-native-tab-view": "^4.2.2" + "react-native-tab-view": "^4.3.0" }, "peerDependencies": { - "@react-navigation/native": "^7.1.31", + "@react-navigation/native": "^7.1.34", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", @@ -3565,12 +3612,12 @@ } }, "node_modules/@react-navigation/native": { - "version": "7.1.31", - "resolved": "https://registry.npmmirror.com/@react-navigation/native/-/native-7.1.31.tgz", - "integrity": "sha512-+YCUwtfDgsux59Q0LDHc3Zid9ih93ecUCFWZOH6/+eNoUGnWx77wjS6ZfvBO/7E+EiIup11IVShDzCHR4of8hw==", + "version": "7.1.34", + "resolved": "https://registry.npmmirror.com/@react-navigation/native/-/native-7.1.34.tgz", + "integrity": "sha512-zzQ0mKAhLsjTIsaoLfILKZVMObJzE0F+bOi0hl2Glt+1Rd2GtaWJ1Z024c3yLmX+Oc79pqoCQLBXpyxtrZu9NQ==", "license": "MIT", "dependencies": { - "@react-navigation/core": "^7.15.1", + "@react-navigation/core": "^7.16.2", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", @@ -3582,18 +3629,18 @@ } }, "node_modules/@react-navigation/native-stack": { - "version": "7.14.2", - "resolved": "https://registry.npmmirror.com/@react-navigation/native-stack/-/native-stack-7.14.2.tgz", - "integrity": "sha512-/nKxFAFSUSGV+NSXrXXcWEcGAHdyp8RyWjoGMDzVPdBhjCLblVSgHWx5y4mm+k0de9V1pkjsftUaroP7rQckzw==", + "version": "7.14.6", + "resolved": "https://registry.npmmirror.com/@react-navigation/native-stack/-/native-stack-7.14.6.tgz", + "integrity": "sha512-VRlC5mLanRPHK0E15Cild6U01Z5TDPBlmt5YcXRBc+hQTAMbMT9XcSTobf3sJXNY0zzDD1IpSs3Ynex/GU225g==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.8", + "@react-navigation/elements": "^2.9.11", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { - "@react-navigation/native": "^7.1.31", + "@react-navigation/native": "^7.1.34", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", @@ -3672,9 +3719,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.90.20", - "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.90.20.tgz", - "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "version": "5.91.2", + "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.91.2.tgz", + "integrity": "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==", "license": "MIT", "funding": { "type": "github", @@ -3682,12 +3729,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.21", - "resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.90.21.tgz", - "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "version": "5.91.2", + "resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.91.2.tgz", + "integrity": "sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.20" + "@tanstack/query-core": "5.91.2" }, "funding": { "type": "github", @@ -3738,6 +3785,12 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmmirror.com/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmmirror.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3778,9 +3831,9 @@ } }, "node_modules/@types/node": { - "version": "25.3.2", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.3.2.tgz", - "integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==", + "version": "25.5.0", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -3885,6 +3938,15 @@ "node": ">= 0.6" } }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", @@ -3951,29 +4013,6 @@ "strip-ansi": "^5.0.0" } }, - "node_modules/ansi-fragments/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-fragments/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^4.1.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -4011,6 +4050,18 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/appdirsjs": { "version": "1.2.7", "resolved": "https://registry.npmmirror.com/appdirsjs/-/appdirsjs-1.2.7.tgz", @@ -4025,13 +4076,10 @@ "license": "MIT" }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" }, "node_modules/aria-hidden": { "version": "1.2.6", @@ -4144,28 +4192,19 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.15", - "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz", - "integrity": "sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==", + "version": "0.4.17", + "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", - "@babel/helper-define-polyfill-provider": "^0.6.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/babel-plugin-polyfill-corejs3": { "version": "0.13.0", "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", @@ -4180,12 +4219,12 @@ } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.6", - "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.6.tgz", - "integrity": "sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==", + "version": "0.6.8", + "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.6" + "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -4207,12 +4246,12 @@ "license": "MIT" }, "node_modules/babel-plugin-syntax-hermes-parser": { - "version": "0.32.0", - "resolved": "https://registry.npmmirror.com/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.32.0.tgz", - "integrity": "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==", + "version": "0.32.1", + "resolved": "https://registry.npmmirror.com/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.32.1.tgz", + "integrity": "sha512-HgErPZTghW76Rkq9uqn5ESeiD97FbqpZ1V170T1RG2RDp+7pJVQV2pQJs7y5YzN0/gcT6GM5ci9apRnIwuyPdQ==", "license": "MIT", "dependencies": { - "hermes-parser": "0.32.0" + "hermes-parser": "0.32.1" } }, "node_modules/babel-plugin-transform-flow-enums": { @@ -4250,6 +4289,54 @@ "@babel/core": "^7.0.0 || ^8.0.0-0" } }, + "node_modules/babel-preset-expo": { + "version": "55.0.12", + "resolved": "https://registry.npmmirror.com/babel-preset-expo/-/babel-preset-expo-55.0.12.tgz", + "integrity": "sha512-oR46ExGZpRijmPUsr0rFH5X4lR/mvwqJAFXJRLpynZcvyv2pHPTeGMNfd/p5oPMbdbaeMS6G+3k18p48u2Qjbw==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.20.5", + "@babel/helper-module-imports": "^7.25.9", + "@babel/plugin-proposal-decorators": "^7.12.9", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/preset-react": "^7.22.15", + "@babel/preset-typescript": "^7.23.0", + "@react-native/babel-preset": "0.83.2", + "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-react-native-web": "~0.21.0", + "babel-plugin-syntax-hermes-parser": "^0.32.0", + "babel-plugin-transform-flow-enums": "^0.0.2", + "debug": "^4.3.4", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "@babel/runtime": "^7.20.0", + "expo": "*", + "expo-widgets": "^55.0.6", + "react-refresh": ">=0.14.0 <1.0.0" + }, + "peerDependenciesMeta": { + "@babel/runtime": { + "optional": true + }, + "expo": { + "optional": true + }, + "expo-widgets": { + "optional": true + } + } + }, "node_modules/babel-preset-jest": { "version": "29.6.3", "resolved": "https://registry.npmmirror.com/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", @@ -4281,6 +4368,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/barcode-detector": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/barcode-detector/-/barcode-detector-3.1.1.tgz", + "integrity": "sha512-ghWlEAV93ZCUniO7Co3ih/01XPm+U30CV+NoPbO6Chj5lZzHydDAqKlrBEd+37TkoR+QTH3tnnwd8k8epGTfIg==", + "license": "MIT", + "dependencies": { + "zxing-wasm": "3.0.1" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", @@ -4302,9 +4398,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.10.9", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", + "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -4325,6 +4421,18 @@ "node": ">=12.0.0" } }, + "node_modules/better-opn/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/better-opn/node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmmirror.com/open/-/open-8.4.2.tgz", @@ -4388,19 +4496,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/body-parser/node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmmirror.com/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -4581,9 +4676,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001774", - "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", - "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "version": "1.0.30001780", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", "funding": [ { "type": "opencollective", @@ -4634,6 +4729,18 @@ "node": ">=12.13.0" } }, + "node_modules/chrome-launcher/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/chromium-edge-launcher": { "version": "0.2.0", "resolved": "https://registry.npmmirror.com/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz", @@ -4648,6 +4755,18 @@ "rimraf": "^3.0.2" } }, + "node_modules/chromium-edge-launcher/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ci-info": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-2.0.0.tgz", @@ -4655,15 +4774,16 @@ "license": "MIT" }, "node_modules/cli-cursor": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-2.1.0.tgz", - "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "devOptional": true, "license": "MIT", "dependencies": { - "restore-cursor": "^2.0.0" + "restore-cursor": "^3.1.0" }, "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/cli-spinners": { @@ -4698,6 +4818,18 @@ "node": ">=12" } }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/clone": { "version": "1.0.4", "resolved": "https://registry.npmmirror.com/clone/-/clone-1.0.4.tgz", @@ -4775,12 +4907,13 @@ "license": "MIT" }, "node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "version": "9.5.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "devOptional": true, "license": "MIT", "engines": { - "node": ">= 10" + "node": "^12.20.0 || >=14" } }, "node_modules/compressible": { @@ -4828,15 +4961,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/compression/node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", @@ -4890,9 +5014,9 @@ "license": "MIT" }, "node_modules/core-js-compat": { - "version": "3.48.0", - "resolved": "https://registry.npmmirror.com/core-js-compat/-/core-js-compat-3.48.0.tgz", - "integrity": "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==", + "version": "3.49.0", + "resolved": "https://registry.npmmirror.com/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", "license": "MIT", "dependencies": { "browserslist": "^4.28.1" @@ -4929,26 +5053,6 @@ } } }, - "node_modules/cosmiconfig/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "devOptional": true, - "license": "Python-2.0" - }, - "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/cross-fetch": { "version": "3.2.0", "resolved": "https://registry.npmmirror.com/cross-fetch/-/cross-fetch-3.2.0.tgz", @@ -4999,9 +5103,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "version": "1.11.20", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "devOptional": true, "license": "MIT" }, @@ -5141,9 +5245,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "version": "1.5.321", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -5194,13 +5298,6 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/error-ex/node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "devOptional": true, - "license": "MIT" - }, "node_modules/error-stack-parser": { "version": "2.1.4", "resolved": "https://registry.npmmirror.com/error-stack-parser/-/error-stack-parser-2.1.4.tgz", @@ -5355,58 +5452,32 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/execa/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/execa/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/expo": { - "version": "55.0.4", - "resolved": "https://registry.npmmirror.com/expo/-/expo-55.0.4.tgz", - "integrity": "sha512-cbQBPYwmH6FRvh942KR8mSdEcrVdsIMkjdHthtf59zlpzgrk28FabhOdL/Pc9WuS+CsIP3EIQbZqmLkTjv6qPg==", + "version": "55.0.8", + "resolved": "https://registry.npmmirror.com/expo/-/expo-55.0.8.tgz", + "integrity": "sha512-sziDGiDmeRmaSpFwMuSxFhr4vfWrQS1UgVXSTovsUDY0ximABzYdnF5L2OwtD8zjtIww8x2oJGmD6mKS+AoVsw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "55.0.14", - "@expo/config": "~55.0.8", - "@expo/config-plugins": "~55.0.6", + "@expo/cli": "55.0.18", + "@expo/config": "~55.0.10", + "@expo/config-plugins": "~55.0.7", "@expo/devtools": "55.0.2", - "@expo/fingerprint": "0.16.5", - "@expo/local-build-cache-provider": "55.0.6", + "@expo/fingerprint": "0.16.6", + "@expo/local-build-cache-provider": "55.0.7", "@expo/log-box": "55.0.7", "@expo/metro": "~54.2.0", - "@expo/metro-config": "55.0.9", + "@expo/metro-config": "55.0.11", "@expo/vector-icons": "^15.0.2", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~55.0.10", - "expo-asset": "~55.0.8", - "expo-constants": "~55.0.7", - "expo-file-system": "~55.0.10", + "babel-preset-expo": "~55.0.12", + "expo-asset": "~55.0.10", + "expo-constants": "~55.0.9", + "expo-file-system": "~55.0.11", "expo-font": "~55.0.4", "expo-keep-awake": "~55.0.4", - "expo-modules-autolinking": "55.0.8", - "expo-modules-core": "55.0.13", + "expo-modules-autolinking": "55.0.11", + "expo-modules-core": "55.0.17", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.1" @@ -5436,33 +5507,68 @@ } }, "node_modules/expo-application": { - "version": "55.0.8", - "resolved": "https://registry.npmmirror.com/expo-application/-/expo-application-55.0.8.tgz", - "integrity": "sha512-PeZk4Zj8LlzRcRtK3J4ouSPBoi9lroYsRbbz/0HEvx+uB6HIaM1qfzgpcctvjkdJJfnidBQNyieW5BVO/qUQ6w==", + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/expo-application/-/expo-application-55.0.10.tgz", + "integrity": "sha512-5ccf+S6hsQz+doi907TOJxKzV5AKgAgw004z4FoDWSoGhfab0LUPg6uyvOspuU4cbNvqw8EAy08hZbVO8nKc9Q==", "license": "MIT", "peerDependencies": { "expo": "*" } }, - "node_modules/expo-background-fetch": { - "version": "55.0.9", - "resolved": "https://registry.npmmirror.com/expo-background-fetch/-/expo-background-fetch-55.0.9.tgz", - "integrity": "sha512-SIXlLyUsEgqZZ9RZBUAHMGudMYUco2LWbWJkC79PzkedSTRuhQUergFhVR+n+OFe/YYfw2l9Rk2KSwtN+1WcYA==", + "node_modules/expo-asset": { + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/expo-asset/-/expo-asset-55.0.10.tgz", + "integrity": "sha512-wxjNBKIaDyachq7oJgVlWVFzZ6SnNpJFJhkkcymXoTPt5O3XmDM+a6fT91xQQawCXTyZuCc1sNxKMetEofeYkg==", "license": "MIT", "dependencies": { - "expo-task-manager": "~55.0.9" + "@expo/image-utils": "^0.8.12", + "expo-constants": "~55.0.9" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-background-fetch": { + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/expo-background-fetch/-/expo-background-fetch-55.0.10.tgz", + "integrity": "sha512-JRSlQ2GEigUxKqHi11xLPz5/T02O7YzsV1xNKDOxr2SXSERmAN9WFD9l2F9SDw5tUz53ewI1kndHQ6FmSu+7Tw==", + "license": "MIT", + "dependencies": { + "expo-task-manager": "~55.0.10" }, "peerDependencies": { "expo": "*" } }, - "node_modules/expo-constants": { - "version": "55.0.7", - "resolved": "https://registry.npmmirror.com/expo-constants/-/expo-constants-55.0.7.tgz", - "integrity": "sha512-kdcO4TsQRRqt0USvjaY5vgQMO9H52K3kBZ/ejC7F6rz70mv08GoowrZ1CYOr5O4JpPDRlIpQfZJUucaS/c+KWQ==", + "node_modules/expo-camera": { + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/expo-camera/-/expo-camera-55.0.10.tgz", + "integrity": "sha512-ftDNJbGsAPNJ/QrM3j6g8/rQAOqTwZpqtvmzF7V9VX0movaCznZFdYsLi/Fff9WeEk1KzcnLIlmSz4Tj+BCrJA==", "license": "MIT", "dependencies": { - "@expo/config": "~55.0.8", + "barcode-detector": "^3.0.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-constants": { + "version": "55.0.9", + "resolved": "https://registry.npmmirror.com/expo-constants/-/expo-constants-55.0.9.tgz", + "integrity": "sha512-iBiXjZeuU5S/8docQeNzsVvtDy4w0zlmXBpFEi1ypwugceEpdQQab65TVRbusXAcwpNVxCPMpNlDssYp0Pli2g==", + "license": "MIT", + "dependencies": { + "@expo/config": "~55.0.10", "@expo/env": "~2.1.1" }, "peerDependencies": { @@ -5471,15 +5577,15 @@ } }, "node_modules/expo-dev-client": { - "version": "55.0.10", - "resolved": "https://registry.npmmirror.com/expo-dev-client/-/expo-dev-client-55.0.10.tgz", - "integrity": "sha512-qclT+uDp5VjdHDrXkMus0d8ZpNq41CzOXWJq4UmlfsuFeY4b7v/vAI0OJTtScx/FSTkSkggRzjqm+EwnmIFRCg==", + "version": "55.0.18", + "resolved": "https://registry.npmmirror.com/expo-dev-client/-/expo-dev-client-55.0.18.tgz", + "integrity": "sha512-zQeCGk+doTMIKwPe9lNlGcUiBlMpJQNyrrsYc5eRdxuMwBrDVAU7zshARmamSw8zlAIHCsThJH2zeJg/lgQrKA==", "license": "MIT", "dependencies": { - "expo-dev-launcher": "55.0.11", - "expo-dev-menu": "55.0.10", + "expo-dev-launcher": "55.0.19", + "expo-dev-menu": "55.0.16", "expo-dev-menu-interface": "55.0.1", - "expo-manifests": "~55.0.9", + "expo-manifests": "~55.0.11", "expo-updates-interface": "~55.1.3" }, "peerDependencies": { @@ -5487,23 +5593,23 @@ } }, "node_modules/expo-dev-launcher": { - "version": "55.0.11", - "resolved": "https://registry.npmmirror.com/expo-dev-launcher/-/expo-dev-launcher-55.0.11.tgz", - "integrity": "sha512-u/8iVwD4VU2N5R5Tr32+yqT7Llm8K7VyfYB76Fe5apK97Ocpf2PR4Oqf8RbKc9DmqcbHEWfNriMGEW8U6FsaKA==", + "version": "55.0.19", + "resolved": "https://registry.npmmirror.com/expo-dev-launcher/-/expo-dev-launcher-55.0.19.tgz", + "integrity": "sha512-RtiC/K7Cg7RafNlq32GtilMEO5cy61HPBKASQHwLK6Gz5+FYepx0KSHIXIhWqekZMfpcj1w80tvEjMNFjUJ9Dg==", "license": "MIT", "dependencies": { "@expo/schema-utils": "^55.0.2", - "expo-dev-menu": "55.0.10", - "expo-manifests": "~55.0.9" + "expo-dev-menu": "55.0.16", + "expo-manifests": "~55.0.11" }, "peerDependencies": { "expo": "*" } }, "node_modules/expo-dev-menu": { - "version": "55.0.10", - "resolved": "https://registry.npmmirror.com/expo-dev-menu/-/expo-dev-menu-55.0.10.tgz", - "integrity": "sha512-gad31DFkRmEC6pj6sZLIv3HY14PR3X6SUwUTtilArF5eQK/Nr3dWhYFL/QHlrBkwTEDYeKKgcw3V6sZDfE6hcg==", + "version": "55.0.16", + "resolved": "https://registry.npmmirror.com/expo-dev-menu/-/expo-dev-menu-55.0.16.tgz", + "integrity": "sha512-UhduIh/6wQmFLIy1EeQJBCN09irOrC1kf/UMQ9CxXCkXit9SOtJx+26YfCmbrIRV/lYK2qZK8Y178/HNkt7yeA==", "license": "MIT", "dependencies": { "expo-dev-menu-interface": "55.0.1" @@ -5528,9 +5634,9 @@ "license": "MIT" }, "node_modules/expo-file-system": { - "version": "55.0.10", - "resolved": "https://registry.npmmirror.com/expo-file-system/-/expo-file-system-55.0.10.tgz", - "integrity": "sha512-ysFdVdUgtfj2ApY0Cn+pBg+yK4xp+SNwcaH8j2B91JJQ4OXJmnyCSmrNZYz7J4mdYVuv2GzxIP+N/IGlHQG3Yw==", + "version": "55.0.11", + "resolved": "https://registry.npmmirror.com/expo-file-system/-/expo-file-system-55.0.11.tgz", + "integrity": "sha512-KMUd6OY375J9WD79ZvjvCDZMveT7YfgiGWdi58/gfuTBsr14TRuoPk8RRQHAtc4UquzWViKcHwna9aPY7/XPpw==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -5552,9 +5658,9 @@ } }, "node_modules/expo-glass-effect": { - "version": "55.0.7", - "resolved": "https://registry.npmmirror.com/expo-glass-effect/-/expo-glass-effect-55.0.7.tgz", - "integrity": "sha512-G7Q9rUaEY0YC36fGE6irDljfsfvzz/y49zagARAKvSJSyQMUSrhR25WOr5LK5Cw7gQNNBEy9U1ctlr7yCay/fQ==", + "version": "55.0.8", + "resolved": "https://registry.npmmirror.com/expo-glass-effect/-/expo-glass-effect-55.0.8.tgz", + "integrity": "sha512-IvUjHb/4t6r2H/LXDjcQ4uDoHrmO2cLOvEb9leLavQ4HX5+P4LRtQrMDMlkWAn5Wo5DkLcG8+1CrQU2nqgogTA==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -5563,9 +5669,9 @@ } }, "node_modules/expo-haptics": { - "version": "55.0.8", - "resolved": "https://registry.npmmirror.com/expo-haptics/-/expo-haptics-55.0.8.tgz", - "integrity": "sha512-yVR6EsQwl1WuhFITc0PpfI/7dsBdjK/F2YA8xB80UUW9iTa+Tqz21FpH4n/vtbargpzFxkhl5WNYMa419+QWFQ==", + "version": "55.0.9", + "resolved": "https://registry.npmmirror.com/expo-haptics/-/expo-haptics-55.0.9.tgz", + "integrity": "sha512-KCRyHr/uu4syXmoq3aIQ6ahuaX6FGtlPkWGlLlHJ836WF3nG+5+oCaCQiI7qMTpml+Tp/V/zP4ZaowM2KHgLNA==", "license": "MIT", "peerDependencies": { "expo": "*" @@ -5601,9 +5707,9 @@ } }, "node_modules/expo-image-picker": { - "version": "55.0.10", - "resolved": "https://registry.npmmirror.com/expo-image-picker/-/expo-image-picker-55.0.10.tgz", - "integrity": "sha512-uspDWjNBNjUD//MLzu7oEtT9cuNgW5pd6HxPfAJffRIdkdCUCYxLYXRp5pfB6JtW640eCdvm2QEQrQC172Y62g==", + "version": "55.0.13", + "resolved": "https://registry.npmmirror.com/expo-image-picker/-/expo-image-picker-55.0.13.tgz", + "integrity": "sha512-G+W11rcoUi3rK+6cnKWkTfZilMkGVZnYe90TiM3R98nPSlzGBoto3a/TkGGTJXedz/dmMzr49L+STlWhuKKIFw==", "license": "MIT", "dependencies": { "expo-image-loader": "~55.0.0" @@ -5618,10 +5724,20 @@ "integrity": "sha512-aupt/o5PDAb8dXDCb0JcRdkqnTLxe/F+La7jrnyd/sXlYFfRgBJLFOa1SqVFXm1E/Xam1SE/yw6eAb+DGY7Arg==", "license": "MIT" }, + "node_modules/expo-keep-awake": { + "version": "55.0.4", + "resolved": "https://registry.npmmirror.com/expo-keep-awake/-/expo-keep-awake-55.0.4.tgz", + "integrity": "sha512-vwfdMtMS5Fxaon8gC0AiE70SpxTsHJ+rjeoVJl8kdfdbxczF7OIaVmfjFJ5Gfigd/WZiLqxhfZk34VAkXF4PNg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, "node_modules/expo-linear-gradient": { - "version": "55.0.8", - "resolved": "https://registry.npmmirror.com/expo-linear-gradient/-/expo-linear-gradient-55.0.8.tgz", - "integrity": "sha512-nCMgZXcHesnFslH1TUFMdqlDiE4jYTTTSHb97g1jq1gyGX2xDEOyqBxQJmiIVGrZJ+kTWH6RljJ5tYyva9mrAg==", + "version": "55.0.9", + "resolved": "https://registry.npmmirror.com/expo-linear-gradient/-/expo-linear-gradient-55.0.9.tgz", + "integrity": "sha512-S82iF+CVoSBVHdwusLQGh6Th/kcWLHU47jZhBPwyTrYWnsHZtb0oCqU96YvhDYvhbTdsuOaKEi+Xu+r/I2R8ow==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -5630,13 +5746,13 @@ } }, "node_modules/expo-linking": { - "version": "55.0.7", - "resolved": "https://registry.npmmirror.com/expo-linking/-/expo-linking-55.0.7.tgz", - "integrity": "sha512-MiGCedere1vzQTEi2aGrkzd7eh/rPSz4w6F3GMBuAJzYl+/0VhIuyhozpEGrueyDIXWfzaUVOcn3SfxVi+kwQQ==", + "version": "55.0.8", + "resolved": "https://registry.npmmirror.com/expo-linking/-/expo-linking-55.0.8.tgz", + "integrity": "sha512-O9QgKAfEqKfsjL6IKs5p7pFAjo/3/TQwjMzzNPl8BCndbxWMPQfMeViXPYYNS9bA2ujUqrtF1OYhO6woI7GNQQ==", "license": "MIT", "peer": true, "dependencies": { - "expo-constants": "~55.0.7", + "expo-constants": "~55.0.8", "invariant": "^2.2.4" }, "peerDependencies": { @@ -5645,12 +5761,12 @@ } }, "node_modules/expo-manifests": { - "version": "55.0.9", - "resolved": "https://registry.npmmirror.com/expo-manifests/-/expo-manifests-55.0.9.tgz", - "integrity": "sha512-i82j3X4hbxYDe6kxUw4u8WfvbvTj2w+9BD9WKuL0mFRy+MjvdzdyaqAjEViWCKo/alquP/hTApDTQBb3UmWhkg==", + "version": "55.0.11", + "resolved": "https://registry.npmmirror.com/expo-manifests/-/expo-manifests-55.0.11.tgz", + "integrity": "sha512-3+pFun4C9F/eFMVpwZgOBrBWq5sfu7rS1uxTrcg9G7jUFatNe5W6hr+M7z7aQPDf0J1afaSudUZPawx1LLf15w==", "license": "MIT", "dependencies": { - "@expo/config": "~55.0.8", + "@expo/config": "~55.0.10", "expo-json-utils": "~55.0.0" }, "peerDependencies": { @@ -5658,9 +5774,9 @@ } }, "node_modules/expo-media-library": { - "version": "55.0.9", - "resolved": "https://registry.npmmirror.com/expo-media-library/-/expo-media-library-55.0.9.tgz", - "integrity": "sha512-E12e4gjQEZNdAa7MHDLOAiOMQmhmOGHFMMU5DpiK6I01hPXyRcaxgAQQLpibIXNP4O5LjGi2psa3NBacjyjxkw==", + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/expo-media-library/-/expo-media-library-55.0.10.tgz", + "integrity": "sha512-xXmz8Do9BJSt1LrkC6r8l2HAXNdaAr2TdzCuiVo9u47Vuo54twlOgzLpdx4rIzODy/cclqbYb2tae4nCKHJLbw==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -5668,12 +5784,12 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "55.0.8", - "resolved": "https://registry.npmmirror.com/expo-modules-autolinking/-/expo-modules-autolinking-55.0.8.tgz", - "integrity": "sha512-nrWB1pkNp7bR8ECUTgYUiJ2Pyh6AvxCBXZ+lyPlfl1TzEIGhwU1Yqr+d78eJDueXaW+9zKeE0HqrTZoLS3ve4A==", + "version": "55.0.11", + "resolved": "https://registry.npmmirror.com/expo-modules-autolinking/-/expo-modules-autolinking-55.0.11.tgz", + "integrity": "sha512-9dqnPzQoIl1dIvEctMWpQ8eaiXDeBTgAwebCc1WF0BbEo+pcdKjZWoCSqlLj+d7IX+OnTgM+k6cY2kPDGIu4sg==", "license": "MIT", "dependencies": { - "@expo/require-utils": "^55.0.2", + "@expo/require-utils": "^55.0.3", "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0" @@ -5682,10 +5798,19 @@ "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, + "node_modules/expo-modules-autolinking/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/expo-modules-core": { - "version": "55.0.13", - "resolved": "https://registry.npmmirror.com/expo-modules-core/-/expo-modules-core-55.0.13.tgz", - "integrity": "sha512-DYLQTOJAR7jD3M9S0sH9myZaPEtShdicHrPiWcupIXMeMkQxFzErx+adUI8gZPy4AU45BgeGgtaogRfT25iLfw==", + "version": "55.0.17", + "resolved": "https://registry.npmmirror.com/expo-modules-core/-/expo-modules-core-55.0.17.tgz", + "integrity": "sha512-pw3cZiaSlBrqRJUD/pHuMnKGsRTW6XJ255FrjDd3HC4QrqErCnfSQPmz+Sv4Qkelcvd9UGdAewyTqZdFwjLwOw==", "license": "MIT", "dependencies": { "invariant": "^2.2.4" @@ -5696,16 +5821,16 @@ } }, "node_modules/expo-notifications": { - "version": "55.0.10", - "resolved": "https://registry.npmmirror.com/expo-notifications/-/expo-notifications-55.0.10.tgz", - "integrity": "sha512-F+ozrVFthKCwfqz2cXmcqrqwzBMTAwoNBqTZERuFtgc+6I++mweVzLLTtbAy8kBDZ33MA13GKyeq7mw13/rDgw==", + "version": "55.0.13", + "resolved": "https://registry.npmmirror.com/expo-notifications/-/expo-notifications-55.0.13.tgz", + "integrity": "sha512-vbtSBcMkYtNTO+6WKdeOzysOqvtmiq/sQrUKJpYcB75m9hBFcAfI2klpXdUiGg5kMr/ygBmFENSolQt1B9QY8A==", "license": "MIT", "dependencies": { "@expo/image-utils": "^0.8.12", "abort-controller": "^3.0.0", "badgin": "^1.1.5", - "expo-application": "~55.0.8", - "expo-constants": "~55.0.7" + "expo-application": "~55.0.10", + "expo-constants": "~55.0.8" }, "peerDependencies": { "expo": "*", @@ -5714,22 +5839,22 @@ } }, "node_modules/expo-router": { - "version": "55.0.4", - "resolved": "https://registry.npmmirror.com/expo-router/-/expo-router-55.0.4.tgz", - "integrity": "sha512-wLKxc9l3IaE96UJFvwXKi2YYYjYK/VUttwAwcnljaUA2dLgDruNGmjsBS9A+g3aK3lt2/JJRu+cec7ZLJ9r6Wg==", + "version": "55.0.7", + "resolved": "https://registry.npmmirror.com/expo-router/-/expo-router-55.0.7.tgz", + "integrity": "sha512-UdraTi8/1LGCCEnq/3+wEVnM11b4ezFEIvMsWP9ajFvEhFGkcXlQitvSehT2yI5cbBrBaIMP2p/2naBiPyYVyw==", "license": "MIT", "dependencies": { "@expo/metro-runtime": "^55.0.6", "@expo/schema-utils": "^55.0.2", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", - "@react-navigation/bottom-tabs": "^7.10.1", - "@react-navigation/native": "^7.1.28", - "@react-navigation/native-stack": "^7.10.1", + "@react-navigation/bottom-tabs": "^7.15.5", + "@react-navigation/native": "^7.1.33", + "@react-navigation/native-stack": "^7.14.5", "client-only": "^0.0.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", - "expo-glass-effect": "^55.0.7", + "expo-glass-effect": "^55.0.8", "expo-image": "^55.0.6", "expo-server": "^55.0.6", "expo-symbols": "^55.0.5", @@ -5749,11 +5874,11 @@ "peerDependencies": { "@expo/log-box": "55.0.7", "@expo/metro-runtime": "^55.0.6", - "@react-navigation/drawer": "^7.7.2", + "@react-navigation/drawer": "^7.9.4", "@testing-library/react-native": ">= 13.2.0", "expo": "*", - "expo-constants": "^55.0.7", - "expo-linking": "^55.0.7", + "expo-constants": "^55.0.8", + "expo-linking": "^55.0.8", "react": "*", "react-dom": "*", "react-native": "*", @@ -5810,9 +5935,9 @@ } }, "node_modules/expo-sqlite": { - "version": "55.0.10", - "resolved": "https://registry.npmmirror.com/expo-sqlite/-/expo-sqlite-55.0.10.tgz", - "integrity": "sha512-yLQXkwcA0OVSKuL4t+a6vv+H/Klh8147n7hH75AN0MkC48p3Go7+6GM+3SFENeaBUsmOnOS3XSFxMxxj6PIg4g==", + "version": "55.0.11", + "resolved": "https://registry.npmmirror.com/expo-sqlite/-/expo-sqlite-55.0.11.tgz", + "integrity": "sha512-lDGJrE0m1lw/3y1ZSsER2kfXnS+9WzgaKcIFp/RKbTfyhs0v8l86Ulqdr+6peRFOfzi0kdj4Ty0LzE2Adx93tg==", "license": "MIT", "dependencies": { "await-lock": "^2.2.2" @@ -5859,9 +5984,9 @@ } }, "node_modules/expo-system-ui": { - "version": "55.0.9", - "resolved": "https://registry.npmmirror.com/expo-system-ui/-/expo-system-ui-55.0.9.tgz", - "integrity": "sha512-8ygP1B0uFAFI8s7eHY2IcGnE83GhFeZYwHBr/fQ4dSXnc7iVT9zp2PvyTyiDiibQ69dBG+fauMQ4KlPcOO51kQ==", + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/expo-system-ui/-/expo-system-ui-55.0.10.tgz", + "integrity": "sha512-s0Fyf37ma3TaWhh18uocg914Uz0tKPaYBf+mEME7Tp88d3FxOTg+tTv1S9mUe4lJNXY+0KlWFz9J4SMNyjGRWQ==", "license": "MIT", "dependencies": { "@react-native/normalize-colors": "0.83.2", @@ -5879,9 +6004,9 @@ } }, "node_modules/expo-task-manager": { - "version": "55.0.9", - "resolved": "https://registry.npmmirror.com/expo-task-manager/-/expo-task-manager-55.0.9.tgz", - "integrity": "sha512-ABqEua5FCjXmzRB+OGKD2KcQL2gkh57nCwGnFGe5Km+/b+0uxIs0lq0L3PPyoYPs1tAWp5FKfn2jcnz1nbBCVA==", + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/expo-task-manager/-/expo-task-manager-55.0.10.tgz", + "integrity": "sha512-QjlVPiBqiDaALssZAQ9w14quELKQ9DBdV4sfmH6rICUbDvSBFiH91HbGxROXcQEDrU8lrVDObJQBxrJK9ucaaw==", "license": "MIT", "dependencies": { "unimodules-app-loader": "~55.0.2" @@ -5892,9 +6017,9 @@ } }, "node_modules/expo-updates": { - "version": "55.0.12", - "resolved": "https://registry.npmmirror.com/expo-updates/-/expo-updates-55.0.12.tgz", - "integrity": "sha512-20YTlmivT7pU8+jYMQHqHQmFTdNHHfIMXXVuFjQA31qCyAOwR32AvDn30IBpgn1Z7XJTX+Sr6cEoMhqz5IJfww==", + "version": "55.0.15", + "resolved": "https://registry.npmmirror.com/expo-updates/-/expo-updates-55.0.15.tgz", + "integrity": "sha512-UE9Ik56trq//kNeJ/BlC5vOTYdNTvsHwhfWFYMazP1UOQK4lnX59/t0qz8Ut+3aPXZZT7+B6mnbWtic0QqN1wA==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", @@ -5904,7 +6029,7 @@ "chalk": "^4.1.2", "debug": "^4.3.4", "expo-eas-client": "~55.0.2", - "expo-manifests": "~55.0.9", + "expo-manifests": "~55.0.11", "expo-structured-headers": "~55.0.0", "expo-updates-interface": "~55.1.3", "getenv": "^2.0.0", @@ -5937,9 +6062,9 @@ "license": "MIT" }, "node_modules/expo-video": { - "version": "55.0.10", - "resolved": "https://registry.npmmirror.com/expo-video/-/expo-video-55.0.10.tgz", - "integrity": "sha512-L3UXgvGjrJv4ym3PnIGPPQi4LlVHQSh89eYm2Q7Pn9iUy5ce98sAdqPIKV88bfdLAIWfmPecYUoOzozXBPmenw==", + "version": "55.0.11", + "resolved": "https://registry.npmmirror.com/expo-video/-/expo-video-55.0.11.tgz", + "integrity": "sha512-XCXcPfzVYx6CgXlbRPH2sgacsNT82T6+euyyFlZsG+iuF5tUOWnMgToTuCmktAkwKDe70pNonoQI9HtZjSfNXw==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -5948,27 +6073,27 @@ } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "55.0.14", - "resolved": "https://registry.npmmirror.com/@expo/cli/-/cli-55.0.14.tgz", - "integrity": "sha512-glXPSjjLCIz+KX/ezqLTGIF9eTE1lexiCxunvB3loRZNnGeBDGW3eF++cuPKudW26jeC6bqZkcqBG7Lp0Sp9qg==", + "version": "55.0.18", + "resolved": "https://registry.npmmirror.com/@expo/cli/-/cli-55.0.18.tgz", + "integrity": "sha512-3sJwu8KvCvQIXBnhUlHgLBZBe+ZK4Da9R5rgI4znaowJavYWMqzRClLzyE6Kri66WVoMX7Q4HUVIh8prRlO0XA==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", - "@expo/config": "~55.0.8", - "@expo/config-plugins": "~55.0.6", + "@expo/config": "~55.0.10", + "@expo/config-plugins": "~55.0.7", "@expo/devcert": "^1.2.1", "@expo/env": "~2.1.1", "@expo/image-utils": "^0.8.12", "@expo/json-file": "^10.0.12", "@expo/log-box": "55.0.7", "@expo/metro": "~54.2.0", - "@expo/metro-config": "~55.0.9", + "@expo/metro-config": "~55.0.11", "@expo/osascript": "^2.4.2", "@expo/package-manager": "^1.10.3", "@expo/plist": "^0.5.2", - "@expo/prebuild-config": "^55.0.8", - "@expo/require-utils": "^55.0.2", - "@expo/router-server": "^55.0.9", + "@expo/prebuild-config": "^55.0.10", + "@expo/require-utils": "^55.0.3", + "@expo/router-server": "^55.0.11", "@expo/schema-utils": "^55.0.2", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", @@ -6028,31 +6153,10 @@ } } }, - "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/prebuild-config": { - "version": "55.0.8", - "resolved": "https://registry.npmmirror.com/@expo/prebuild-config/-/prebuild-config-55.0.8.tgz", - "integrity": "sha512-VJNJiOmmZgyDnR7JMmc3B8Z0ZepZ17I8Wtw+wAH/2+UCUsFg588XU+bwgYcFGw+is28kwGjY46z43kfufpxOnA==", - "license": "MIT", - "dependencies": { - "@expo/config": "~55.0.8", - "@expo/config-plugins": "~55.0.6", - "@expo/config-types": "^55.0.5", - "@expo/image-utils": "^0.8.12", - "@expo/json-file": "^10.0.12", - "@react-native/normalize-colors": "0.83.2", - "debug": "^4.3.1", - "resolve-from": "^5.0.0", - "semver": "^7.6.0", - "xml2js": "0.6.0" - }, - "peerDependencies": { - "expo": "*" - } - }, "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": { - "version": "55.0.9", - "resolved": "https://registry.npmmirror.com/@expo/router-server/-/router-server-55.0.9.tgz", - "integrity": "sha512-LcCFi+P1qfZOsw0DO4JwNKRxtWt4u2bjTYj0PUe4WVf9NVG/NfUetAXYRbBS6P+gupfM6SC+/bdzdqCWQh7j8g==", + "version": "55.0.11", + "resolved": "https://registry.npmmirror.com/@expo/router-server/-/router-server-55.0.11.tgz", + "integrity": "sha512-Kd8J1OOlFR00DZxn+1KfiQiXZtRut6cj8+ynqHJa7dtt/lTL4tGkYistqmVhpKJ6w886eRY5WivKy7o0ZBFkJA==", "license": "MIT", "dependencies": { "debug": "^4.3.4" @@ -6060,7 +6164,7 @@ "peerDependencies": { "@expo/metro-runtime": "^55.0.6", "expo": "*", - "expo-constants": "^55.0.7", + "expo-constants": "^55.0.9", "expo-font": "^55.0.4", "expo-router": "*", "expo-server": "^55.0.6", @@ -6083,87 +6187,16 @@ } } }, - "node_modules/expo/node_modules/@expo/metro-config": { - "version": "55.0.9", - "resolved": "https://registry.npmmirror.com/@expo/metro-config/-/metro-config-55.0.9.tgz", - "integrity": "sha512-ZJFEfat/+dLUhFyFFWrzMjAqAwwUaJ3RD42QNqR7jh+RVYkAf6XYLynb5qrKJTHI1EcOx4KoO1717yXYYRFDBA==", + "node_modules/expo/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.20.0", - "@babel/generator": "^7.20.5", - "@expo/config": "~55.0.8", - "@expo/env": "~2.1.1", - "@expo/json-file": "~10.0.12", - "@expo/metro": "~54.2.0", - "@expo/spawn-async": "^1.7.2", - "browserslist": "^4.25.0", - "chalk": "^4.1.0", - "debug": "^4.3.2", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "hermes-parser": "^0.32.0", - "jsc-safe-url": "^0.2.4", - "lightningcss": "^1.30.1", - "picomatch": "^4.0.3", - "postcss": "~8.4.32", - "resolve-from": "^5.0.0" + "color-convert": "^1.9.0" }, - "peerDependencies": { - "expo": "*" - }, - "peerDependenciesMeta": { - "expo": { - "optional": true - } - } - }, - "node_modules/expo/node_modules/babel-preset-expo": { - "version": "55.0.10", - "resolved": "https://registry.npmmirror.com/babel-preset-expo/-/babel-preset-expo-55.0.10.tgz", - "integrity": "sha512-aRtW7qJKohGU2V0LUJ6IeP7py3+kVUo9zcc8+v1Kix8jGGuIvqvpo9S6W1Fmn9VFP2DBwkFDLiyzkCZS85urVA==", - "license": "MIT", - "dependencies": { - "@babel/generator": "^7.20.5", - "@babel/helper-module-imports": "^7.25.9", - "@babel/plugin-proposal-decorators": "^7.12.9", - "@babel/plugin-proposal-export-default-from": "^7.24.7", - "@babel/plugin-syntax-export-default-from": "^7.24.7", - "@babel/plugin-transform-class-static-block": "^7.27.1", - "@babel/plugin-transform-export-namespace-from": "^7.25.9", - "@babel/plugin-transform-flow-strip-types": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8", - "@babel/plugin-transform-object-rest-spread": "^7.24.7", - "@babel/plugin-transform-parameters": "^7.24.7", - "@babel/plugin-transform-private-methods": "^7.24.7", - "@babel/plugin-transform-private-property-in-object": "^7.24.7", - "@babel/plugin-transform-runtime": "^7.24.7", - "@babel/preset-react": "^7.22.15", - "@babel/preset-typescript": "^7.23.0", - "@react-native/babel-preset": "0.83.2", - "babel-plugin-react-compiler": "^1.0.0", - "babel-plugin-react-native-web": "~0.21.0", - "babel-plugin-syntax-hermes-parser": "^0.32.0", - "babel-plugin-transform-flow-enums": "^0.0.2", - "debug": "^4.3.4", - "resolve-from": "^5.0.0" - }, - "peerDependencies": { - "@babel/runtime": "^7.20.0", - "expo": "*", - "expo-widgets": "^55.0.2", - "react-refresh": ">=0.14.0 <1.0.0" - }, - "peerDependenciesMeta": { - "@babel/runtime": { - "optional": true - }, - "expo": { - "optional": true - }, - "expo-widgets": { - "optional": true - } + "engines": { + "node": ">=4" } }, "node_modules/expo/node_modules/ci-info": { @@ -6181,41 +6214,164 @@ "node": ">=8" } }, - "node_modules/expo/node_modules/expo-asset": { - "version": "55.0.8", - "resolved": "https://registry.npmmirror.com/expo-asset/-/expo-asset-55.0.8.tgz", - "integrity": "sha512-yEz2svDX67R0yiW2skx6dJmcE0q7sj9ECpGMcxBExMCbctc+nMoZCnjUuhzPl5vhClUsO5HFFXS5vIGmf1bgHQ==", + "node_modules/expo/node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.8.12", - "expo-constants": "~55.0.7" + "restore-cursor": "^2.0.0" }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" + "engines": { + "node": ">=4" } }, - "node_modules/expo/node_modules/expo-keep-awake": { - "version": "55.0.4", - "resolved": "https://registry.npmmirror.com/expo-keep-awake/-/expo-keep-awake-55.0.4.tgz", - "integrity": "sha512-vwfdMtMS5Fxaon8gC0AiE70SpxTsHJ+rjeoVJl8kdfdbxczF7OIaVmfjFJ5Gfigd/WZiLqxhfZk34VAkXF4PNg==", + "node_modules/expo/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*" + "dependencies": { + "color-name": "1.1.3" } }, - "node_modules/expo/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/expo/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/expo/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "license": "MIT", "engines": { - "node": ">=12" + "node": ">=0.8.0" + } + }, + "node_modules/expo/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/expo/node_modules/log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.0.1" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": ">=4" + } + }, + "node_modules/expo/node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/expo/node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/expo/node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/expo/node_modules/ora": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/ora/-/ora-3.4.0.tgz", + "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-spinners": "^2.0.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/expo/node_modules/ora/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/expo/node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "license": "MIT", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/expo/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" } }, "node_modules/expo/node_modules/ws": { @@ -6284,22 +6440,9 @@ "license": "MIT" }, "node_modules/fast-xml-builder": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", - "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", - "devOptional": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/fast-xml-parser": { - "version": "5.4.2", - "resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.4.2.tgz", - "integrity": "sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==", + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "devOptional": true, "funding": [ { @@ -6309,8 +6452,25 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.7", + "resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz", + "integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.1.3", + "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -6378,9 +6538,9 @@ } }, "node_modules/fetch-nodeshim": { - "version": "0.4.8", - "resolved": "https://registry.npmmirror.com/fetch-nodeshim/-/fetch-nodeshim-0.4.8.tgz", - "integrity": "sha512-YW5vG33rabBq6JpYosLNoXoaMN69/WH26MeeX2hkDVjN6UlvRGq3Wkazl9H0kisH95aMu/HtHL64JUvv/+Nv/g==", + "version": "0.4.9", + "resolved": "https://registry.npmmirror.com/fetch-nodeshim/-/fetch-nodeshim-0.4.9.tgz", + "integrity": "sha512-XIQWlB2A4RZ7NebXWGxS0uDMdvRHkiUDTghBVJKFg9yEOd45w/PP8cZANuPf2H08W6Cor3+2n7Q6TTZgAS3Fkw==", "license": "MIT" }, "node_modules/fill-range": { @@ -6437,17 +6597,33 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/finalhandler/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", "license": "MIT", "dependencies": { - "locate-path": "^5.0.0", + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", "path-exists": "^4.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/flow-enums-runtime": { @@ -6749,18 +6925,18 @@ "license": "MIT" }, "node_modules/hermes-estree": { - "version": "0.32.0", - "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.32.0.tgz", - "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "version": "0.32.1", + "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.32.1.tgz", + "integrity": "sha512-ne5hkuDxheNBAikDjqvCZCwihnz0vVu9YsBzAEO1puiyFR4F1+PAz/SiPHSsNTuOveCYGRMX8Xbx4LOubeC0Qg==", "license": "MIT" }, "node_modules/hermes-parser": { - "version": "0.32.0", - "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.32.0.tgz", - "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "version": "0.32.1", + "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.32.1.tgz", + "integrity": "sha512-175dz634X/W5AiwrpLdoMl/MOb17poLHyIqgyExlE8D9zQ1OPnoORnGMB5ltRKnpvQzBjMYvT2rN/sHeIfZW5Q==", "license": "MIT", "dependencies": { - "hermes-estree": "0.32.0" + "hermes-estree": "0.32.1" } }, "node_modules/hoist-non-react-statics": { @@ -6988,9 +7164,10 @@ } }, "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "devOptional": true, "license": "MIT" }, "node_modules/is-core-module": { @@ -7034,12 +7211,13 @@ } }, "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "devOptional": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=4" } }, "node_modules/is-glob": { @@ -7110,15 +7288,13 @@ } }, "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", + "devOptional": true, "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, "engines": { - "node": ">=8" + "node": ">=4" } }, "node_modules/isexe": { @@ -7152,15 +7328,6 @@ "node": ">=8" } }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmmirror.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -7287,6 +7454,18 @@ "node": ">=8" } }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/jest-validate": { "version": "29.7.0", "resolved": "https://registry.npmmirror.com/jest-validate/-/jest-validate-29.7.0.tgz", @@ -7361,13 +7540,12 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -7484,9 +7662,9 @@ "license": "MIT" }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -7499,23 +7677,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -7533,9 +7711,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -7553,9 +7731,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -7573,9 +7751,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -7593,9 +7771,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -7613,9 +7791,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], @@ -7633,9 +7811,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], @@ -7653,9 +7831,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], @@ -7673,9 +7851,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], @@ -7693,9 +7871,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -7713,9 +7891,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -7740,15 +7918,19 @@ "license": "MIT" }, "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "devOptional": true, "license": "MIT", "dependencies": { - "p-locate": "^4.1.0" + "p-locate": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/lodash.debounce": { @@ -7764,86 +7946,20 @@ "license": "MIT" }, "node_modules/log-symbols": { - "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-2.2.0.tgz", - "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "devOptional": true, "license": "MIT", "dependencies": { - "chalk": "^2.0.1" + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" }, "engines": { - "node": ">=4" - } - }, - "node_modules/log-symbols/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" + "node": ">=10" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/log-symbols/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmmirror.com/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/log-symbols/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/log-symbols/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, - "node_modules/log-symbols/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/log-symbols/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/log-symbols/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/logkitty": { @@ -7883,6 +7999,75 @@ "wrap-ansi": "^6.2.0" } }, + "node_modules/logkitty/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/logkitty/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/logkitty/node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -8100,6 +8285,21 @@ "node": ">=20.19.4" } }, + "node_modules/metro-babel-transformer/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/metro-babel-transformer/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, "node_modules/metro-cache": { "version": "0.83.3", "resolved": "https://registry.npmmirror.com/metro-cache/-/metro-cache-0.83.3.tgz", @@ -8300,6 +8500,42 @@ "node": ">=20.19.4" } }, + "node_modules/metro/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/metro/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, + "node_modules/metro/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmmirror.com/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", @@ -8313,22 +8549,35 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "devOptional": true, "license": "MIT", "bin": { "mime": "cli.js" }, "engines": { - "node": ">=4" + "node": ">=4.0.0" } }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -8346,13 +8595,23 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-1.2.0.tgz", - "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { - "node": ">=4" + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" } }, "node_modules/minimatch": { @@ -8422,9 +8681,9 @@ } }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "0.6.4", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -8476,9 +8735,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.36", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "license": "MIT" }, "node_modules/node-stream-zip": { @@ -8519,6 +8778,18 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/npm-package-arg/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmmirror.com/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -8573,9 +8844,9 @@ } }, "node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -8603,167 +8874,101 @@ } }, "node_modules/onetime": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/onetime/-/onetime-2.0.1.tgz", - "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "devOptional": true, "license": "MIT", "dependencies": { - "mimic-fn": "^1.0.0" + "mimic-fn": "^2.1.0" }, "engines": { - "node": ">=4" - } - }, - "node_modules/open": { - "version": "7.4.2", - "resolved": "https://registry.npmmirror.com/open/-/open-7.4.2.tgz", - "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0", - "is-wsl": "^2.1.1" - }, - "engines": { - "node": ">=8" + "node": ">=6" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora": { - "version": "3.4.0", - "resolved": "https://registry.npmmirror.com/ora/-/ora-3.4.0.tgz", - "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "node_modules/open": { + "version": "6.4.0", + "resolved": "https://registry.npmmirror.com/open/-/open-6.4.0.tgz", + "integrity": "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==", + "devOptional": true, "license": "MIT", "dependencies": { - "chalk": "^2.4.2", - "cli-cursor": "^2.1.0", - "cli-spinners": "^2.0.0", - "log-symbols": "^2.2.0", - "strip-ansi": "^5.2.0", + "is-wsl": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmmirror.com/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" }, "engines": { - "node": ">=6" - } - }, - "node_modules/ora/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ora/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" + "node": ">=10" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/ora/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmmirror.com/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/ora/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/ora/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, - "node_modules/ora/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/ora/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", - "engines": { - "node": ">=4" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/ora/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, "license": "MIT", "dependencies": { - "ansi-regex": "^4.1.0" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=6" - } - }, - "node_modules/ora/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "devOptional": true, "license": "MIT", "dependencies": { - "p-try": "^2.0.0" + "yocto-queue": "^0.1.0" }, "engines": { - "node": ">=6" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "devOptional": true, "license": "MIT", "dependencies": { - "p-limit": "^2.2.0" + "p-limit": "^3.0.2" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-try": { @@ -8837,6 +9042,22 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -8878,9 +9099,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -8893,12 +9114,12 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -8996,6 +9217,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/proc-log": { "version": "4.2.0", "resolved": "https://registry.npmmirror.com/proc-log/-/proc-log-4.2.0.tgz", @@ -9150,6 +9377,27 @@ "ws": "^7" } }, + "node_modules/react-devtools-core/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmmirror.com/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "19.2.0", "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.0.tgz", @@ -9181,9 +9429,9 @@ } }, "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "version": "19.2.4", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", "license": "MIT" }, "node_modules/react-native": { @@ -9260,9 +9508,9 @@ } }, "node_modules/react-native-is-edge-to-edge": { - "version": "1.2.1", - "resolved": "https://registry.npmmirror.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", - "integrity": "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==", + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", + "integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==", "license": "MIT", "peerDependencies": { "react": "*", @@ -9325,9 +9573,9 @@ "license": "MIT" }, "node_modules/react-native-reanimated": { - "version": "4.2.1", - "resolved": "https://registry.npmmirror.com/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz", - "integrity": "sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg==", + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/react-native-reanimated/-/react-native-reanimated-4.2.2.tgz", + "integrity": "sha512-o3kKvdD8cVlg12Z4u3jv0MFAt53QV4k7gD9OLwQqU8eZLyd8QvaOjVZIghMZhC2pjP93uUU44PlO5JgF8S4Vxw==", "license": "MIT", "dependencies": { "react-native-is-edge-to-edge": "1.2.1", @@ -9339,6 +9587,16 @@ "react-native-worklets": ">=0.7.0" } }, + "node_modules/react-native-reanimated/node_modules/react-native-is-edge-to-edge": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", + "integrity": "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-reanimated/node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz", @@ -9382,9 +9640,9 @@ "license": "MIT" }, "node_modules/react-native-tab-view": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/react-native-tab-view/-/react-native-tab-view-4.2.2.tgz", - "integrity": "sha512-NXtrG6OchvbGjsvbySJGVocXxo4Y2vA17ph4rAaWtA2jh+AasD8OyikKBRg2SmllEfeQ+GEhcKe8kulHv8BhTg==", + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/react-native-tab-view/-/react-native-tab-view-4.3.0.tgz", + "integrity": "sha512-qPMF75uz/7+MuVG2g+YETdGMzlWZnhC6iI4h/7EBbwIBwNBIBi2z4OA6KhY3IOOBwGHXEIz5IyA6doDqifYBHg==", "license": "MIT", "dependencies": { "use-latest-callback": "^0.2.4" @@ -9572,6 +9830,15 @@ } } }, + "node_modules/react-native/node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.32.0.tgz", + "integrity": "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.32.0" + } + }, "node_modules/react-native/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", @@ -9618,6 +9885,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/react-native/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/react-native/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, "node_modules/react-native/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", @@ -9630,6 +9912,39 @@ "node": "*" } }, + "node_modules/react-native/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmmirror.com/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.14.2.tgz", @@ -9834,16 +10149,17 @@ "license": "MIT" }, "node_modules/restore-cursor": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-2.0.0.tgz", - "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "devOptional": true, "license": "MIT", "dependencies": { - "onetime": "^2.0.0", + "onetime": "^5.1.0", "signal-exit": "^3.0.2" }, "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/reusify": { @@ -9974,9 +10290,9 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.4.4", - "resolved": "https://registry.npmmirror.com/sax/-/sax-1.4.4.tgz", - "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -9989,15 +10305,12 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/send": { @@ -10048,16 +10361,16 @@ "node": ">= 0.8" } }, - "node_modules/send/node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" + "bin": { + "mime": "cli.js" }, "engines": { - "node": ">= 0.8" + "node": ">=4" } }, "node_modules/send/node_modules/statuses": { @@ -10277,6 +10590,12 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmmirror.com/sisteransi/-/sisteransi-1.0.5.tgz", @@ -10337,20 +10656,10 @@ "devOptional": true, "license": "MIT" }, - "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/slugify": { - "version": "1.6.6", - "resolved": "https://registry.npmmirror.com/slugify/-/slugify-1.6.6.tgz", - "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", + "version": "1.6.8", + "resolved": "https://registry.npmmirror.com/slugify/-/slugify-1.6.8.tgz", + "integrity": "sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA==", "license": "MIT", "engines": { "node": ">=8.0.0" @@ -10505,7 +10814,16 @@ "node": ">=8" } }, - "node_modules/strip-ansi": { + "node_modules/string-width/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", @@ -10517,6 +10835,27 @@ "node": ">=8" } }, + "node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz", @@ -10528,9 +10867,9 @@ } }, "node_modules/strnum": { - "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/strnum/-/strnum-2.2.0.tgz", - "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/strnum/-/strnum-2.2.1.tgz", + "integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==", "devOptional": true, "funding": [ { @@ -10589,6 +10928,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/terminal-link": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/terminal-link/-/terminal-link-2.1.1.tgz", @@ -10606,9 +10957,9 @@ } }, "node_modules/terser": { - "version": "5.46.0", - "resolved": "https://registry.npmmirror.com/terser/-/terser-5.46.0.tgz", - "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "version": "5.46.1", + "resolved": "https://registry.npmmirror.com/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -10776,16 +11127,6 @@ "node": ">= 0.6" } }, - "node_modules/type-is/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/type-is/node_modules/mime-types": { "version": "3.0.2", "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", @@ -11158,6 +11499,18 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", @@ -11178,24 +11531,13 @@ } }, "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmmirror.com/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "6.2.3", + "resolved": "https://registry.npmmirror.com/ws/-/ws-6.2.3.tgz", + "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "devOptional": true, "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "dependencies": { + "async-limiter": "~1.0.0" } }, "node_modules/xcode": { @@ -11322,9 +11664,9 @@ } }, "node_modules/zustand": { - "version": "5.0.11", - "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.11.tgz", - "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "version": "5.0.12", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", "license": "MIT", "engines": { "node": ">=12.20.0" @@ -11349,6 +11691,34 @@ "optional": true } } + }, + "node_modules/zxing-wasm": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/zxing-wasm/-/zxing-wasm-3.0.1.tgz", + "integrity": "sha512-3CLj6iaGkpqPWXAB4pIWkFOR63MwqGekpMzaROFKto4dFowiPmLlC56KoMoOSXzqOCOpI5DAvMdB8ku2va6fUg==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "^1.41.5", + "type-fest": "^5.4.4" + }, + "peerDependencies": { + "@types/emscripten": ">=1.39.6" + } + }, + "node_modules/zxing-wasm/node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/screenshots/after-fix.png b/screenshots/after-fix.png deleted file mode 100644 index 2c352368355bb76dc996383facff166a30a7cb03..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 126583 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 diff --git a/screenshots/test-navigation-fix.png b/screenshots/test-navigation-fix.png deleted file mode 100644 index 060a68050f33d4168d51b7bfe6d7a8745acf750c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2877 zcmeAS@N?(olHy`uVBq!ia0y~yU~XYxV2a>i0*Y*1zQ2cof$Ni}i(^Q|oHy4Tc^M2i z7z{U^*`GS0aDJHU|95;03?la!7#v*O7#U8S&|qMYlw@U4P+rWykdh+8(9qMv#4urE zAOnMu5f_7l+o+<^AQ(*rqZwheEEp{gM{9)9N^!JZFxoU4jIBdUVB7cq|NX~qJOMJ; dK&@Y9hNrvOH%M!>yab9cc)I$ztaD0e0sy-%b@l)N diff --git a/src/components/business/QRCodeScanner.tsx b/src/components/business/QRCodeScanner.tsx new file mode 100644 index 0000000..5a007e7 --- /dev/null +++ b/src/components/business/QRCodeScanner.tsx @@ -0,0 +1,251 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Alert, + Modal, + Dimensions, +} from 'react-native'; +import { CameraView, useCameraPermissions } from 'expo-camera'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { RootStackParamList } from '../../navigation/types'; +import { colors, spacing, fontSizes, borderRadius } from '../../theme'; + +const { width, height } = Dimensions.get('window'); + +type NavigationProp = NativeStackNavigationProp; + +interface QRCodeScannerProps { + visible: boolean; + onClose: () => void; +} + +export const QRCodeScanner: React.FC = ({ visible, onClose }) => { + const navigation = useNavigation(); + const [permission, requestPermission] = useCameraPermissions(); + const [scanned, setScanned] = useState(false); + + useEffect(() => { + if (visible) { + // 重置扫描状态,确保每次打开都能重新扫描 + setScanned(false); + if (!permission?.granted) { + requestPermission(); + } + } + }, [visible]); + + const handleBarCodeScanned = ({ type, data }: { type: string; data: string }) => { + if (scanned) return; + setScanned(true); + + // 先关闭扫描界面,类似微信的做法 + onClose(); + + // 解析二维码内容 + // 格式: carrotbbs://qrcode/login?session_id=xxx + if (data.startsWith('carrotbbs://qrcode/login')) { + const sessionId = extractSessionId(data); + if (sessionId) { + // 跳转到确认页面 + navigation.navigate('QRCodeConfirm', { sessionId }); + } else { + Alert.alert('无效的二维码', '无法识别该二维码'); + } + } else { + Alert.alert('无效的二维码', '请扫描网页端的登录二维码'); + } + }; + + const extractSessionId = (url: string): string | null => { + try { + const sessionIdMatch = url.match(/session_id=([^&]+)/); + return sessionIdMatch ? sessionIdMatch[1] : null; + } catch { + return null; + } + }; + + if (!permission?.granted) { + return ( + + + + + + + 扫码登录 + + + + + 需要相机权限才能扫码 + + 授予权限 + + + + + ); + } + + return ( + + + + + + + + + 扫码登录 + + + + + + + + + + + 将二维码放入框内即可扫描 + + + + {}}> + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#000', + }, + camera: { + flex: 1, + }, + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.md, + paddingTop: spacing.xl, + paddingBottom: spacing.md, + }, + closeButton: { + padding: spacing.sm, + }, + headerTitle: { + fontSize: fontSizes.lg, + fontWeight: '600', + color: '#fff', + }, + placeholder: { + width: 40, + }, + scanArea: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + scanFrame: { + width: width * 0.7, + height: width * 0.7, + position: 'relative', + }, + corner: { + position: 'absolute', + width: 20, + height: 20, + borderColor: colors.primary.main, + borderWidth: 3, + }, + topLeft: { + top: 0, + left: 0, + borderRightWidth: 0, + borderBottomWidth: 0, + }, + topRight: { + top: 0, + right: 0, + borderLeftWidth: 0, + borderBottomWidth: 0, + }, + bottomLeft: { + bottom: 0, + left: 0, + borderRightWidth: 0, + borderTopWidth: 0, + }, + bottomRight: { + bottom: 0, + right: 0, + borderLeftWidth: 0, + borderTopWidth: 0, + }, + scanText: { + marginTop: spacing.lg, + fontSize: fontSizes.md, + color: '#fff', + textAlign: 'center', + }, + footer: { + padding: spacing.xl, + alignItems: 'center', + }, + flashButton: { + padding: spacing.md, + backgroundColor: 'rgba(255,255,255,0.2)', + borderRadius: borderRadius.full, + }, + permissionContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: spacing.xl, + }, + permissionText: { + marginTop: spacing.lg, + fontSize: fontSizes.md, + color: '#666', + textAlign: 'center', + }, + permissionButton: { + marginTop: spacing.xl, + paddingVertical: spacing.md, + paddingHorizontal: spacing.xl, + backgroundColor: colors.primary.main, + borderRadius: borderRadius.md, + }, + permissionButtonText: { + color: '#fff', + fontSize: fontSizes.md, + fontWeight: '600', + }, +}); + +export default QRCodeScanner; \ No newline at end of file diff --git a/src/navigation/MainNavigator.tsx b/src/navigation/MainNavigator.tsx index 4d0dbea..9f864c7 100644 --- a/src/navigation/MainNavigator.tsx +++ b/src/navigation/MainNavigator.tsx @@ -22,7 +22,7 @@ import { useAuthStore } from '../stores'; // Deep linking 配置 const linking: LinkingOptions = { - prefixes: [], + prefixes: ['carrotbbs://'], config: { screens: { Auth: { @@ -63,6 +63,7 @@ const linking: LinkingOptions = { CreatePost: 'posts/create', Chat: 'chat/:conversationId', FollowList: 'users/:userId/:type', + QRCodeConfirm: 'qrcode/login/:sessionId', }, }, }; diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx index c996800..05d072a 100644 --- a/src/navigation/RootNavigator.tsx +++ b/src/navigation/RootNavigator.tsx @@ -23,6 +23,7 @@ import { PostDetailScreen } from '../screens/home'; import { UserScreen } from '../screens/profile'; import FollowListScreen from '../screens/profile/FollowListScreen'; import { CreatePostScreen } from '../screens/create'; +import { QRCodeConfirmScreen } from '../screens/auth/QRCodeConfirmScreen'; import { ChatScreen, CreateGroupScreen, @@ -210,6 +211,15 @@ const getAuthenticatedScreens = () => [ animation: 'slide_from_right', }} />, + , ]; export function RootNavigator({ isAuthenticated, isInitializing }: RootNavigatorProps) { diff --git a/src/navigation/SimpleMobileTabNavigator.tsx b/src/navigation/SimpleMobileTabNavigator.tsx index 4045aa6..1591417 100644 --- a/src/navigation/SimpleMobileTabNavigator.tsx +++ b/src/navigation/SimpleMobileTabNavigator.tsx @@ -1,6 +1,6 @@ /** * 简单的移动端 Tab Navigator - * + * * 不使用 React Navigation 的 Tab Navigator,而是使用一个简单的自定义实现 * 这样可以完全避免 React Navigation 状态恢复的问题 */ @@ -15,6 +15,7 @@ import { } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { colors, shadows } from '../theme'; import { useTotalUnreadCount } from '../stores'; @@ -33,6 +34,15 @@ import { ProfileScreen, SettingsScreen, EditProfileScreen, NotificationSettingsS // ==================== 类型定义 ==================== type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; +// Schedule Stack 类型定义 +export type ScheduleStackParamList = { + Schedule: undefined; + CourseDetail: { course: any; relatedCourses?: any[] }; +}; + +// ==================== Stack Navigators ==================== +const ScheduleStack = createNativeStackNavigator(); + // ==================== 常量 ==================== const TAB_BAR_HEIGHT = 64; const TAB_BAR_MARGIN = 14; @@ -41,19 +51,32 @@ const TAB_BAR_FLOATING_MARGIN = 12; // ==================== 组件 ==================== /** - * 简化的 Stack Navigator 包装 + * Schedule Stack Navigator 组件 */ -function SimpleStackNavigator({ - children, - screenName -}: { - children: React.ReactNode; - screenName: string; -}) { +function ScheduleStackNavigatorComponent() { return ( - - {children} - + + + + ); } @@ -83,7 +106,7 @@ export function SimpleMobileTabNavigator() { case 'MessageTab': return ; case 'ScheduleTab': - return ; + return ; case 'ProfileTab': return ; default: diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 450f8ef..c8c7862 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -106,6 +106,7 @@ export type RootStackParamList = { userName?: string; userAvatar?: string | null; }; + QRCodeConfirm: { sessionId: string }; }; // ==================== 全局类型声明 ==================== diff --git a/src/presentation/hooks/responsive/MIGRATION.md b/src/presentation/hooks/responsive/MIGRATION.md deleted file mode 100644 index 7eb181c..0000000 --- a/src/presentation/hooks/responsive/MIGRATION.md +++ /dev/null @@ -1,223 +0,0 @@ -# useResponsive 拆分迁移指南 - -## 概述 - -原有的 `useResponsive.ts` (485行) 已被拆分为多个专注的 hooks,以提高代码的可维护性和性能。 - -## 新目录结构 - -``` -src/presentation/hooks/responsive/ -├── types.ts # 共享类型定义 -├── useBreakpoint.ts # 断点检测 -├── useScreenSize.ts # 屏幕尺寸 -├── useOrientation.ts # 方向检测 -├── usePlatform.ts # 平台检测 -├── useResponsiveValue.ts # 响应式值映射 -├── useResponsiveSpacing.ts # 响应式间距 -├── useColumnCount.ts # 列数计算 -├── useMediaQuery.ts # 媒体查询 -├── useBreakpointCheck.ts # 断点范围检查 (兼容层) -├── useResponsive.ts # 兼容层 (保持向后兼容) -└── index.ts # 统一导出 -``` - -## 新 Hook API 说明 - -### useBreakpoint - 断点检测 - -```typescript -import { useBreakpoint, useFineBreakpoint } from '../presentation/hooks/responsive'; - -// 基础断点: 'mobile' | 'tablet' | 'desktop' | 'wide' -const breakpoint = useBreakpoint(); - -// 细粒度断点: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' -const fineBreakpoint = useFineBreakpoint(); -``` - -### useScreenSize - 屏幕尺寸 - -```typescript -import { useScreenSize, useWindowDimensions } from '../presentation/hooks/responsive'; - -// 完整屏幕尺寸信息 -const { - width, height, - breakpoint, fineBreakpoint, - isMobile, isTablet, isDesktop, isWide -} = useScreenSize(); - -// 仅获取窗口尺寸 -const { width, height, scale, fontScale } = useWindowDimensions(); -``` - -### useOrientation - 方向检测 - -```typescript -import { useOrientation } from '../presentation/hooks/responsive'; - -const { orientation, isPortrait, isLandscape } = useOrientation(); -``` - -### usePlatform - 平台检测 - -```typescript -import { usePlatform } from '../presentation/hooks/responsive'; - -const { OS, isWeb, isIOS, isAndroid, isNative } = usePlatform(); -``` - -### useResponsiveValue - 响应式值 - -```typescript -import { useResponsiveValue } from '../presentation/hooks/responsive'; - -// 根据断点返回不同值 -const padding = useResponsiveValue({ xs: 8, md: 16, lg: 24 }); - -// 响应式样式 -const style = useResponsiveStyle({ - padding: { xs: 8, md: 16, lg: 24 }, - fontSize: { xs: 14, lg: 16 } -}); -``` - -### useResponsiveSpacing - 响应式间距 - -```typescript -import { useResponsiveSpacing } from '../presentation/hooks/responsive'; - -const gap = useResponsiveSpacing({ xs: 8, md: 16, lg: 24 }); -``` - -### useColumnCount - 列数计算 - -```typescript -import { useColumnCount } from '../presentation/hooks/responsive'; - -const columns = useColumnCount({ xs: 1, sm: 2, md: 3, lg: 4 }); -``` - -### useBreakpointGTE / useBreakpointLT - 断点范围检查 - -```typescript -import { useBreakpointGTE, useBreakpointLT, useBreakpointBetween } from '../presentation/hooks/responsive'; - -const isMediumUp = useBreakpointGTE('md'); -const isMobileOnly = useBreakpointLT('lg'); -const isTabletRange = useBreakpointBetween('md', 'lg'); -``` - -### useMediaQuery - 媒体查询 - -```typescript -import { useMediaQuery } from '../presentation/hooks/responsive'; - -const isMinWidth768 = useMediaQuery({ minWidth: 768 }); -const isPortrait = useMediaQuery({ orientation: 'portrait' }); -``` - -## 迁移示例 - -### 示例 1: 使用 useResponsive (旧) - -```typescript -import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../hooks'; - -function Component() { - const { width, isMobile, isTablet, isDesktop, isWide } = useResponsive(); - const padding = useResponsiveSpacing({ xs: 8, md: 16 }); - - return ; -} -``` - -### 示例 2: 使用新专注 hooks (推荐) - -```typescript -import { - useScreenSize, - useResponsiveSpacing -} from '../presentation/hooks/responsive'; - -function Component() { - const { width, isMobile, isTablet, isDesktop, isWide } = useScreenSize(); - const padding = useResponsiveSpacing({ xs: 8, md: 16 }); - - return ; -} -``` - -### 示例 3: 仅使用部分功能 (性能优化) - -```typescript -import { useBreakpoint, usePlatform } from '../presentation/hooks/responsive'; - -function Component() { - // 只订阅断点变化,不订阅尺寸变化 - const breakpoint = useBreakpoint(); - const { isIOS } = usePlatform(); // 平台不会变化,只计算一次 - - return ; -} -``` - -## 性能优势 - -1. **按需订阅**: 只使用需要的功能,避免不必要的重渲染 -2. **细粒度更新**: 每个 hook 独立管理状态 -3. **平台常量**: usePlatform 返回常量,不会触发重渲染 - -## 向后兼容 - -原有的 `useResponsive` 仍然可用,但标记为 `@deprecated`: - -```typescript -import { useResponsive } from '../hooks'; // 仍然可用 - -function Component() { - const { width, height, isMobile, platform } = useResponsive(); // 仍然可用 - return ; -} -``` - -## 工具函数 - -```typescript -import { - getBreakpoint, - getFineBreakpoint, - isBreakpointGTE, - isBreakpointLT -} from '../presentation/hooks/responsive'; - -// 非 hooks 版本,用于工具函数 -const breakpoint = getBreakpoint(800); -const fineBreakpoint = getFineBreakpoint(800); -const isGTE = isBreakpointGTE('md', 'sm'); -``` - -## 类型导出 - -```typescript -import type { - BreakpointKey, - FineBreakpointKey, - ResponsiveValue, - Orientation, - PlatformInfo, - ScreenSize, - MediaQueryOptions, - ResponsiveInfo, // 兼容层 -} from '../presentation/hooks/responsive'; -``` - -## 常量 - -```typescript -import { BREAKPOINTS, FINE_BREAKPOINTS } from '../presentation/hooks/responsive'; - -// BREAKPOINTS = { mobile: 0, tablet: 768, desktop: 1024, wide: 1440 } -// FINE_BREAKPOINTS = { xs: 0, sm: 375, md: 414, lg: 768, xl: 1024, '2xl': 1280, '3xl': 1440, '4xl': 1920 } -``` diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx index 711bcfb..000e7e6 100644 --- a/src/screens/auth/LoginScreen.tsx +++ b/src/screens/auth/LoginScreen.tsx @@ -188,7 +188,7 @@ export const LoginScreen: React.FC = () => { {/* 品牌名称 - 优化大屏排版 */} - 胡萝卜 + 萝卜社区 发现有趣的内容,结识志同道合的朋友 {/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */} @@ -431,7 +431,7 @@ export const LoginScreen: React.FC = () => { color="#FFF" /> - 胡萝卜 + 萝卜社区 发现有趣的内容,结识志同道合的朋友 diff --git a/src/screens/auth/QRCodeConfirmScreen.tsx b/src/screens/auth/QRCodeConfirmScreen.tsx new file mode 100644 index 0000000..6d6ce38 --- /dev/null +++ b/src/screens/auth/QRCodeConfirmScreen.tsx @@ -0,0 +1,288 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Image, + ActivityIndicator, + Alert, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useRoute, useNavigation, RouteProp } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { RootStackParamList } from '../../navigation/types'; +import { qrcodeApi } from '../../services/authService'; +import { colors, spacing, fontSizes, borderRadius } from '../../theme'; + +type QRCodeConfirmRouteProp = RouteProp; +type NavigationProp = NativeStackNavigationProp; + +export const QRCodeConfirmScreen: React.FC = () => { + const route = useRoute(); + const navigation = useNavigation(); + const { sessionId } = route.params; + + const [loading, setLoading] = useState(false); + const [userInfo, setUserInfo] = useState<{ id: string; nickname: string; avatar: string } | null>(null); + const [error, setError] = useState(null); + + useEffect(() => { + // 扫码 + handleScan(); + }, []); + + const handleScan = async () => { + try { + setLoading(true); + const response = await qrcodeApi.scan(sessionId); + setUserInfo(response.user); + } catch (err: any) { + const errorMsg = err.response?.data?.message || '扫码失败'; + setError(errorMsg); + Alert.alert('扫码失败', errorMsg, [ + { text: '确定', onPress: () => navigation.goBack() } + ]); + } finally { + setLoading(false); + } + }; + + const handleConfirm = async () => { + try { + setLoading(true); + await qrcodeApi.confirm(sessionId); + Alert.alert('登录成功', '网页端已登录', [ + { text: '确定', onPress: () => navigation.goBack() } + ]); + } catch (err: any) { + const errorMsg = err.response?.data?.message || '确认登录失败'; + Alert.alert('登录失败', errorMsg); + } finally { + setLoading(false); + } + }; + + const handleCancel = async () => { + try { + await qrcodeApi.cancel(sessionId); + } catch (err) { + // 忽略取消错误 + } + navigation.goBack(); + }; + + if (loading && !userInfo) { + return ( + + + + 正在扫码... + + + ); + } + + if (error) { + return ( + + + + {error} + navigation.goBack()}> + 返回 + + + + ); + } + + return ( + + + + + + 确认登录 + + + + + + 您正在登录网页端 + + {userInfo && ( + + {userInfo.avatar ? ( + + ) : ( + + + + )} + {userInfo.nickname} + + )} + + + + + {loading ? ( + + ) : ( + 确认登录 + )} + + + + 取消 + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: '#fff', + borderBottomWidth: 1, + borderBottomColor: '#eee', + }, + closeButton: { + padding: spacing.sm, + }, + headerTitle: { + fontSize: fontSizes.lg, + fontWeight: '600', + color: '#333', + }, + placeholder: { + width: 40, + }, + content: { + flex: 1, + padding: spacing.lg, + justifyContent: 'center', + }, + infoCard: { + backgroundColor: '#fff', + borderRadius: borderRadius.lg, + padding: spacing.xl, + alignItems: 'center', + marginBottom: spacing.xl, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + infoText: { + fontSize: fontSizes.md, + color: '#666', + marginBottom: spacing.lg, + }, + userInfo: { + alignItems: 'center', + }, + avatar: { + width: 80, + height: 80, + borderRadius: 40, + marginBottom: spacing.md, + }, + avatarPlaceholder: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: '#f0f0f0', + justifyContent: 'center', + alignItems: 'center', + marginBottom: spacing.md, + }, + nickname: { + fontSize: fontSizes.lg, + fontWeight: '600', + color: '#333', + }, + buttonContainer: { + gap: spacing.md, + }, + button: { + paddingVertical: spacing.md, + paddingHorizontal: spacing.xl, + borderRadius: borderRadius.md, + alignItems: 'center', + }, + confirmButton: { + backgroundColor: colors.primary.main, + }, + confirmButtonText: { + color: '#fff', + fontSize: fontSizes.md, + fontWeight: '600', + }, + cancelButton: { + backgroundColor: '#fff', + borderWidth: 1, + borderColor: '#ddd', + }, + cancelButtonText: { + color: '#666', + fontSize: fontSizes.md, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: spacing.md, + fontSize: fontSizes.md, + color: '#666', + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: spacing.xl, + }, + errorText: { + marginTop: spacing.md, + fontSize: fontSizes.md, + color: '#666', + textAlign: 'center', + }, + backButton: { + marginTop: spacing.xl, + paddingVertical: spacing.md, + paddingHorizontal: spacing.xl, + backgroundColor: colors.primary.main, + borderRadius: borderRadius.md, + }, + backButtonText: { + color: '#fff', + fontSize: fontSizes.md, + fontWeight: '600', + }, +}); + +export default QRCodeConfirmScreen; \ No newline at end of file diff --git a/src/screens/auth/RegisterScreen.tsx b/src/screens/auth/RegisterScreen.tsx index 12f9794..d6fa740 100644 --- a/src/screens/auth/RegisterScreen.tsx +++ b/src/screens/auth/RegisterScreen.tsx @@ -268,8 +268,8 @@ export const RegisterScreen: React.FC = () => { {/* 品牌名称 - 优化大屏排版 */} - 创建账号 - 加入胡萝卜,开始你的旅程 + 加入萝卜社区 + 加入萝卜社区,发现有趣的内容 {/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */} @@ -720,8 +720,8 @@ export const RegisterScreen: React.FC = () => { color="#FFF" /> - 创建账号 - 加入胡萝卜,开始你的旅程 + 加入萝卜社区 + 加入萝卜社区,发现有趣的内容 {/* 表单 */} diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index 3bf4b36..5dafe28 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -19,15 +19,15 @@ * - ChatInput.tsx: 输入框组件 */ -import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { View, - FlatList, TouchableWithoutFeedback, ActivityIndicator, KeyboardAvoidingView, Platform, } from 'react-native'; +import { FlashList } from "@shopify/flash-list"; import { useNavigation } from '@react-navigation/native'; import { StatusBar } from 'expo-status-bar'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -224,6 +224,7 @@ export const ChatScreen: React.FC = () => { formatTime={formatTime} shouldShowTime={shouldShowTime} onImagePress={handleImagePress} + onReply={handleReplyMessage} /> ); @@ -268,25 +269,23 @@ export const ChatScreen: React.FC = () => { ) : ( - String(item.id)} - contentContainerStyle={listContentStyle} + keyExtractor={(item: any) => String(item.id)} + contentContainerStyle={listContentStyle as any} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" refreshing={loadingMore} onRefresh={hasMoreHistory ? loadMoreHistory : undefined} - progressViewOffset={0} - onContentSizeChange={handleMessageListContentSizeChange} + // @ts-ignore FlashList 类型定义问题:estimatedItemSize 是必需属性但类型定义缺失 + estimatedItemSize={80} + maintainVisibleContentPosition={{ + minIndexForVisible: 0, + autoscrollToTopThreshold: undefined, + } as any} onScroll={handleMessageListScroll} - scrollEventThrottle={16} - // 优化渲染性能 - initialNumToRender={15} - maxToRenderPerBatch={10} - windowSize={10} - removeClippedSubviews={true} /> )} diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index 217ca1d..943538f 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -42,6 +42,8 @@ import { getUserCache } from '../../services/database'; import { EmbeddedChat } from './components/EmbeddedChat'; // 导入 NotificationsScreen 用于在内部显示 import { NotificationsScreen } from './NotificationsScreen'; +// 导入扫码组件 +import { QRCodeScanner } from '../../components/business/QRCodeScanner'; type NavigationProp = NativeStackNavigationProp; type MessageNavProp = NativeStackNavigationProp; @@ -182,6 +184,7 @@ export const MessageListScreen: React.FC = () => { const [isSearching, setIsSearching] = useState(false); const [activeSearchTab, setActiveSearchTab] = useState<'chat' | 'user'>('chat'); const [actionMenuVisible, setActionMenuVisible] = useState(false); + const [scannerVisible, setScannerVisible] = useState(false); // 系统通知显示状态 - 用于在移动端显示通知页面 const [showNotifications, setShowNotifications] = useState(false); @@ -356,7 +359,7 @@ export const MessageListScreen: React.FC = () => { setActionMenuVisible(true); }; - const runMenuAction = (action: 'join' | 'create' | 'readAll') => { + const runMenuAction = (action: 'join' | 'create' | 'readAll' | 'scanQRCode') => { setActionMenuVisible(false); if (action === 'join') { handleJoinGroup(); @@ -366,6 +369,10 @@ export const MessageListScreen: React.FC = () => { handleCreateGroup(); return; } + if (action === 'scanQRCode') { + setScannerVisible(true); + return; + } handleMarkAllRead(); }; @@ -911,6 +918,10 @@ export const MessageListScreen: React.FC = () => { setActionMenuVisible(false)}> setActionMenuVisible(false)}> + runMenuAction('scanQRCode')}> + + 扫码登录 + runMenuAction('join')}> 加入群聊 @@ -980,6 +991,7 @@ export const MessageListScreen: React.FC = () => { renderConversationList() )} {renderActionMenu()} + setScannerVisible(false)} /> ); }; diff --git a/src/screens/message/components/ChatScreen/MessageBubble.tsx b/src/screens/message/components/ChatScreen/MessageBubble.tsx index 8f39a5b..bd1ada6 100644 --- a/src/screens/message/components/ChatScreen/MessageBubble.tsx +++ b/src/screens/message/components/ChatScreen/MessageBubble.tsx @@ -4,7 +4,7 @@ * 支持响应式布局(宽屏下优化显示) */ -import React, { useRef, useMemo } from 'react'; +import React, { useRef, useMemo, useCallback } from 'react'; import { View, TouchableOpacity, @@ -22,6 +22,14 @@ import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive import { MessageBubbleProps, SenderInfo, MenuPosition, GroupMessage } from './types'; import { MessageSegmentsRenderer } from './SegmentRenderer'; import { MessageSegment, ImageSegmentData, extractTextFromSegments } from '../../../../types/dto'; +import { SwipeableMessageBubble } from './SwipeableMessageBubble'; +import { + getBubbleBorderRadius, + getMessageGroupPosition, + shouldShowSenderInfo, + bubbleColors, + bubbleStyles, +} from './bubbleStyles'; // 获取屏幕宽度 const { width: SCREEN_WIDTH } = Dimensions.get('window'); @@ -50,6 +58,7 @@ export const MessageBubble: React.FC = ({ formatTime, shouldShowTime, onImagePress, + onReply, }) => { const bubbleRef = useRef(null); const pressPositionRef = useRef({ x: 0, y: 0 }); @@ -343,6 +352,13 @@ export const MessageBubble: React.FC = ({ ); }; + // 处理滑动回复 + const handleSwipeReply = useCallback(() => { + if (onReply) { + onReply(message); + } + }, [onReply, message]); + // 系统通知消息渲染 if (isSystemNotice) { return ( @@ -361,7 +377,8 @@ export const MessageBubble: React.FC = ({ ); } - return ( + // 消息内容渲染 + const messageContent = ( = ({ ); + + // 使用 SwipeableMessageBubble 包裹消息内容 + return ( + + {messageContent} + + ); }; // Segment 消息的特殊样式 - 自适应宽度 @@ -460,4 +488,16 @@ const segmentStyles = StyleSheet.create({ }, }); -export default MessageBubble; +// 使用 React.memo 优化渲染性能 +export default React.memo(MessageBubble, (prevProps, nextProps) => { + // 自定义比较逻辑:只比较关键属性 + return ( + prevProps.message.id === nextProps.message.id && + prevProps.message.status === nextProps.message.status && + prevProps.selectedMessageId === nextProps.selectedMessageId && + prevProps.currentUserId === nextProps.currentUserId && + prevProps.isGroupChat === nextProps.isGroupChat && + prevProps.otherUserLastReadSeq === nextProps.otherUserLastReadSeq && + prevProps.index === nextProps.index + ); +}); diff --git a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx index a7d92e0..a8eaa7c 100644 --- a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx +++ b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx @@ -290,6 +290,7 @@ const ImageSegment: React.FC<{ contentFit="cover" cachePolicy="disk" priority="normal" + transition={200} onError={() => { setLoadError(true); }} diff --git a/src/screens/message/components/ChatScreen/SwipeableMessageBubble.tsx b/src/screens/message/components/ChatScreen/SwipeableMessageBubble.tsx new file mode 100644 index 0000000..21dd0b6 --- /dev/null +++ b/src/screens/message/components/ChatScreen/SwipeableMessageBubble.tsx @@ -0,0 +1,157 @@ +/** + * 可滑动的消息气泡容器 + * 实现滑动回复手势功能 - 严格单向滑动,无抖动 + */ + +import React, { useRef, useCallback } from 'react'; +import { View, StyleSheet, Animated } from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { PanGestureHandler, State } from 'react-native-gesture-handler'; +import * as Haptics from 'expo-haptics'; +import { colors } from '../../../../theme'; + +// 滑动阈值 +const SWIPE_THRESHOLD = 30; +const MAX_SWIPE_DISTANCE = 50; + +interface SwipeableMessageBubbleProps { + children: React.ReactNode; + isMe: boolean; + onReply: () => void; + enabled?: boolean; +} + +export const SwipeableMessageBubble: React.FC = ({ + children, + isMe, + onReply, + enabled = true, +}) => { + const translateX = useRef(new Animated.Value(0)).current; + const hasTriggeredRef = useRef(false); + + // 处理手势事件 - 使用原生驱动,不干预值 + const onGestureEvent = Animated.event( + [{ nativeEvent: { translationX: translateX } }], + { useNativeDriver: true } + ); + + // 处理手势状态变化 + const onHandlerStateChange = useCallback((event: any) => { + const { nativeEvent } = event; + + if (nativeEvent.state === State.END) { + const translationX = nativeEvent.translationX; + + // 只允许特定方向的滑动 + const shouldTrigger = isMe + ? translationX < -SWIPE_THRESHOLD // 自己消息只能向左滑 + : translationX > SWIPE_THRESHOLD; // 对方消息只能向右滑 + + if (shouldTrigger && !hasTriggeredRef.current) { + hasTriggeredRef.current = true; + + // 触觉反馈 + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + + // 立即触发回复 + onReply(); + + // 重置标记 + setTimeout(() => { + hasTriggeredRef.current = false; + }, 300); + } + + // 立即回到原位 + translateX.setValue(0); + } + }, [isMe, onReply]); + + // 计算回复图标的透明度 - 只在允许的方向显示 + const iconOpacity = translateX.interpolate({ + inputRange: isMe + ? [-MAX_SWIPE_DISTANCE, -SWIPE_THRESHOLD, 0, 10] + : [-10, 0, SWIPE_THRESHOLD, MAX_SWIPE_DISTANCE], + outputRange: isMe ? [1, 0.8, 0, 0] : [0, 0, 0.8, 1], + extrapolate: 'clamp', + }); + + // 计算消息的位移 - 限制在允许的方向 + const clampedTranslateX = translateX.interpolate({ + inputRange: isMe + ? [-MAX_SWIPE_DISTANCE - 10, -MAX_SWIPE_DISTANCE, 0, 10] + : [-10, 0, MAX_SWIPE_DISTANCE, MAX_SWIPE_DISTANCE + 10], + outputRange: isMe + ? [-MAX_SWIPE_DISTANCE, -MAX_SWIPE_DISTANCE, 0, 0] + : [0, 0, MAX_SWIPE_DISTANCE, MAX_SWIPE_DISTANCE], + extrapolate: 'clamp', + }); + + // 如果禁用手势,直接返回子元素 + if (!enabled) { + return <>{children}; + } + + return ( + + {/* 回复图标 - 在消息后面 */} + + + + + {/* 可滑动的消息内容 */} + + + {children} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: 'relative', + }, + replyIconContainer: { + position: 'absolute', + top: '50%', + marginTop: -15, + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: 'rgba(255, 107, 53, 0.1)', + alignItems: 'center', + justifyContent: 'center', + }, + replyIconLeft: { + left: 8, + }, + replyIconRight: { + right: 8, + }, +}); + +export default SwipeableMessageBubble; \ No newline at end of file diff --git a/src/screens/message/components/ChatScreen/bubbleStyles.ts b/src/screens/message/components/ChatScreen/bubbleStyles.ts new file mode 100644 index 0000000..1739007 --- /dev/null +++ b/src/screens/message/components/ChatScreen/bubbleStyles.ts @@ -0,0 +1,270 @@ +/** + * 消息气泡样式工具 + * 参考 Element X 设计,实现动态圆角和现代化气泡样式 + */ + +import { StyleSheet, ViewStyle, TextStyle } from 'react-native'; +import { colors, spacing } from '../../../../theme'; + +// 气泡圆角大小(参考 Element X: 12pt) +export const BUBBLE_RADIUS = 16; + +// 气泡颜色方案(参考 Element X 的语义化颜色) +export const bubbleColors = { + // 自己发送的消息 + outgoing: { + background: '#E8E8E8', // Element X 风格:灰色系 + text: '#1A1A1A', + }, + // 收到的消息 + incoming: { + background: '#FFFFFF', + text: '#1A1A1A', + }, + // 被回复的消息高亮 + replyHighlight: { + background: 'rgba(255, 107, 53, 0.08)', + borderLeft: '#FF6B35', + }, +}; + +// 消息在组中的位置类型 +export type MessageGroupPosition = 'single' | 'first' | 'middle' | 'last'; + +/** + * 根据消息在组中的位置获取圆角样式 + * 参考 Element X 的动态圆角逻辑 + */ +export const getBubbleBorderRadius = ( + isMe: boolean, + position: MessageGroupPosition +): ViewStyle => { + const radius = BUBBLE_RADIUS; + + if (isMe) { + // 自己发送的消息 + switch (position) { + case 'single': + return { + borderTopLeftRadius: radius, + borderTopRightRadius: radius, + borderBottomLeftRadius: radius, + borderBottomRightRadius: 4, // 尖角在右下 + }; + case 'first': + return { + borderTopLeftRadius: radius, + borderTopRightRadius: radius, + borderBottomLeftRadius: radius, + borderBottomRightRadius: 4, + }; + case 'middle': + return { + borderTopLeftRadius: radius, + borderTopRightRadius: 4, + borderBottomLeftRadius: radius, + borderBottomRightRadius: 4, + }; + case 'last': + return { + borderTopLeftRadius: radius, + borderTopRightRadius: 4, + borderBottomLeftRadius: radius, + borderBottomRightRadius: radius, + }; + } + } else { + // 收到的消息 + switch (position) { + case 'single': + return { + borderTopLeftRadius: 4, // 尖角在左上 + borderTopRightRadius: radius, + borderBottomLeftRadius: radius, + borderBottomRightRadius: radius, + }; + case 'first': + return { + borderTopLeftRadius: 4, + borderTopRightRadius: radius, + borderBottomLeftRadius: 4, + borderBottomRightRadius: radius, + }; + case 'middle': + return { + borderTopLeftRadius: 4, + borderTopRightRadius: radius, + borderBottomLeftRadius: 4, + borderBottomRightRadius: radius, + }; + case 'last': + return { + borderTopLeftRadius: radius, + borderTopRightRadius: radius, + borderBottomLeftRadius: radius, + borderBottomRightRadius: radius, + }; + } + } +}; + +/** + * 判断消息在组中的位置 + */ +export const getMessageGroupPosition = ( + index: number, + messages: Array<{ sender_id: string }>, + currentUserId: string +): MessageGroupPosition => { + const currentMessage = messages[index]; + if (!currentMessage) return 'single'; + + const prevMessage = index > 0 ? messages[index - 1] : null; + const nextMessage = index < messages.length - 1 ? messages[index + 1] : null; + + const isSameSenderAsPrev = prevMessage && prevMessage.sender_id === currentMessage.sender_id; + const isSameSenderAsNext = nextMessage && nextMessage.sender_id === currentMessage.sender_id; + + if (!isSameSenderAsPrev && !isSameSenderAsNext) { + return 'single'; + } else if (!isSameSenderAsPrev && isSameSenderAsNext) { + return 'first'; + } else if (isSameSenderAsPrev && isSameSenderAsNext) { + return 'middle'; + } else { + return 'last'; + } +}; + +/** + * 判断是否显示发送者信息(只在组的第一条消息显示) + */ +export const shouldShowSenderInfo = ( + index: number, + messages: Array<{ sender_id: string }>, +): boolean => { + if (index === 0) return true; + + const currentMessage = messages[index]; + const prevMessage = messages[index - 1]; + + return prevMessage.sender_id !== currentMessage.sender_id; +}; + +/** + * 获取气泡基础样式 + */ +export const getBubbleBaseStyle = (isMe: boolean): ViewStyle => ({ + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 4, + minWidth: 60, + maxWidth: '75%', + backgroundColor: isMe ? bubbleColors.outgoing.background : bubbleColors.incoming.background, + // Element X 风格的微妙阴影 + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.06, + shadowRadius: 2, + elevation: 2, +}); + +/** + * 获取文本样式 + */ +export const getBubbleTextStyle = (isMe: boolean): TextStyle => ({ + color: isMe ? bubbleColors.outgoing.text : bubbleColors.incoming.text, + fontSize: 16, + lineHeight: 23, + letterSpacing: 0.2, + fontWeight: '400', +}); + +/** + * 消息气泡样式集合 + */ +export const bubbleStyles = StyleSheet.create({ + // 基础气泡 + bubble: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 4, + minWidth: 60, + }, + + // 自己发送的消息 + outgoing: { + backgroundColor: bubbleColors.outgoing.background, + }, + + // 收到的消息 + incoming: { + backgroundColor: bubbleColors.incoming.background, + }, + + // 文本样式 + text: { + fontSize: 16, + lineHeight: 23, + letterSpacing: 0.2, + fontWeight: '400', + }, + + // 阴影样式(Element X 风格) + shadow: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.06, + shadowRadius: 2, + elevation: 2, + }, + + // 长按反馈阴影 + longPressShadow: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 3 }, + shadowOpacity: 0.12, + shadowRadius: 8, + elevation: 6, + }, + + // 被回复消息的高亮边框 + replyHighlight: { + borderLeftWidth: 3, + borderLeftColor: colors.primary.main, + backgroundColor: bubbleColors.replyHighlight.background, + }, + + // 已撤回消息 + recalled: { + backgroundColor: '#F5F7FA', + borderWidth: 1, + borderColor: '#E8E8E8', + borderStyle: 'dashed', + borderRadius: 12, + }, + + // 系统通知 + systemNotice: { + alignItems: 'center', + marginVertical: spacing.sm, + }, + systemNoticeText: { + fontSize: 12, + color: '#8E8E93', + backgroundColor: 'rgba(142, 142, 147, 0.12)', + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: 12, + overflow: 'hidden', + }, +}); + +export default { + BUBBLE_RADIUS, + bubbleColors, + getBubbleBorderRadius, + getMessageGroupPosition, + shouldShowSenderInfo, + getBubbleBaseStyle, + getBubbleTextStyle, + bubbleStyles, +}; \ No newline at end of file diff --git a/src/screens/message/components/ChatScreen/index.ts b/src/screens/message/components/ChatScreen/index.ts index c905b0f..10661fb 100644 --- a/src/screens/message/components/ChatScreen/index.ts +++ b/src/screens/message/components/ChatScreen/index.ts @@ -20,6 +20,7 @@ export { ChatHeader } from './ChatHeader'; export { MessageBubble } from './MessageBubble'; export { ChatInput } from './ChatInput'; export { GroupInfoPanel } from './GroupInfoPanel'; +export { SwipeableMessageBubble } from './SwipeableMessageBubble'; // Segment 渲染组件 export { diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts index ea704fa..191e43a 100644 --- a/src/screens/message/components/ChatScreen/types.ts +++ b/src/screens/message/components/ChatScreen/types.ts @@ -86,6 +86,8 @@ export interface MessageBubbleProps { shouldShowTime: (index: number) => boolean; // 图片点击回调 onImagePress?: (images: { id: string; url: string; thumbnail_url?: string; width?: number; height?: number }[], index: number) => void; + // 滑动回复回调 + onReply?: (message: GroupMessage) => void; } // 输入框 Props @@ -180,6 +182,14 @@ export interface ReplyPreviewProps { onCancel: () => void; } +// 可滑动消息气泡 Props +export interface SwipeableMessageBubbleProps { + children: React.ReactNode; + isMe: boolean; + onReply: () => void; + enabled?: boolean; +} + // ChatScreen 状态接口 export interface ChatScreenState { // 基础状态 diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 33ab6e7..77351b6 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -390,6 +390,16 @@ export const useChatScreen = () => { return; } + // 禁用自动滚动到底部,防止加载历史消息后滚动位置跳转 + shouldAutoScrollOnEnterRef.current = false; + // 清除所有待执行的自动滚动定时器 + autoScrollTimersRef.current.forEach(clearTimeout); + autoScrollTimersRef.current = []; + + // 保存加载前的滚动位置和内容高度 + const scrollYBefore = scrollPositionRef.current.scrollY; + const contentHeightBefore = scrollPositionRef.current.contentHeight; + setLoadingMore(true); try { @@ -400,6 +410,23 @@ export const useChatScreen = () => { const minSeq = Math.min(...messages.map(m => m.seq)); setFirstSeq(minSeq); } + + // 加载完成后,恢复滚动位置 + // 使用 setTimeout 确保 FlashList 已经更新 + setTimeout(() => { + if (flatListRef.current) { + // 计算新的滚动位置,保持相对位置不变 + const newContentHeight = scrollPositionRef.current.contentHeight; + const heightDiff = newContentHeight - contentHeightBefore; + const newScrollY = scrollYBefore + heightDiff; + + // 滚动到计算后的位置 + flatListRef.current.scrollToOffset({ + offset: newScrollY, + animated: false, + }); + } + }, 100); } catch (error) { console.error('加载历史消息失败:', error); } finally { @@ -409,13 +436,19 @@ export const useChatScreen = () => { // 列表内容尺寸变化后触发首次自动滚动 const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => { + // 如果是加载更多历史消息,不要自动滚动 + if (loadingMore) { + return; + } + if (!shouldAutoScrollOnEnterRef.current) return; - if (loading || loadingMore) return; + if (loading) return; if (messages.length === 0) return; const prevContentHeight = scrollPositionRef.current.contentHeight; scrollPositionRef.current.contentHeight = contentHeight; + // 如果内容高度增加(加载了更多消息),不要自动滚动到底部 if (prevContentHeight > 0 && contentHeight > prevContentHeight) { return; } diff --git a/src/screens/profile/SettingsScreen.tsx b/src/screens/profile/SettingsScreen.tsx index 4273225..e61abc4 100644 --- a/src/screens/profile/SettingsScreen.tsx +++ b/src/screens/profile/SettingsScreen.tsx @@ -15,6 +15,7 @@ import { import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; +import Constants from 'expo-constants'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { useAuthStore } from '../../stores'; import { Text, ResponsiveContainer } from '../../components/common'; @@ -30,6 +31,9 @@ interface SettingsItem { subtitle?: string; } +// 获取应用版本号 +const APP_VERSION = Constants.expoConfig?.version || '1.0.0'; + // 设置分组配置 const SETTINGS_GROUPS = [ { @@ -53,7 +57,7 @@ const SETTINGS_GROUPS = [ title: '关于与帮助', icon: 'information-outline', items: [ - { key: 'about', title: '关于我们', icon: 'information-outline', showArrow: true, subtitle: '版本 1.0.2' }, + { key: 'about', title: '关于我们', icon: 'information-outline', showArrow: true, subtitle: `版本 ${APP_VERSION}` }, { key: 'help', title: '帮助与反馈', icon: 'help-circle-outline', showArrow: true }, ], }, @@ -183,7 +187,7 @@ export const SettingsScreen: React.FC = () => { {/* 底部版权信息 */} - 萝卜社区 v1.0.2 + 萝卜社区 v{APP_VERSION} © 2024 Carrot BBS. All rights reserved. diff --git a/src/services/authService.ts b/src/services/authService.ts index c4055d5..fa28a9d 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -432,5 +432,63 @@ class AuthService { } } +// ── 二维码登录相关接口 ── + +export interface QRCodeSession { + session_id: string; + qrcode_url: string; + expires_in: number; + expires_at: number; +} + +export interface ScanResponse { + user: { + id: string; + nickname: string; + avatar: string; + }; +} + +// 二维码登录 API(扩展 AuthService) +export const qrcodeApi = { + /** + * 获取二维码 + */ + getQRCode: async (): Promise => { + const response = await api.get('/auth/qrcode'); + return response.data; + }, + + /** + * 扫描二维码 + */ + scan: async (sessionId: string): Promise => { + const response = await api.post('/auth/qrcode/scan', { + session_id: sessionId, + }); + return response.data; + }, + + /** + * 确认登录 + */ + confirm: async (sessionId: string): Promise<{ success: boolean }> => { + const response = await api.post<{ success: boolean }>('/auth/qrcode/confirm', { + session_id: sessionId, + }); + return response.data; + }, + + /** + * 取消登录 + */ + cancel: async (sessionId: string): Promise<{ success: boolean }> => { + const response = await api.post<{ success: boolean }>('/auth/qrcode/cancel', { + session_id: sessionId, + }); + return response.data; + }, +}; + // 导出认证服务实例 export const authService = new AuthService(); diff --git a/src/utils/__tests__/optimisticUpdate.test.ts b/src/utils/__tests__/optimisticUpdate.test.ts deleted file mode 100644 index 39081d7..0000000 --- a/src/utils/__tests__/optimisticUpdate.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -/** - * 乐观更新工具函数测试 - */ - -import { optimisticUpdate, simpleOptimisticUpdate, createOptimisticUpdaterFactory } from '../optimisticUpdate'; - -// 模拟数据 -interface TestState { - posts: Array<{ id: string; likes: number; isLiked: boolean }>; - users: Array<{ id: string; isFollowing: boolean }>; -} - -const initialState: TestState = { - posts: [ - { id: '1', likes: 10, isLiked: false }, - { id: '2', likes: 5, isLiked: true }, - ], - users: [ - { id: 'user1', isFollowing: false }, - { id: 'user2', isFollowing: true }, - ], -}; - -describe('optimisticUpdate', () => { - let currentState: TestState; - - beforeEach(() => { - currentState = JSON.parse(JSON.stringify(initialState)); - }); - - describe('成功场景', () => { - it('应该执行乐观更新并在API成功后保持更新', async () => { - const mockApiCall = jest.fn().mockResolvedValue({ success: true, likes: 11 }); - - const result = await optimisticUpdate({ - getState: () => currentState, - optimisticUpdate: (state) => ({ - ...state, - posts: state.posts.map((p) => - p.id === '1' ? { ...p, likes: p.likes + 1, isLiked: true } : p - ), - }), - apiCall: mockApiCall, - onSuccess: (result, current) => ({ - ...current, - posts: current.posts.map((p) => - p.id === '1' ? { ...p, likes: result.likes } : p - ), - }), - }); - - // 验证API被调用 - expect(mockApiCall).toHaveBeenCalledTimes(1); - // 验证返回结果 - expect(result).toEqual({ success: true, likes: 11 }); - }); - - it('应该在乐观更新后立即改变状态', async () => { - const mockApiCall = jest.fn().mockImplementation(() => { - // 在API调用期间验证状态已被乐观更新 - expect(currentState.posts[0].isLiked).toBe(true); - expect(currentState.posts[0].likes).toBe(11); - return Promise.resolve({ success: true }); - }); - - await optimisticUpdate({ - getState: () => currentState, - optimisticUpdate: (state) => { - const newState = { - ...state, - posts: state.posts.map((p) => - p.id === '1' ? { ...p, likes: p.likes + 1, isLiked: true } : p - ), - }; - currentState = newState; // 模拟状态更新 - return newState; - }, - apiCall: mockApiCall, - }); - }); - }); - - describe('失败回滚场景', () => { - it('应该在API失败时回滚到原始状态', async () => { - const mockApiCall = jest.fn().mockRejectedValue(new Error('Network error')); - const onError = jest.fn((error, original) => original); - - const result = await optimisticUpdate({ - getState: () => currentState, - optimisticUpdate: (state) => ({ - ...state, - posts: state.posts.map((p) => - p.id === '1' ? { ...p, likes: p.likes + 1, isLiked: true } : p - ), - }), - apiCall: mockApiCall, - onError, - errorMessage: '点赞失败', - }); - - // 验证API被调用 - expect(mockApiCall).toHaveBeenCalledTimes(1); - // 验证onError被调用 - expect(onError).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledWith( - expect.any(Error), - initialState - ); - // 验证返回undefined(因为失败了) - expect(result).toBeUndefined(); - }); - - it('应该在静默模式下不抛出错误', async () => { - const mockApiCall = jest.fn().mockRejectedValue(new Error('Network error')); - - // 不应该抛出错误 - await expect( - optimisticUpdate({ - getState: () => currentState, - optimisticUpdate: (state) => state, - apiCall: mockApiCall, - silent: true, - }) - ).resolves.toBeUndefined(); - }); - - it('应该在非静默模式下抛出错误', async () => { - const mockApiCall = jest.fn().mockRejectedValue(new Error('Network error')); - - // 应该抛出错误 - await expect( - optimisticUpdate({ - getState: () => currentState, - optimisticUpdate: (state) => state, - apiCall: mockApiCall, - silent: false, - }) - ).rejects.toThrow('Network error'); - }); - }); - - describe('错误处理', () => { - it('应该记录错误日志', async () => { - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - const mockApiCall = jest.fn().mockRejectedValue(new Error('API Error')); - - await optimisticUpdate({ - getState: () => currentState, - optimisticUpdate: (state) => state, - apiCall: mockApiCall, - errorMessage: '自定义错误消息', - silent: true, - }); - - expect(consoleSpy).toHaveBeenCalledWith('自定义错误消息:', expect.any(Error)); - - consoleSpy.mockRestore(); - }); - }); -}); - -describe('simpleOptimisticUpdate', () => { - let currentState: TestState; - let setStateMock: jest.Mock; - - beforeEach(() => { - currentState = JSON.parse(JSON.stringify(initialState)); - setStateMock = jest.fn((newState) => { - currentState = newState; - }); - }); - - it('应该成功执行乐观更新', async () => { - const mockApiCall = jest.fn().mockResolvedValue(undefined); - - await simpleOptimisticUpdate({ - getState: () => currentState, - setState: setStateMock, - optimisticUpdate: (state) => ({ - ...state, - posts: state.posts.map((p) => - p.id === '1' ? { ...p, likes: p.likes + 1 } : p - ), - }), - rollbackState: (original) => original, - apiCall: mockApiCall, - }); - - expect(mockApiCall).toHaveBeenCalledTimes(1); - expect(setStateMock).toHaveBeenCalledTimes(1); - }); - - it('应该在失败时回滚状态', async () => { - const mockApiCall = jest.fn().mockRejectedValue(new Error('Error')); - const rollbackMock = jest.fn((original) => original); - - await simpleOptimisticUpdate({ - getState: () => currentState, - setState: setStateMock, - optimisticUpdate: (state) => ({ - ...state, - posts: state.posts.map((p) => - p.id === '1' ? { ...p, likes: p.likes + 1 } : p - ), - }), - rollbackState: rollbackMock, - apiCall: mockApiCall, - silent: true, - }); - - // 验证乐观更新和回滚都被调用 - expect(setStateMock).toHaveBeenCalledTimes(2); - expect(rollbackMock).toHaveBeenCalledWith(initialState); - }); -}); - -describe('createOptimisticUpdaterFactory', () => { - interface StoreState { - posts: Array<{ id: string; likes: number }>; - count: number; - } - - let storeState: StoreState; - let setMock: jest.Mock; - let getMock: jest.Mock; - - beforeEach(() => { - storeState = { - posts: [{ id: '1', likes: 10 }], - count: 0, - }; - setMock = jest.fn((fn) => { - storeState = fn(storeState); - }); - getMock = jest.fn(() => storeState); - }); - - it('应该创建乐观更新器并正常工作', async () => { - const optimisticUpdater = createOptimisticUpdaterFactory(setMock, getMock); - const mockApiCall = jest.fn().mockResolvedValue(undefined); - - await optimisticUpdater({ - key: 'posts', - optimisticUpdate: (posts) => - posts.map((p) => (p.id === '1' ? { ...p, likes: p.likes + 1 } : p)), - rollbackState: (original) => original, - apiCall: mockApiCall, - }); - - expect(mockApiCall).toHaveBeenCalledTimes(1); - expect(setMock).toHaveBeenCalledTimes(1); - expect(storeState.posts[0].likes).toBe(11); - }); - - it('应该在API失败时回滚状态', async () => { - const optimisticUpdater = createOptimisticUpdaterFactory(setMock, getMock); - const mockApiCall = jest.fn().mockRejectedValue(new Error('Error')); - - await optimisticUpdater({ - key: 'posts', - optimisticUpdate: (posts) => - posts.map((p) => (p.id === '1' ? { ...p, likes: p.likes + 1 } : p)), - rollbackState: (original) => original, - apiCall: mockApiCall, - silent: true, - }); - - // 乐观更新 + 回滚 = 2次set调用 - expect(setMock).toHaveBeenCalledTimes(2); - // 最终状态应该和初始状态一样 - expect(storeState.posts[0].likes).toBe(10); - }); - - it('应该处理不同的state key', async () => { - const optimisticUpdater = createOptimisticUpdaterFactory(setMock, getMock); - const mockApiCall = jest.fn().mockResolvedValue(undefined); - - await optimisticUpdater({ - key: 'count', - optimisticUpdate: (count) => count + 1, - rollbackState: (original) => original, - apiCall: mockApiCall, - }); - - expect(storeState.count).toBe(1); - }); -}); From 8a0aea1c590a31db2f3f0dafa50c26bc37c6dd7f Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Fri, 20 Mar 2026 23:00:27 +0800 Subject: [PATCH 05/36] feat(pagination): implement cursor-based pagination across the app Add useCursorPagination hook and update multiple screens and services to use cursor-based pagination for better performance and consistency. - Add useCursorPagination hook with deduplication and caching support - Add cursor pagination types to infrastructure layer - Refactor HomeScreen, PostDetailScreen, SearchScreen for posts/comments - Refactor GroupMembersScreen, JoinGroupScreen for groups/members - Refactor MessageListScreen, NotificationsScreen for messages - Update post, message, group, comment, notification services with cursor endpoints - Add CursorPaginationRequest/Response DTOs - Remove deprecated OPTIMIZATION_DESIGN.md documentation --- .gitignore | 3 + docs/OPTIMIZATION_DESIGN.md | 1725 ------------------- plans/frontend_cursor_pagination_design.md | 1131 ++++++++++++ src/hooks/index.ts | 3 + src/hooks/useCursorPagination.ts | 208 +++ src/infrastructure/pagination/index.ts | 10 + src/infrastructure/pagination/types.ts | 76 + src/screens/home/HomeScreen.tsx | 247 +-- src/screens/home/PostDetailScreen.tsx | 109 +- src/screens/home/SearchScreen.tsx | 163 +- src/screens/message/GroupMembersScreen.tsx | 146 +- src/screens/message/JoinGroupScreen.tsx | 284 ++- src/screens/message/MessageListScreen.tsx | 63 +- src/screens/message/NotificationsScreen.tsx | 96 +- src/services/commentService.ts | 57 + src/services/groupService.ts | 82 + src/services/messageService.ts | 82 + src/services/notificationService.ts | 28 + src/services/postService.ts | 87 + src/types/dto.ts | 30 + 20 files changed, 2464 insertions(+), 2166 deletions(-) delete mode 100644 docs/OPTIMIZATION_DESIGN.md create mode 100644 plans/frontend_cursor_pagination_design.md create mode 100644 src/hooks/useCursorPagination.ts diff --git a/.gitignore b/.gitignore index 4074a5c..f5cbf7d 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ dist-android-update.zip # Backend backend/data/ backend/logs/ + +doc/ +docs/ diff --git a/docs/OPTIMIZATION_DESIGN.md b/docs/OPTIMIZATION_DESIGN.md deleted file mode 100644 index 5b1a7e8..0000000 --- a/docs/OPTIMIZATION_DESIGN.md +++ /dev/null @@ -1,1725 +0,0 @@ -# 聊天系统性能优化设计方案 - -## 文档信息 - -- **创建日期**: 2026-03-18 -- **项目**: 胡萝卜 BBS (Carrot BBS) 前端 -- **范围**: 消息系统性能优化 - ---- - -## 目录 - -1. [架构概述](#架构概述) -2. [P0 分页状态管理](#p0-分页状态管理) -3. [P0 同步状态机](#p0-同步状态机) -4. [P1 差异更新](#p1-差异更新) -5. [P1 媒体缓存清理](#p1-媒体缓存清理) -6. [实现优先级与依赖关系](#实现优先级与依赖关系) - ---- - -## 架构概述 - -### 当前消息系统架构 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ UI Layer │ -│ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ ChatScreen │ │ MessageList │ │ -│ │ useChatScreen │ │ Screen │ │ -│ └────────┬────────┘ └────────┬────────┘ │ -└───────────┼─────────────────────┼───────────────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Store Layer (Zustand) │ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ messageManager (MessageManager) ││ -│ │ - messages Map ││ -│ │ - conversations: Conversation[] ││ -│ │ - activeConversation: string | null ││ -│ │ - isConnected: boolean ││ -│ └─────────────────────────────────────────────────────────────┘│ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ messageManagerHooks (React Hooks) ││ -│ │ - useChat(conversationId) ││ -│ │ - useMessages(conversationId) ││ -│ │ - useConversations() ││ -│ └─────────────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ UseCase Layer │ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ ProcessMessageUseCase ││ -│ │ - WebSocket 事件监听与处理 ││ -│ │ - 消息去重 (processedMessageIds) ││ -│ │ - 已读状态管理 (pendingReadMap) ││ -│ │ - 消息持久化到 Repository ││ -│ └─────────────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ DataSource Layer │ -│ ┌───────────────┐ ┌───────────────┐ ┌───────────────────────┐│ -│ │WebSocketClient│ │CacheDataSource│ │LocalDataSource (SQLite)││ -│ │ │ │ (Memory+Async) │ │ ││ -│ │ - 事件订阅 │ │ - get/set │ │ - messages 表 ││ -│ │ - emit │ │ - delete │ │ - conversations 表 ││ -│ │ - 连接状态 │ │ - clear │ │ - users 表 ││ -│ └───────────────┘ └───────────────┘ └───────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Infrastructure Layer │ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ sseService (SSE Client) ││ -│ │ - EventSource 连接管理 ││ -│ │ - 重连逻辑 (maxReconnectAttempts=20) ││ -│ │ - AppState 监听 ││ -│ └─────────────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 问题分析 - -基于代码分析,当前系统存在以下问题: - -| 问题 | 影响 | 优先级 | -|------|------|--------| -| 分页状态管理不完善 | 用户滚动加载时可能重复请求同一页数据 | P0 | -| 连接状态反馈不清晰 | 用户不知道连接是否正常 | P0 | -| 全量数据更新 | 大房间每次更新都刷新整个消息列表 | P1 | -| 媒体缓存无清理机制 | 长期使用后存储空间爆炸 | P1 | - ---- - -## P0 分页状态管理 - -### 现有代码分析 - -#### 问题点 - -1. **`useChatScreen.ts` (第388-408行)** - ```typescript - const loadMoreHistory = useCallback(async () => { - if (!conversationId || !hasMoreHistory || loadingMore) { - return; - } - // 缺少分页游标管理,可能导致重复加载 - setLoadingMore(true); - try { - await loadMoreMessages(); - // 没有追踪当前加载到了哪一页 - } finally { - setLoadingMore(false); - } - }, [...]); - ``` - -2. **`messageManagerHooks.ts` (第156-158行)** - ```typescript - setIsLoading(true); - const loadedMessages = await messageManager.loadMoreMessages(conversationId, minSeq, 20); - // 直接使用 minSeq,但没有缓存 minSeq 的状态 - ``` - -3. **`MessageRepository.ts` (第125-137行)** - ```typescript - async getMessagesBeforeSeq(conversationId, beforeSeq, limit = 20) { - // 依赖 beforeSeq 参数,但没有分页偏移量管理 - } - ``` - -#### 架构缺陷 - -- 没有独立的分页状态存储 -- `hasMoreMessages` 状态与实际数据可能不同步 -- 并发加载时缺少互斥机制 -- 没有加载中的分页信息缓存 - -### 实现方案 - -#### 1. 新增分页状态管理器 - -```typescript -// src/stores/pagination/PaginationStateManager.ts - -interface PaginationState { - conversationId: string; - currentMinSeq: number; // 当前已加载的最小seq - currentMaxSeq: number; // 当前已加载的最大seq - pages: Map; // pageNumber -> messageIds - loadingPageNumbers: Set; - hasMoreBefore: boolean; - hasMoreAfter: boolean; - totalLoaded: number; -} - -class PaginationStateManager { - private states: Map = new Map(); - - // 获取或创建分页状态 - getOrCreate(conversationId: string): PaginationState { ... } - - // 标记页面加载中 - markPageLoading(conversationId: string, pageNumber: number): void { ... } - - // 标记页面加载完成 - markPageLoaded(conversationId: string, pageNumber: number, messageIds: string[], hasMore: boolean): void { ... } - - // 检查页面是否已加载 - isPageLoaded(conversationId: string, pageNumber: number): boolean { ... } - - // 检查页面是否正在加载 - isPageLoading(conversationId: string, pageNumber: number): boolean { ... } - - // 重置分页状态 - reset(conversationId: string): void { ... } -} -``` - -#### 2. 修改 useMessages Hook - -```typescript -// src/stores/messageManagerHooks.ts - -interface UseMessagesOptions { - pageSize?: number; - prefetchThreshold?: number; // 预加载阈值 -} - -function useMessages( - conversationId: string | null, - options: UseMessagesOptions = {} -) { - const { - pageSize = 20, - prefetchThreshold = 5 - } = options; - - // 分页状态 - const [paginationState, setPaginationState] = useState(null); - - // 计算当前页码 - const currentPage = useMemo(() => { - if (!paginationState) return 1; - return Math.ceil( - (paginationState.totalLoaded - messages.length) / pageSize - ) + 1; - }, [paginationState, messages.length]); - - // 加载更多(带分页保护) - const loadMoreMessages = useCallback(async () => { - if (!conversationId || !paginationState) return; - - // 计算下一页 - const nextPage = Math.floor(paginationState.totalLoaded / pageSize) + 1; - - // 检查是否正在加载 - if (paginationState.loadingPageNumbers.has(nextPage)) { - return; - } - - // 检查是否已加载 - if (paginationState.pages.has(nextPage)) { - return; - } - - await messageManager.loadMoreMessages( - conversationId, - paginationState.currentMinSeq, - pageSize - ); - }, [conversationId, paginationState, pageSize]); - - // 预加载检测 - useEffect(() => { - if (!paginationState || !hasMore) return; - - const currentPageLoaded = messages.length; - const threshold = pageSize - prefetchThreshold; - - if (currentPageLoaded <= threshold && !paginationState.loadingPageNumbers.has(1)) { - loadMoreMessages(); - } - }, [messages.length, hasMore, loadMoreMessages]); -} -``` - -#### 3. 修改 MessageManager - -```typescript -// src/stores/message/MessageManager.ts - -class MessageManager { - private paginationManager = new PaginationStateManager(); - - async loadMoreMessages( - conversationId: string, - beforeSeq: number, - limit: number - ): Promise { - // 检查分页状态 - const pageNumber = this.calculatePageNumber(conversationId, beforeSeq); - - if (this.paginationManager.isPageLoading(conversationId, pageNumber)) { - return []; // 避免重复加载 - } - - if (this.paginationManager.isPageLoaded(conversationId, pageNumber)) { - return []; // 已加载,直接返回 - } - - this.paginationManager.markPageLoading(conversationId, pageNumber); - - try { - // 调用 API - const response = await messageService.getMessages(conversationId, { - before_seq: beforeSeq, - limit - }); - - const messageIds = response.messages.map(m => m.id); - - this.paginationManager.markPageLoaded( - conversationId, - pageNumber, - messageIds, - response.has_more - ); - - // 更新消息存储 - this.appendMessages(conversationId, response.messages); - - return response.messages; - } catch (error) { - // 加载失败,清除分页状态 - this.paginationManager.markPageFailed(conversationId, pageNumber); - throw error; - } - } -} -``` - -#### 4. 修改数据库查询 - -```typescript -// src/services/database.ts (或 MessageRepository) - -export async function getMessagesBeforeSeq( - conversationId: string, - beforeSeq: number, - limit: number -): Promise { - // 确保使用索引优化查询 - const result = await db.getAllAsync(` - SELECT * FROM messages - WHERE conversationId = ? AND seq < ? - ORDER BY seq DESC - LIMIT ? - `, [conversationId, beforeSeq, limit]); - - return result; -} -``` - -### 修改/新增文件列表 - -| 文件 | 操作 | 说明 | -|------|------|------| -| `src/stores/pagination/PaginationStateManager.ts` | 新增 | 分页状态管理器 | -| `src/stores/pagination/index.ts` | 新增 | 导出文件 | -| `src/stores/messageManagerHooks.ts` | 修改 | 集成分页状态管理 | -| `src/stores/message/MessageManager.ts` | 修改 | 添加分页保护逻辑 | -| `src/data/repositories/MessageRepository.ts` | 修改 | 优化分页查询 | - -### 关键设计决策 - -1. **分页粒度**: 以 `beforeSeq` 为游标,而非 offset,避免消息新增导致的分页漂移 -2. **页面缓存**: 使用 `Map` 缓存已加载页面,避免重复请求 -3. **加载锁**: 使用 `Set` 追踪正在加载的页面,防止并发重复加载 -4. **预加载**: 当滚动到距离底部 `prefetchThreshold` 条消息时,提前加载下一页 - ---- - -## P0 同步状态机 - -### 现有代码分析 - -#### 问题点 - -1. **`sseService.ts` (第406-408行)** - ```typescript - isConnected(): boolean { - return this.source != null; - } - // 状态判断过于简单,source != null 不代表真正连接成功 - ``` - -2. **`WebSocketClient.ts` (第23-35行)** - ```typescript - export interface WebSocketEvents { - 'connected': void; - 'disconnected': void; - 'error': Error; - } - // 只有 connected/disconnected 两种状态 - ``` - -3. **`sseService.ts` (第190-228行)** - ```typescript - async connect(): Promise { - // isConnecting 状态没有暴露给外部 - // 外部无法区分"正在连接"和"连接失败" - } - ``` - -4. **`messageManagerHooks.ts` (第390-405行)** - ```typescript - const [isConnected, setIsConnected] = useState(() => messageManager.isConnected()); - // 连接状态变化没有详细的状态分类 - ``` - -#### 状态定义缺失 - -当前没有区分以下状态: -- **Connecting**: 正在建立连接 -- **Connected**: 连接已建立 -- **Reconnecting**: 连接断开,正在重连 -- **Disconnected**: 连接已断开 -- **Error**: 连接错误 - -### 实现方案 - -#### 1. 定义连接状态机 - -```typescript -// src/services/connection/ConnectionState.ts - -export enum ConnectionStateType { - IDLE = 'idle', - CONNECTING = 'connecting', - CONNECTED = 'connected', - RECONNECTING = 'reconnecting', - DISCONNECTED = 'disconnected', - ERROR = 'error', -} - -export interface ConnectionState { - type: ConnectionStateType; - timestamp: number; - error?: Error; - retryCount?: number; - lastConnectedAt?: number; -} - -export interface ConnectionStateChangeEvent { - previousState: ConnectionState; - currentState: ConnectionState; - reason?: string; -} -``` - -#### 2. 创建连接状态管理器 - -```typescript -// src/services/connection/ConnectionStateManager.ts - -type ConnectionStateListener = (event: ConnectionStateChangeEvent) => void; - -class ConnectionStateManager { - private state: ConnectionState = { - type: ConnectionStateType.IDLE, - timestamp: Date.now(), - }; - - private listeners: Set = new Set(); - private reconnectAttempts = 0; - private maxReconnectAttempts = 20; - - // 获取当前状态 - getState(): ConnectionState { ... } - - // 状态转换 - transition(newType: ConnectionStateType, reason?: string, error?: Error): void { - const previousState = this.state; - this.state = { - type: newType, - timestamp: Date.now(), - error, - retryCount: newType === ConnectionStateType.RECONNECTING - ? this.reconnectAttempts - : undefined, - lastConnectedAt: newType === ConnectionStateType.CONNECTED - ? Date.now() - : this.state.lastConnectedAt, - }; - - this.notifyListeners({ previousState, currentState: this.state, reason }); - } - - // 增加重试计数 - incrementRetry(): number { - this.reconnectAttempts++; - return this.reconnectAttempts; - } - - // 重置重试计数 - resetRetry(): void { - this.reconnectAttempts = 0; - } - - // 订阅状态变化 - subscribe(listener: ConnectionStateListener): () => void { ... } - - // 获取可读的状态描述 - getStatusDescription(): string { - switch (this.state.type) { - case ConnectionStateType.IDLE: - return '未连接'; - case ConnectionStateType.CONNECTING: - return '正在连接...'; - case ConnectionStateType.CONNECTED: - return '已连接'; - case ConnectionStateType.RECONNECTING: - return `正在重连 (${this.state.retryCount}/${this.maxReconnectAttempts})`; - case ConnectionStateType.DISCONNECTED: - return '已断开'; - case ConnectionStateType.ERROR: - return `连接错误: ${this.state.error?.message || '未知错误'}`; - } - } -} - -export const connectionStateManager = new ConnectionStateManager(); -``` - -#### 3. 修改 SSEService - -```typescript -// src/services/sseService.ts - -class SSEService { - private connectionStateManager = connectionStateManager; - - async connect(): Promise { - if (this.isConnecting || this.isConnected()) { - return true; - } - - this.connectionStateManager.transition( - ConnectionStateType.CONNECTING, - 'initiated' - ); - - try { - const token = await api.getToken(); - if (!token) { - throw new Error('No auth token'); - } - - // ... 建立连接 ... - - this.source.addEventListener('open', () => { - this.connectionStateManager.resetRetry(); - this.connectionStateManager.transition( - ConnectionStateType.CONNECTED, - 'connection_opened' - ); - }); - - this.source.addEventListener('error', (error) => { - this.connectionStateManager.transition( - ConnectionStateType.ERROR, - 'connection_error', - error - ); - this.scheduleReconnect(); - }); - - return true; - } catch (error) { - this.connectionStateManager.transition( - ConnectionStateType.ERROR, - 'connection_failed', - error - ); - this.scheduleReconnect(); - return false; - } - } - - private scheduleReconnect(): void { - const retryCount = this.connectionStateManager.incrementRetry(); - - if (retryCount >= this.maxReconnectAttempts) { - this.connectionStateManager.transition( - ConnectionStateType.DISCONNECTED, - 'max_reconnect_exceeded' - ); - return; - } - - this.connectionStateManager.transition( - ConnectionStateType.RECONNECTING, - 'scheduling_reconnect' - ); - - this.reconnectTimer = setTimeout(() => { - this.connect(); - }, this.reconnectDelay); - } -} -``` - -#### 4. 创建连接状态 Hook - -```typescript -// src/hooks/useConnectionState.ts - -export function useConnectionState() { - const [state, setState] = useState( - () => connectionStateManager.getState() - ); - - useEffect(() => { - const unsubscribe = connectionStateManager.subscribe((event) => { - setState(event.currentState); - }); - - return unsubscribe; - }, []); - - return { - state, - status: connectionStateManager.getStatusDescription(), - isConnected: state.type === ConnectionStateType.CONNECTED, - isConnecting: state.type === ConnectionStateType.CONNECTING, - isReconnecting: state.type === ConnectionStateType.RECONNECTING, - isDisconnected: state.type === ConnectionStateType.DISCONNECTED, - isError: state.type === ConnectionStateType.ERROR, - retryCount: state.retryCount, - error: state.error, - reconnect: () => sseService.connect(), - disconnect: () => sseService.disconnect(), - }; -} -``` - -#### 5. UI 集成示例 - -```typescript -// src/screens/message/components/ChatScreen/ConnectionStatusBadge.tsx - -export function ConnectionStatusBadge() { - const { status, isConnected, isReconnecting, isError, retryCount } = useConnectionState(); - - if (isConnected) { - return null; // 已连接不显示 - } - - return ( - - {isReconnecting && ( - - )} - - {isReconnecting - ? `连接中断,正在重连 (${retryCount}/20)` - : status} - - - ); -} -``` - -### 修改/新增文件列表 - -| 文件 | 操作 | 说明 | -|------|------|------| -| `src/services/connection/ConnectionState.ts` | 新增 | 连接状态定义 | -| `src/services/connection/ConnectionStateManager.ts` | 新增 | 连接状态管理器 | -| `src/services/connection/index.ts` | 新增 | 导出文件 | -| `src/services/sseService.ts` | 修改 | 集成状态管理器 | -| `src/hooks/useConnectionState.ts` | 新增 | 连接状态 Hook | -| `src/hooks/index.ts` | 修改 | 导出新 Hook | - -### Mermaid 状态图 - -```mermaid -stateDiagram-v2 - [*] --> IDLE - IDLE --> CONNECTING: 调用 connect() - CONNECTING --> CONNECTED: 连接成功 - CONNECTING --> ERROR: 连接失败 - CONNECTED --> RECONNECTING: 连接断开 - RECONNECTING --> CONNECTED: 重连成功 - RECONNECTING --> DISCONNECTED: 超过最大重试次数 - RECONNECTING --> ERROR: 重连失败 - ERROR --> RECONNECTING: 自动重连 - ERROR --> DISCONNECTED: 停止重连 - DISCONNECTED --> CONNECTING: 手动重连 - IDLE --> CONNECTING: 手动重连 - CONNECTED --> DISCONNECTED: 调用 disconnect() -``` - ---- - -## P1 差异更新 - -### 现有代码分析 - -#### 问题点 - -1. **`MessageManager` 消息更新机制** - - 每当有新消息时,通过 `setMessages` 更新整个消息数组 - - 大房间场景下,频繁的全量更新会导致 UI 卡顿 - - 没有区分不同类型的消息更新(新增、删除、修改) - -2. **`messageManagerHooks.ts` (第33-46行)** - ```typescript - const unsubscribe = messageManager.subscribe((event: MessageEvent) => { - switch (event.type) { - case 'conversations_updated': - setConversations(messageManager.getConversations()); - break; - // ... 其他都是全量更新 - } - }); - ``` - -3. **`useChatScreen.ts` (第148-162行)** - ```typescript - const messages = useMemo(() => { - return messageManagerMessages.map(m => ({ - // 全量映射 - })); - }, [messageManagerMessages]); - ``` - -4. **`ProcessMessageUseCase.ts` (第159-199行)** - ```typescript - private async handleNewMessage(message: any): Promise { - // 每条消息都触发完整流程 - this.notifySubscribers({ - type: 'message_received', - payload: { message, ... } - }); - } - ``` - -#### 性能瓶颈 - -- 1000人群聊,每秒10条消息 = 每秒10000次 UI 更新操作 -- 全量 `setMessages` 触发 FlatList 完整重渲染 -- 没有虚拟列表优化 - -### 实现方案 - -#### 1. 定义消息更新类型 - -```typescript -// src/stores/message/MessageUpdateTypes.ts - -export enum MessageUpdateType { - APPEND = 'append', // 追加新消息到末尾 - PREPEND = 'prepend', // 预置历史消息到开头 - UPDATE = 'update', // 更新单条消息 - DELETE = 'delete', // 删除单条消息 - BATCH_APPEND = 'batch_append', // 批量追加 - BATCH_PREPEND = 'batch_prepend', // 批量预置 - RECALL = 'recall', // 撤回消息 - CLEAR = 'clear', // 清空会话 -} - -export interface MessageUpdate { - type: MessageUpdateType; - conversationId: string; - payload: T; - timestamp: number; -} - -export interface AppendPayload { - messages: Message[]; -} - -export interface PrependPayload { - messages: Message[]; - hasMoreBefore: boolean; -} - -export interface UpdatePayload { - messageId: string; - updates: Partial; -} - -export interface DeletePayload { - messageId: string; -} - -export interface BatchAppendPayload { - messages: Message[]; -} - -export interface RecallPayload { - messageId: string; -} -``` - -#### 2. 创建差异更新 Hook - -```typescript -// src/hooks/useDifferentialMessages.ts - -export function useDifferentialMessages(conversationId: string | null) { - const [messages, setMessages] = useState([]); - const pendingUpdatesRef = useRef([]); - const flushScheduledRef = useRef(false); - - // 批量处理更新 - const flushUpdates = useCallback(() => { - if (pendingUpdatesRef.current.length === 0) return; - - const updates = pendingUpdatesRef.current; - pendingUpdatesRef.current = []; - flushScheduledRef.current = false; - - setMessages(currentMessages => { - const newMessages = [...currentMessages]; - - for (const update of updates) { - switch (update.type) { - case MessageUpdateType.APPEND: - for (const msg of update.payload.messages) { - if (!newMessages.find(m => m.id === msg.id)) { - newMessages.push(msg); - } - } - break; - - case MessageUpdateType.PREPEND: - const prependMessages = update.payload.messages - .filter(m => !newMessages.find(n => n.id === m.id)); - newMessages.unshift(...prependMessages.reverse()); - break; - - case MessageUpdateType.UPDATE: - const updateIdx = newMessages.findIndex(m => m.id === update.payload.messageId); - if (updateIdx !== -1) { - newMessages[updateIdx] = { - ...newMessages[updateIdx], - ...update.payload.updates - }; - } - break; - - case MessageUpdateType.DELETE: - newMessages = newMessages.filter(m => m.id !== update.payload.messageId); - break; - - case MessageUpdateType.RECALL: - const recallIdx = newMessages.findIndex(m => m.id === update.payload.messageId); - if (recallIdx !== -1) { - newMessages[recallIdx] = { - ...newMessages[recallIdx], - status: 'recalled', - segments: [], - }; - } - break; - - case MessageUpdateType.BATCH_APPEND: - for (const msg of update.payload.messages) { - if (!newMessages.find(m => m.id === msg.id)) { - newMessages.push(msg); - } - } - break; - } - } - - return newMessages; - }); - }, []); - - // 调度批量更新(16ms 内合并) - const scheduleFlush = useCallback(() => { - if (flushScheduledRef.current) return; - flushScheduledRef.current = true; - requestAnimationFrame(flushUpdates); - }, [flushUpdates]); - - // 处理消息更新 - const handleMessageUpdate = useCallback((update: MessageUpdate) => { - pendingUpdatesRef.current.push(update); - scheduleFlush(); - }, [scheduleFlush]); - - // 订阅 MessageManager - useEffect(() => { - if (!conversationId) return; - - const unsubscribe = messageManager.subscribe((event) => { - if (event.type === 'message_received') { - const { message, isCurrentUser } = event.payload; - handleMessageUpdate({ - type: isCurrentUser - ? MessageUpdateType.APPEND - : MessageUpdateType.PREPEND, - conversationId, - payload: { messages: [message] }, - timestamp: Date.now(), - }); - } - - if (event.type === 'messages_loaded') { - const { messages, direction } = event.payload; - handleMessageUpdate({ - type: direction === 'before' - ? MessageUpdateType.PREPEND - : MessageUpdateType.BATCH_APPEND, - conversationId, - payload: { messages }, - timestamp: Date.now(), - }); - } - - if (event.type === 'message_recalled') { - handleMessageUpdate({ - type: MessageUpdateType.RECALL, - conversationId, - payload: { messageId: event.payload.messageId }, - timestamp: Date.now(), - }); - } - }); - - return unsubscribe; - }, [conversationId, handleMessageUpdate]); - - return { messages }; -} -``` - -#### 3. 优化 FlatList 渲染 - -```typescript -// src/screens/message/components/ChatScreen/OptimizedMessageList.tsx - -export function OptimizedMessageList({ - messages, - onLoadMore, - hasMore, -}: { - messages: Message[]; - onLoadMore: () => void; - hasMore: boolean; -}) { - const viewabilityConfig = useRef({ - itemVisiblePercentThreshold: 50, - minimumViewTime: 100, - }); - - // 关键消息路径优化 - const keyExtractor = useCallback((item: Message) => item.id, []); - - const getItemLayout = useCallback((data: Message[] | null, index: number) => { - // 估算每条消息高度(可以根据类型调整) - const BASE_HEIGHT = 60; - const IMAGE_EXTRA = 200; - const EXTRA_PER_SEGMENT = 30; - - let length = BASE_HEIGHT; - if (data) { - const message = data[index]; - if (message.segments) { - length += message.segments.length * EXTRA_PER_SEGMENT; - } - if (message.segments?.some(s => s.type === 'image')) { - length += IMAGE_EXTRA; - } - } - - return { - length, - offset: data.slice(0, index).reduce((sum, m) => { - // 计算累计高度 - return sum + BASE_HEIGHT + - (m.segments?.length || 0) * EXTRA_PER_SEGMENT + - (m.segments?.some(s => s.type === 'image') ? IMAGE_EXTRA : 0); - }, 0), - index, - }; - }, []); - - // 消息类型判断 - const isSameDay = useCallback((prev: Message, next: Message) => { - const prevDate = new Date(prev.created_at).toDateString(); - const nextDate = new Date(next.created_at).toDateString(); - return prevDate === nextDate; - }, []); - - const renderItem = useCallback(({ item, index }: { item: Message; index: number }) => { - const prevItem = messages[index - 1]; - - return ( - 50} - /> - ); - }, [isSameDay]); - - return ( - - ); -} -``` - -#### 4. 修改 MessageManager 支持差异事件 - -```typescript -// src/stores/message/MessageManager.ts - -class MessageManager { - // 发送差异更新事件 - private emitMessageUpdate(update: MessageUpdate): void { - this.subscribers.forEach(subscriber => { - if (subscriber.types.includes(update.type) || subscriber.types.includes('*')) { - subscriber.callback(update); - } - }); - } - - async handleIncomingMessage(message: WSChatMessage): Promise { - // 检查消息是否已存在 - const existingMessages = this.messages.get(message.conversation_id) || []; - if (existingMessages.find(m => m.id === message.id)) { - return; // 避免重复 - } - - const newMessage = this.createMessageObject(message); - - // 直接发送差异更新,而不是全量更新 - this.emitMessageUpdate({ - type: MessageUpdateType.APPEND, - conversationId: message.conversation_id, - payload: { messages: [newMessage] }, - timestamp: Date.now(), - }); - - // 异步保存到数据库 - this.persistMessage(newMessage); - } -} -``` - -### 修改/新增文件列表 - -| 文件 | 操作 | 说明 | -|------|------|------| -| `src/stores/message/MessageUpdateTypes.ts` | 新增 | 消息更新类型定义 | -| `src/hooks/useDifferentialMessages.ts` | 新增 | 差异更新 Hook | -| `src/stores/messageManagerHooks.ts` | 修改 | 集成差异更新 | -| `src/stores/message/MessageManager.ts` | 修改 | 添加差异事件支持 | -| `src/screens/message/components/ChatScreen/OptimizedMessageList.tsx` | 新增 | 优化的消息列表组件 | - -### 性能收益 - -| 场景 | 优化前 | 优化后 | -|------|--------|--------| -| 1000人群,每秒10条消息 | 10000次/秒 UI 更新 | 60次/秒 UI 更新 | -| 滚动加载100条历史消息 | 全量重渲染100条 | 仅渲染可见区域~20条 | -| 消息撤回 | 全量更新 + UI 重建 | 单条更新 | - ---- - -## P1 媒体缓存清理 - -### 现有代码分析 - -#### 问题点 - -1. **`CacheDataSource.ts` (第14-28行)** - ```typescript - export class CacheDataSource implements ICacheDataSource { - private memoryCache: Map> = new Map(); - private maxSize: number; // 默认 100 条 - private defaultTtl: number; // 默认 5 分钟 - - // 没有媒体文件专用缓存管理 - // 没有磁盘空间监控 - } - ``` - -2. **缺少媒体缓存机制** - - 图片、视频、音频没有独立的缓存策略 - - 没有按会话清理过期媒体的功能 - - 没有用户手动清理入口 - -3. **`LocalDataSource.ts` (第73-86行)** - ```typescript - // 消息表结构 - `CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY NOT NULL, - conversationId TEXT NOT NULL, - segments TEXT - // 没有存储媒体文件路径 - )` - ``` - - 消息表没有记录媒体缓存路径 - - 无法追踪哪些媒体文件属于哪个会话 - -#### 存储风险 - -- 图片缓存:无限制增长 -- 视频缓存:无限制增长 -- 音频缓存:无限制增长 -- 总计:可能导致存储空间耗尽 - -### 实现方案 - -#### 1. 定义媒体缓存配置 - -```typescript -// src/services/media/MediaCacheConfig.ts - -export interface MediaCacheConfig { - // 图片配置 - image: { - maxMemoryCacheSize: number; // 内存缓存数量,默认 50 - maxDiskCacheSize: number; // 磁盘缓存大小(MB),默认 500 - maxAge: number; // 缓存有效期(小时),默认 168 (7天) - maxAgeForConversation: number; // 会话内媒体保留时间(天),默认 30 - }; - - // 视频配置 - video: { - maxDiskCacheSize: number; // 磁盘缓存大小(MB),默认 1000 - maxAge: number; // 缓存有效期(小时),默认 72 (3天) - autoPreload: boolean; // 是否自动预加载,默认 false - }; - - // 音频配置 - audio: { - maxDiskCacheSize: number; // 磁盘缓存大小(MB),默认 200 - maxAge: number; // 缓存有效期(天),默认 7 - }; - - // 全局配置 - global: { - checkOnStartup: boolean; // 启动时检查清理,默认 true - checkInterval: number; // 定期检查间隔(小时),默认 24 - lowStorageThreshold: number; // 低存储空间阈值(MB),默认 500 - }; -} - -export const DEFAULT_MEDIA_CACHE_CONFIG: MediaCacheConfig = { - image: { - maxMemoryCacheSize: 50, - maxDiskCacheSize: 500, - maxAge: 168, // 7 days - maxAgeForConversation: 30, - }, - video: { - maxDiskCacheSize: 1000, - maxAge: 72, // 3 days - autoPreload: false, - }, - audio: { - maxDiskCacheSize: 200, - maxAge: 168, // 7 days - }, - global: { - checkOnStartup: true, - checkInterval: 24, - lowStorageThreshold: 500, - }, -}; -``` - -#### 2. 创建媒体缓存管理器 - -```typescript -// src/services/media/MediaCacheManager.ts - -import * as FileSystem from 'expo-file-system'; -import { cacheDataSource } from '../data/datasources/CacheDataSource'; - -export enum MediaType { - IMAGE = 'image', - VIDEO = 'video', - AUDIO = 'audio', -} - -export interface MediaCacheInfo { - type: MediaType; - uri: string; - localPath: string; - conversationId?: string; - messageId?: string; - size: number; - createdAt: number; - lastAccessedAt: number; -} - -class MediaCacheManager { - private config: MediaCacheConfig; - private cacheDirectory: string; - private mediaRecords: Map = new Map(); - - // 缓存目录 - private readonly CACHE_DIR = `${FileSystem.documentDirectory}media_cache/`; - private readonly IMAGE_DIR = `${this.CACHE_DIR}images/`; - private readonly VIDEO_DIR = `${this.CACHE_DIR}videos/`; - private readonly AUDIO_DIR = `${this.CACHE_DIR}audios/`; - - constructor(config: MediaCacheConfig = DEFAULT_MEDIA_CACHE_CONFIG) { - this.config = config; - this.cacheDirectory = this.CACHE_DIR; - } - - // 初始化 - async initialize(): Promise { - await this.ensureDirectories(); - await this.loadMediaRecords(); - await this.cleanupIfNeeded(); - } - - // 确保缓存目录存在 - private async ensureDirectories(): Promise { - await FileSystem.makeDirectoryAsync(this.IMAGE_DIR, { intermediates: true }); - await FileSystem.makeDirectoryAsync(this.VIDEO_DIR, { intermediates: true }); - await FileSystem.makeDirectoryAsync(this.AUDIO_DIR, { intermediates: true }); - } - - // 缓存媒体文件 - async cacheMedia( - uri: string, - type: MediaType, - options: { - conversationId?: string; - messageId?: string; - } = {} - ): Promise { - const { conversationId, messageId } = options; - - // 生成唯一文件名 - const filename = this.generateFilename(uri, type); - const localPath = this.getMediaPath(type, filename); - - // 检查是否已缓存 - if (await this.exists(localPath)) { - await this.updateAccessTime(localPath); - return localPath; - } - - try { - // 下载文件 - const downloadResult = await FileSystem.downloadAsync(uri, localPath); - - // 记录缓存信息 - const cacheInfo: MediaCacheInfo = { - type, - uri, - localPath, - conversationId, - messageId, - size: downloadResult.headers['content-length'] - ? parseInt(downloadResult.headers['content-length']) - : 0, - createdAt: Date.now(), - lastAccessedAt: Date.now(), - }; - - await this.saveMediaRecord(localPath, cacheInfo); - await this.cleanupIfNeeded(); - - return localPath; - } catch (error) { - console.error('[MediaCacheManager] 缓存失败:', error); - throw error; - } - } - - // 获取缓存的媒体文件路径 - async getCachedMedia(uri: string, type: MediaType): Promise { - const record = this.findByUri(uri); - if (record && await this.exists(record.localPath)) { - await this.updateAccessTime(record.localPath); - return record.localPath; - } - return null; - } - - // 删除单个会话的所有媒体缓存 - async clearConversationMedia(conversationId: string): Promise { - const toDelete: string[] = []; - - for (const [path, info] of this.mediaRecords) { - if (info.conversationId === conversationId) { - toDelete.push(path); - } - } - - await Promise.all(toDelete.map(p => this.deleteMedia(p))); - } - - // 删除过期媒体 - async clearExpiredMedia(): Promise { - const now = Date.now(); - const maxAge = this.config.image.maxAge * 60 * 60 * 1000; // 转换为毫秒 - let deletedCount = 0; - - for (const [path, info] of this.mediaRecords) { - if (now - info.lastAccessedAt > maxAge) { - await this.deleteMedia(path); - deletedCount++; - } - } - - return deletedCount; - } - - // 清理超过会话保留期的媒体 - async clearOldConversationMedia(): Promise { - const now = Date.now(); - const maxAge = this.config.image.maxAgeForConversation * 24 * 60 * 60 * 1000; - let deletedCount = 0; - - for (const [path, info] of this.mediaRecords) { - if (info.conversationId && now - info.createdAt > maxAge) { - await this.deleteMedia(path); - deletedCount++; - } - } - - return deletedCount; - } - - // 按大小清理(LRU) - async clearBySizeLimit(): Promise { - const totalSize = await this.getTotalCacheSize(); - const limit = this.config.image.maxDiskCacheSize * 1024 * 1024; - - if (totalSize <= limit) { - return 0; - } - - // 按最后访问时间排序 - const sorted = Array.from(this.mediaRecords.entries()) - .sort((a, b) => a[1].lastAccessedAt - b[1].lastAccessedAt); - - let currentSize = totalSize; - let deletedCount = 0; - - for (const [path, info] of sorted) { - if (currentSize <= limit) break; - - await this.deleteMedia(path); - currentSize -= info.size; - deletedCount++; - } - - return deletedCount; - } - - // 获取缓存统计 - async getCacheStats(): Promise<{ - totalSize: number; - imageCount: number; - videoCount: number; - audioCount: number; - oldestItem: number; - newestItem: number; - }> { - const stats = { - totalSize: 0, - imageCount: 0, - videoCount: 0, - audioCount: 0, - oldestItem: Date.now(), - newestItem: 0, - }; - - for (const info of this.mediaRecords.values()) { - stats.totalSize += info.size; - stats.oldestItem = Math.min(stats.oldestItem, info.createdAt); - stats.newestItem = Math.max(stats.newestItem, info.createdAt); - - switch (info.type) { - case MediaType.IMAGE: stats.imageCount++; break; - case MediaType.VIDEO: stats.videoCount++; break; - case MediaType.AUDIO: stats.audioCount++; break; - } - } - - return stats; - } - - // 清理入口 - async cleanupIfNeeded(): Promise { - await this.clearExpiredMedia(); - await this.clearBySizeLimit(); - } - - // 私有辅助方法 - private generateFilename(uri: string, type: MediaType): string { - const ext = this.getExtension(uri, type); - const hash = this.hashString(uri); - return `${type}_${hash}_${Date.now()}.${ext}`; - } - - private getExtension(uri: string, type: MediaType): string { - if (type === MediaType.IMAGE) { - if (uri.includes('.gif')) return 'gif'; - if (uri.includes('.webp')) return 'webp'; - return 'jpg'; - } - if (type === MediaType.VIDEO) return 'mp4'; - if (type === MediaType.AUDIO) return 'mp3'; - return 'bin'; - } - - private getMediaPath(type: MediaType, filename: string): string { - switch (type) { - case MediaType.IMAGE: return `${this.IMAGE_DIR}${filename}`; - case MediaType.VIDEO: return `${this.VIDEO_DIR}${filename}`; - case MediaType.AUDIO: return `${this.AUDIO_DIR}${filename}`; - } - } - - private async exists(path: string): Promise { - const info = await FileSystem.getInfoAsync(path); - return info.exists; - } - - private async deleteMedia(path: string): Promise { - try { - await FileSystem.deleteAsync(path, { idempotent: true }); - this.mediaRecords.delete(path); - await this.removeMediaRecord(path); - } catch (error) { - console.warn('[MediaCacheManager] 删除失败:', path, error); - } - } - - private async saveMediaRecord(path: string, info: MediaCacheInfo): Promise { - this.mediaRecords.set(path, info); - await cacheDataSource.set(`media_${path}`, info, null); - } - - private async loadMediaRecords(): Promise { - // 从缓存数据源加载 - } - - private async updateAccessTime(path: string): Promise { - const info = this.mediaRecords.get(path); - if (info) { - info.lastAccessedAt = Date.now(); - await this.saveMediaRecord(path, info); - } - } - - private findByUri(uri: string): MediaCacheInfo | undefined { - for (const info of this.mediaRecords.values()) { - if (info.uri === uri) { - return info; - } - } - return undefined; - } - - private async getTotalCacheSize(): Promise { - let total = 0; - for (const info of this.mediaRecords.values()) { - total += info.size; - } - return total; - } - - private hashString(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; - } - return Math.abs(hash).toString(16); - } -} - -export const mediaCacheManager = new MediaCacheManager(); -``` - -#### 3. 创建清理策略 - -```typescript -// src/services/media/MediaCleanupPolicy.ts - -export enum CleanupTrigger { - STARTUP = 'startup', - SCHEDULED = 'scheduled', - MANUAL = 'manual', - LOW_STORAGE = 'low_storage', - CONVERSATION_DELETED = 'conversation_deleted', -} - -export interface CleanupResult { - trigger: CleanupTrigger; - timestamp: number; - deletedCount: number; - freedSize: number; - errors: string[]; -} - -class MediaCleanupPolicy { - private lastCleanup: number = 0; - private config: MediaCacheConfig; - - // 启动时清理 - async cleanupOnStartup(): Promise { - const errors: string[] = []; - let deletedCount = 0; - let freedSize = 0; - - try { - // 清理过期文件 - const expired = await mediaCacheManager.clearExpiredMedia(); - deletedCount += expired; - - // 清理超过保留期的会话媒体 - const oldConversation = await mediaCacheManager.clearOldConversationMedia(); - deletedCount += oldConversation; - - // 超过大小限制时清理 - const bySize = await mediaCacheManager.clearBySizeLimit(); - deletedCount += bySize; - - this.lastCleanup = Date.now(); - } catch (error) { - errors.push(`Startup cleanup failed: ${error}`); - } - - return { - trigger: CleanupTrigger.STARTUP, - timestamp: Date.now(), - deletedCount, - freedSize, - errors, - }; - } - - // 检查是否需要定期清理 - shouldRunScheduledCleanup(): boolean { - const interval = this.config.global.checkInterval * 60 * 60 * 1000; - return Date.now() - this.lastCleanup > interval; - } - - // 会话删除时清理 - async cleanupOnConversationDeleted(conversationId: string): Promise { - const errors: string[] = []; - let deletedCount = 0; - let freedSize = 0; - - try { - const statsBefore = await mediaCacheManager.getCacheStats(); - await mediaCacheManager.clearConversationMedia(conversationId); - const statsAfter = await mediaCacheManager.getCacheStats(); - - deletedCount = statsBefore.imageCount + statsBefore.videoCount + statsBefore.audioCount - - (statsAfter.imageCount + statsAfter.videoCount + statsAfter.audioCount); - freedSize = statsBefore.totalSize - statsAfter.totalSize; - } catch (error) { - errors.push(`Conversation cleanup failed: ${error}`); - } - - return { - trigger: CleanupTrigger.CONVERSATION_DELETED, - timestamp: Date.now(), - deletedCount, - freedSize, - errors, - }; - } -} - -export const mediaCleanupPolicy = new MediaCleanupPolicy(); -``` - -#### 4. 创建清理 Hook - -```typescript -// src/hooks/useMediaCache.ts - -export function useMediaCache() { - const [stats, setStats] = useState(null); - const [isClearing, setIsClearing] = useState(false); - - // 加载缓存统计 - const loadStats = useCallback(async () => { - const cacheStats = await mediaCacheManager.getCacheStats(); - setStats(cacheStats); - }, []); - - // 手动清理 - const clearAll = useCallback(async () => { - setIsClearing(true); - try { - await mediaCacheManager.clearExpiredMedia(); - await mediaCacheManager.clearBySizeLimit(); - await loadStats(); - } finally { - setIsClearing(false); - } - }, [loadStats]); - - // 清理指定会话的媒体 - const clearConversation = useCallback(async (conversationId: string) => { - await mediaCacheManager.clearConversationMedia(conversationId); - await loadStats(); - }, [loadStats]); - - // 组件挂载时加载统计 - useEffect(() => { - loadStats(); - }, [loadStats]); - - return { - stats, - isClearing, - loadStats, - clearAll, - clearConversation, - formatSize: (bytes: number) => { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; - return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`; - }, - }; -} -``` - -### 修改/新增文件列表 - -| 文件 | 操作 | 说明 | -|------|------|------| -| `src/services/media/MediaCacheConfig.ts` | 新增 | 媒体缓存配置 | -| `src/services/media/MediaCacheManager.ts` | 新增 | 媒体缓存管理器 | -| `src/services/media/MediaCleanupPolicy.ts` | 新增 | 清理策略 | -| `src/services/media/index.ts` | 新增 | 导出文件 | -| `src/hooks/useMediaCache.ts` | 新增 | 媒体缓存 Hook | -| `src/hooks/index.ts` | 修改 | 导出新 Hook | -| `src/data/datasources/CacheDataSource.ts` | 修改 | 添加媒体记录存储 | - -### 清理策略 - -```mermaid -graph TD - A[启动 App] --> B{检查启动清理标志} - B -->|是| C[清理过期媒体] - B -->|是| D[清理超期会话媒体] - B -->|是| E[检查大小限制] - E -->|超过限制| F[LRU 清理] - - G[定期检查] -->|间隔到期| C - G -->|间隔到期| D - G -->|间隔到期| E - - H[用户操作] -->|删除会话| I[清理该会话媒体] - H -->|手动清理| J[清理所有过期媒体] - - K[存储空间检查] -->|低于阈值| L[紧急清理] -``` - ---- - -## 实现优先级与依赖关系 - -### 优先级矩阵 - -| 优先级 | 功能 | 依赖 | 复杂度 | -|--------|------|------|--------| -| P0 | 分页状态管理 | 无 | 中 | -| P0 | 同步状态机 | 无 | 低 | -| P1 | 差异更新 | P0 分页状态管理 | 高 | -| P1 | 媒体缓存清理 | 无 | 中 | - -### 推荐实现顺序 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Phase 1: 基础改进 (1-2天) │ -│ ┌───────────────────┐ ┌───────────────────┐ │ -│ │ P0 分页状态管理 │ │ P0 同步状态机 │ │ -│ │ │ │ │ │ -│ │ - 避免重复加载 │ │ - 清晰状态反馈 │ │ -│ │ - 页面缓存 │ │ - 连接状态 Hook │ │ -│ │ - 预加载机制 │ │ - UI 状态指示器 │ │ -│ └───────────────────┘ └───────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Phase 2: 性能优化 (2-3天) │ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ P1 差异更新 ││ -│ │ ││ -│ │ - 依赖分页状态管理 ││ -│ │ - 消息更新类型定义 ││ -│ │ - 差异更新 Hook ││ -│ │ - FlatList 渲染优化 ││ -│ └─────────────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Phase 3: 存储优化 (1-2天) │ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ P1 媒体缓存清理 ││ -│ │ ││ -│ │ - 媒体缓存管理器 ││ -│ │ - 清理策略 ││ -│ │ - 用户清理界面 ││ -│ └─────────────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 风险与注意事项 - -1. **差异更新风险** - - 需要确保消息 ID 的唯一性 - - 并发更新时需要正确合并 - - 建议在开发环境进行大房间压力测试 - -2. **分页状态管理风险** - - 页面缓存会占用内存,需要设置上限 - - 分页状态需要正确序列化和恢复(跨会话) - -3. **状态机风险** - - 状态转换需要严格测试 - - 重连逻辑需要处理边界情况(如网络中断) - -4. **媒体缓存风险** - - 删除文件时需要确保没有正在使用 - - 缓存元数据需要持久化存储 - - 清理操作应该在后台线程执行 \ No newline at end of file diff --git a/plans/frontend_cursor_pagination_design.md b/plans/frontend_cursor_pagination_design.md new file mode 100644 index 0000000..00268ed --- /dev/null +++ b/plans/frontend_cursor_pagination_design.md @@ -0,0 +1,1131 @@ +# 前端游标分页适配方案设计文档 + +## 一、背景说明 + +后端已完成游标分页功能实现,共改造了 11 个接口。前端需要对接这些新的游标分页接口,替换现有的基于页码的分页方式。 + +### 后端游标分页接口列表 + +| 模块 | 接口路径 | 排序方式 | +|------|----------|----------| +| 帖子 | GET /posts/cursor | created_at DESC | +| 帖子搜索 | GET /posts/search/cursor | created_at DESC | +| 用户帖子 | GET /users/:id/posts/cursor | created_at DESC | +| 会话列表 | GET /conversations/cursor | updated_at DESC | +| 消息列表 | GET /conversations/:id/messages/cursor | seq DESC | +| 评论列表 | GET /comments/post/:id/cursor | created_at DESC | +| 评论回复 | GET /comments/:id/replies/cursor | created_at ASC | +| 通知列表 | GET /notifications/cursor | created_at DESC | +| 群组列表 | GET /groups/cursor | created_at DESC | +| 群成员 | GET /groups/:id/members/cursor | join_time DESC | +| 群公告 | GET /groups/:id/announcements/cursor | created_at DESC | + +### 后端 API 规范 + +**请求参数:** +- `cursor` - 游标字符串(首次请求不传) +- `direction` - 分页方向:`forward` 或 `backward`(默认 forward) +- `page_size` - 每页数量(默认 20,最大 100) + +**响应格式:** +```json +{ + "items": [...], + "next_cursor": "xxx", + "prev_cursor": "xxx", + "has_more": true +} +``` + +--- + +## 二、现有前端分页机制分析 + +### 2.1 分页类型定义 (`src/infrastructure/pagination/types.ts`) + +```typescript +// 现有分页状态 - 基于页码 +interface PaginationState { + currentPage: number; + pageSize: number; + totalLoaded: number; + hasMore: boolean; + isLoading: boolean; + lastLoadTime: number; + cursor?: string | number | null; // 已支持但未充分使用 + hasError: boolean; + error?: string; +} + +// 现有 FetchPageFunction +type FetchPageFunction = ( + page: number, + pageSize: number, + cursor?: string | number | null +) => Promise<{ + data: T[]; + hasMore: boolean; + cursor?: string | number | null; +}>; +``` + +### 2.2 分页状态管理器 (`PaginationStateManager.ts`) + +- 使用 `currentPage` 作为分页基准 +- 基于页码的缓存机制(`pageCaches: Map>`) +- `loadMore()` 方法自动递增页码 + +### 2.3 usePagination Hook (`usePagination.ts`) + +- 暴露 `currentPage`、`loadMore`、`refresh` 等方法 +- 内部调用 `PaginationStateManager` 进行状态管理 +- 已有 `cursor` 支持,但 UI 层仍使用页码 + +### 2.4 现有 API 服务层 + +**postService.ts:** +```typescript +// 现有分页方式 +async getPosts(page = 1, pageSize = 20, tab?: string): Promise> +``` + +**messageService.ts:** +```typescript +// 现有分页方式 - 基于 seq +async getMessages(conversationId, afterSeq?, beforeSeq?, limit?): Promise +``` + +### 2.5 现有页面组件分页方式 + +各页面使用**手动管理状态**的方式: +```typescript +const [page, setPage] = useState(1); +const [hasMore, setHasMore] = useState(true); + +const loadMore = useCallback(() => { + if (!loading && hasMore) { + const nextPage = page + 1; + setPage(nextPage); + loadData(nextPage); + } +}, [loading, hasMore, page, loadData]); +``` + +**涉及页面:** +- `HomeScreen.tsx` - 帖子列表 +- `SearchScreen.tsx` - 帖子搜索 +- `PostDetailScreen.tsx` - 帖子详情(评论列表) +- `MessageListScreen.tsx` - 会话列表 +- `ChatScreen.tsx` - 消息列表 +- `NotificationsScreen.tsx` - 通知列表 +- `GroupMembersScreen.tsx` - 群组成员 +- `JoinGroupScreen.tsx` - 群组列表 + +--- + +## 三、游标分页类型定义设计 + +### 3.1 新增游标分页相关类型 (`src/infrastructure/pagination/types.ts`) + +```typescript +// ==================== 游标分页类型 ==================== + +/** + * 游标分页方向 + */ +export type CursorDirection = 'forward' | 'backward'; + +/** + * 游标分页请求参数 + */ +export interface CursorPageRequest { + cursor?: string | null; + direction?: CursorDirection; + page_size?: number; +} + +/** + * 游标分页响应 + */ +export interface CursorPageResponse { + items: T[]; + next_cursor: string | null; + prev_cursor: string | null; + has_more: boolean; +} + +/** + * 游标分页状态 - 扩展现有 PaginationState + */ +export interface CursorPaginationState { + // 游标相关 + nextCursor: string | null; + prevCursor: string | null; + currentCursor: string | null; + + // 方向 + direction: CursorDirection; + + // 通用 + hasMore: boolean; + isLoading: boolean; + hasError: boolean; + error?: string; +} + +/** + * 游标分页配置 + */ +export interface CursorPaginationConfig { + pageSize: number; + prefetchThreshold: number; + enablePrefetch: boolean; + maxRetries: number; + retryDelay: number; +} + +/** + * 游标分页加载函数 + */ +export type CursorFetchFunction = ( + request: CursorPageRequest +) => Promise>; + +/** + * 游标分页加载结果 + */ +export interface CursorLoadMoreResult { + success: boolean; + data: T[]; + hasMore: boolean; + nextCursor: string | null; + prevCursor: string | null; + fromCache: boolean; + error?: string; +} + +/** + * 游标分页刷新结果 + */ +export interface CursorRefreshResult { + success: boolean; + data: T[]; + hasMore: boolean; + nextCursor: string | null; + error?: string; +} +``` + +### 3.2 新增游标分页 DTO 类型 (`src/types/dto.ts`) + +```typescript +// ==================== 游标分页 DTO ==================== + +/** + * 游标分页响应基础结构 + */ +export interface CursorPaginatedResponse { + items: T[]; + next_cursor: string | null; + prev_cursor: string | null; + has_more: boolean; +} + +/** + * 帖子列表游标分页响应 + */ +export interface PostCursorResponse extends CursorPaginatedResponse {} + +/** + * 会话列表游标分页响应 + */ +export interface ConversationCursorResponse extends CursorPaginatedResponse {} + +/** + * 消息列表游标分页响应 + */ +export interface MessageCursorResponse extends CursorPaginatedResponse {} + +/** + * 评论列表游标分页响应 + */ +export interface CommentCursorResponse extends CursorPaginatedResponse {} + +/** + * 通知列表游标分页响应 + */ +export interface NotificationCursorResponse extends CursorPaginatedResponse {} + +/** + * 群组列表游标分页响应 + */ +export interface GroupCursorResponse extends CursorPaginatedResponse {} + +/** + * 群成员列表游标分页响应 + */ +export interface GroupMemberCursorResponse extends CursorPaginatedResponse {} + +/** + * 群公告列表游标分页响应 + */ +export interface GroupAnnouncementCursorResponse extends CursorPaginatedResponse {} +``` + +--- + +## 四、API 服务层改造设计 + +### 4.1 新增游标分页 API 方法 + +#### postService.ts 改造 +```typescript +class PostService { + // ==================== 游标分页方法 ==================== + + /** + * 获取帖子列表(游标分页) + * GET /api/v1/posts/cursor + */ + async getPostsCursor( + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get('/posts/cursor', params); + return { + items: response.data.items, + next_cursor: response.data.next_cursor, + prev_cursor: response.data.prev_cursor, + has_more: response.data.has_more, + }; + } + + /** + * 搜索帖子(游标分页) + * GET /api/v1/posts/search/cursor + */ + async searchPostsCursor( + keyword: string, + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + keyword, + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get('/posts/search/cursor', params); + return response.data; + } + + /** + * 获取用户帖子列表(游标分页) + * GET /api/v1/users/:id/posts/cursor + */ + async getUserPostsCursor( + userId: string, + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get( + `/users/${userId}/posts/cursor`, + params + ); + return response.data; + } +} +``` + +#### messageService.ts 改造 +```typescript +class MessageService { + // ==================== 会话列表游标分页 ==================== + + /** + * 获取会话列表(游标分页) + * GET /api/v1/conversations/cursor + */ + async getConversationsCursor( + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get('/conversations/cursor', params); + return response.data; + } + + // ==================== 消息列表游标分页 ==================== + + /** + * 获取消息列表(游标分页) + * GET /api/v1/conversations/:id/messages/cursor + */ + async getMessagesCursor( + conversationId: string, + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get( + `/conversations/${encodeURIComponent(conversationId)}/messages/cursor`, + params + ); + return response.data; + } +} +``` + +#### 新增 commentService.ts +```typescript +// 新建文件: src/services/commentService.ts + +import { api } from './api'; +import { CommentCursorResponse, CursorPageRequest, CursorPageResponse } from '../types/dto'; +import { CommentDTO } from '../types/dto'; + +class CommentService { + /** + * 获取帖子评论列表(游标分页) + * GET /api/v1/comments/post/:id/cursor + */ + async getPostCommentsCursor( + postId: string, + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get( + `/comments/post/${postId}/cursor`, + params + ); + return response.data; + } + + /** + * 获取评论回复列表(游标分页) + * GET /api/v1/comments/:id/replies/cursor + */ + async getCommentRepliesCursor( + commentId: string, + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get( + `/comments/${commentId}/replies/cursor`, + params + ); + return response.data; + } +} + +export const commentService = new CommentService(); +``` + +#### 新增 notificationService.ts +```typescript +// 新建文件: src/services/notificationService.ts + +import { api } from './api'; +import { NotificationCursorResponse, CursorPageRequest, CursorPageResponse } from '../types/dto'; +import { NotificationDTO } from '../types/dto'; + +class NotificationService { + /** + * 获取通知列表(游标分页) + * GET /api/v1/notifications/cursor + */ + async getNotificationsCursor( + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get('/notifications/cursor', params); + return response.data; + } +} + +export const notificationService = new NotificationService(); +``` + +#### 新增 groupService.ts (扩展现有群组服务) +```typescript +// 在现有 groupService.ts 中添加游标分页方法 + +class GroupService { + /** + * 获取群组列表(游标分页) + * GET /api/v1/groups/cursor + */ + async getGroupsCursor( + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get('/groups/cursor', params); + return response.data; + } + + /** + * 获取群成员列表(游标分页) + * GET /api/v1/groups/:id/members/cursor + */ + async getGroupMembersCursor( + groupId: string, + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get( + `/groups/${groupId}/members/cursor`, + params + ); + return response.data; + } + + /** + * 获取群公告列表(游标分页) + * GET /api/v1/groups/:id/announcements/cursor + */ + async getGroupAnnouncementsCursor( + groupId: string, + request: CursorPageRequest = {} + ): Promise> { + const params: Record = { + page_size: request.page_size || 20, + }; + if (request.cursor) params.cursor = request.cursor; + if (request.direction) params.direction = request.direction; + + const response = await api.get( + `/groups/${groupId}/announcements/cursor`, + params + ); + return response.data; + } +} +``` + +--- + +## 五、分页 Hook 改造设计 + +### 5.1 新增 useCursorPagination Hook (`src/hooks/useCursorPagination.ts`) + +```typescript +/** + * 游标分页 Hook + * + * 提供基于游标的分页功能,包括: + * - loadMore: 加载下一页 + * - loadPrevious: 加载上一页(双向游标支持) + * - refresh: 刷新数据 + * - 状态管理 + */ + +import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; +import { CursorPaginationState, CursorPaginationConfig } from '../infrastructure/pagination/types'; + +export interface UseCursorPaginationOptions { + /** 分页键 */ + key: string | null; + /** 数据获取函数 */ + fetchFunction: CursorFetchFunction; + /** 分页配置 */ + config?: Partial; + /** 初始数据 */ + initialData?: T[]; + /** 是否自动加载第一页 */ + autoLoad?: boolean; + /** 是否启用自动预加载 */ + enableAutoPrefetch?: boolean; +} + +export interface UseCursorPaginationReturn { + /** 当前数据列表 */ + data: T[]; + /** 是否正在加载 */ + isLoading: boolean; + /** 是否正在刷新 */ + isRefreshing: boolean; + /** 是否有更多数据 */ + hasMore: boolean; + /** 是否有上一页 */ + hasPrevious: boolean; + /** 是否有错误 */ + hasError: boolean; + /** 错误信息 */ + error?: string; + /** 当前游标 */ + currentCursor: string | null; + /** 加载下一页 */ + loadMore: () => Promise>; + /** 加载上一页 */ + loadPrevious: () => Promise>; + /** 刷新数据 */ + refresh: () => Promise>; + /** 重置分页状态 */ + reset: () => void; +} + +export function useCursorPagination( + options: UseCursorPaginationOptions +): UseCursorPaginationReturn { + const { + key, + fetchFunction, + config: userConfig, + initialData = [], + autoLoad = false, + enableAutoPrefetch = true, + } = options; + + // 合并配置 + const config = useMemo(() => ({ + ...DEFAULT_CURSOR_PAGINATION_CONFIG, + ...userConfig, + }), [userConfig]); + + // 数据状态 + const [data, setData] = useState(initialData); + const [state, setState] = useState({ + nextCursor: null, + prevCursor: null, + currentCursor: null, + direction: 'forward', + hasMore: true, + isLoading: false, + hasError: false, + error: undefined, + }); + + // Refs + const autoLoadRef = useRef(false); + const isMountedRef = useRef(true); + + // 派生的 UI 状态 + const isRefreshing = state.isLoading && data.length > 0; + const hasPrevious = state.prevCursor !== null; + + /** + * 加载下一页 + */ + const loadMore = useCallback(async (): Promise> => { + if (!key) { + return { success: false, data: [], hasMore: false, nextCursor: null, prevCursor: null, fromCache: false, error: 'No pagination key' }; + } + + try { + setState(prev => ({ ...prev, isLoading: true, hasError: false })); + + const response = await fetchFunction({ + cursor: state.nextCursor, + direction: 'forward', + page_size: config.pageSize, + }); + + if (isMountedRef.current) { + setData(prev => [...prev, ...response.items]); + setState({ + nextCursor: response.next_cursor, + prevCursor: response.prev_cursor, + currentCursor: response.next_cursor, + direction: 'forward', + hasMore: response.has_more, + isLoading: false, + hasError: false, + }); + } + + return { + success: true, + data: response.items, + hasMore: response.has_more, + nextCursor: response.next_cursor, + prevCursor: response.prev_cursor, + fromCache: false, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + if (isMountedRef.current) { + setState(prev => ({ ...prev, isLoading: false, hasError: true, error: errorMessage })); + } + return { success: false, data: [], hasMore: false, nextCursor: null, prevCursor: null, fromCache: false, error: errorMessage }; + } + }, [key, fetchFunction, state.nextCursor, config.pageSize]); + + /** + * 加载上一页 + */ + const loadPrevious = useCallback(async (): Promise> => { + if (!key || !state.prevCursor) { + return { success: false, data: [], hasMore: false, nextCursor: null, prevCursor: null, fromCache: false, error: 'No previous page' }; + } + + try { + setState(prev => ({ ...prev, isLoading: true, hasError: false })); + + const response = await fetchFunction({ + cursor: state.prevCursor, + direction: 'backward', + page_size: config.pageSize, + }); + + if (isMountedRef.current) { + setData(prev => [...response.items, ...prev]); + setState({ + nextCursor: response.next_cursor, + prevCursor: response.prev_cursor, + currentCursor: response.prev_cursor, + direction: 'backward', + hasMore: response.has_more, + isLoading: false, + hasError: false, + }); + } + + return { + success: true, + data: response.items, + hasMore: response.has_more, + nextCursor: response.next_cursor, + prevCursor: response.prev_cursor, + fromCache: false, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + if (isMountedRef.current) { + setState(prev => ({ ...prev, isLoading: false, hasError: true, error: errorMessage })); + } + return { success: false, data: [], hasMore: false, nextCursor: null, prevCursor: null, fromCache: false, error: errorMessage }; + } + }, [key, fetchFunction, state.prevCursor, config.pageSize]); + + /** + * 刷新数据 + */ + const refresh = useCallback(async (): Promise> => { + if (!key) { + return { success: false, data: [], hasMore: false, nextCursor: null, error: 'No pagination key' }; + } + + try { + setState(prev => ({ ...prev, isLoading: true, hasError: false })); + + const response = await fetchFunction({ + cursor: null, + direction: 'forward', + page_size: config.pageSize, + }); + + if (isMountedRef.current) { + setData(response.items); + setState({ + nextCursor: response.next_cursor, + prevCursor: null, + currentCursor: null, + direction: 'forward', + hasMore: response.has_more, + isLoading: false, + hasError: false, + }); + } + + return { + success: true, + data: response.items, + hasMore: response.has_more, + nextCursor: response.next_cursor, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + if (isMountedRef.current) { + setState(prev => ({ ...prev, isLoading: false, hasError: true, error: errorMessage })); + } + return { success: false, data: [], hasMore: false, nextCursor: null, error: errorMessage }; + } + }, [key, fetchFunction, config.pageSize]); + + /** + * 重置分页状态 + */ + const reset = useCallback(() => { + if (isMountedRef.current) { + setData([]); + setState({ + nextCursor: null, + prevCursor: null, + currentCursor: null, + direction: 'forward', + hasMore: true, + isLoading: false, + hasError: false, + }); + autoLoadRef.current = false; + } + }, []); + + // 自动加载第一页 + useEffect(() => { + if (!key || !autoLoad || autoLoadRef.current) return; + if (data.length > 0) return; + + autoLoadRef.current = true; + refresh(); + }, [key, autoLoad, data.length, refresh]); + + // 组件卸载时清理 + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + return { + data, + isLoading: state.isLoading, + isRefreshing, + hasMore: state.hasMore, + hasPrevious, + hasError: state.hasError, + error: state.error, + currentCursor: state.currentCursor, + loadMore, + loadPrevious, + refresh, + reset, + }; +} +``` + +--- + +## 六、各列表页面改造计划 + +### 6.1 帖子列表 (HomeScreen.tsx) + +```typescript +// 改造后的分页方式 +const { + data: posts, + isLoading, + hasMore, + loadMore, + refresh, +} = useCursorPagination({ + key: 'home-posts', + fetchFunction: async (request) => { + return postService.getPostsCursor(request); + }, + config: { pageSize: 20 }, + autoLoad: true, +}); + +// UI 层使用 FlatList/FlashList 的 onEndReached + 0} + onRefresh={refresh} +/> +``` + +### 6.2 会话列表 (MessageListScreen.tsx) + +```typescript +const { + data: conversations, + isLoading, + hasMore, + loadMore, + refresh, +} = useCursorPagination({ + key: 'conversations', + fetchFunction: async (request) => { + return messageService.getConversationsCursor(request); + }, + autoLoad: true, +}); +``` + +### 6.3 消息列表 (ChatScreen.tsx) + +```typescript +// 聊天消息需要支持双向加载 +const { + data: messages, + isLoading, + hasMore, + hasPrevious, + loadMore, // 加载更多历史 + loadPrevious, // 加载更新的消息 + refresh, +} = useCursorPagination({ + key: `chat-${conversationId}`, + fetchFunction: async (request) => { + return messageService.getMessagesCursor(conversationId, request); + }, +}); +``` + +### 6.4 帖子搜索 (SearchScreen.tsx) + +```typescript +const { + data: searchResults, + isLoading, + hasMore, + loadMore, + refresh, +} = useCursorPagination({ + key: `search-${keyword}`, + fetchFunction: async (request) => { + return postService.searchPostsCursor(keyword, request); + }, + autoLoad: !!keyword, +}); +``` + +### 6.5 评论列表 (PostDetailScreen.tsx) + +```typescript +const { + data: comments, + isLoading, + hasMore, + loadMore, + refresh, +} = useCursorPagination({ + key: `comments-${postId}`, + fetchFunction: async (request) => { + return commentService.getPostCommentsCursor(postId, request); + }, +}); +``` + +### 6.6 通知列表 (NotificationsScreen.tsx) + +```typescript +const { + data: notifications, + isLoading, + hasMore, + loadMore, + refresh, +} = useCursorPagination({ + key: 'notifications', + fetchFunction: async (request) => { + return notificationService.getNotificationsCursor(request); + }, +}); +``` + +### 6.7 群组列表 (JoinGroupScreen.tsx) + +```typescript +const { + data: groups, + isLoading, + hasMore, + loadMore, + refresh, +} = useCursorPagination({ + key: 'groups', + fetchFunction: async (request) => { + return groupService.getGroupsCursor(request); + }, +}); +``` + +### 6.8 群组成员 (GroupMembersScreen.tsx) + +```typescript +const { + data: members, + isLoading, + hasMore, + loadMore, + refresh, +} = useCursorPagination({ + key: `group-members-${groupId}`, + fetchFunction: async (request) => { + return groupService.getGroupMembersCursor(groupId, request); + }, +}); +``` + +--- + +## 七、需要修改的文件清单 + +### 7.1 新增文件 + +| 文件路径 | 说明 | +|----------|------| +| `src/hooks/useCursorPagination.ts` | 新增游标分页 Hook | +| `src/services/commentService.ts` | 新增评论服务(含游标分页) | +| `src/services/notificationService.ts` | 新增通知服务(含游标分页) | +| `src/types/cursor.ts` | 新增游标分页相关类型定义 | + +### 7.2 需要修改的文件 + +| 文件路径 | 修改内容 | +|----------|----------| +| `src/types/dto.ts` | 新增游标分页 DTO 类型 | +| `src/services/postService.ts` | 新增游标分页方法(保留旧方法兼容) | +| `src/services/messageService.ts` | 新增会话/消息游标分页方法 | +| `src/services/groupService.ts` | 新增群组相关游标分页方法 | +| `src/infrastructure/pagination/types.ts` | 新增游标分页类型定义 | +| `src/infrastructure/pagination/CursorPaginationStateManager.ts` | 新增游标分页状态管理器(可选) | +| `src/screens/home/HomeScreen.tsx` | 改造为使用 useCursorPagination | +| `src/screens/home/SearchScreen.tsx` | 改造为使用 useCursorPagination | +| `src/screens/home/PostDetailScreen.tsx` | 改造评论列表使用 useCursorPagination | +| `src/screens/message/MessageListScreen.tsx` | 改造为使用 useCursorPagination | +| `src/screens/message/ChatScreen.tsx` | 改造消息列表使用 useCursorPagination | +| `src/screens/message/NotificationsScreen.tsx` | 改造为使用 useCursorPagination | +| `src/screens/message/JoinGroupScreen.tsx` | 改造为使用 useCursorPagination | +| `src/screens/message/GroupMembersScreen.tsx` | 改造为使用 useCursorPagination | + +--- + +## 八、实现顺序建议 + +### 阶段一:基础设施(优先级高) +1. 在 `src/types/dto.ts` 新增游标分页 DTO 类型 +2. 在 `src/infrastructure/pagination/types.ts` 新增游标分页类型 +3. 创建 `src/hooks/useCursorPagination.ts` + +### 阶段二:API 服务层 +4. 改造 `postService.ts` - 新增游标分页方法 +5. 改造 `messageService.ts` - 新增游标分页方法 +6. 创建 `commentService.ts` +7. 创建 `notificationService.ts` +8. 改造 `groupService.ts` - 新增游标分页方法 + +### 阶段三:UI 组件改造(按依赖顺序) +9. `HomeScreen.tsx` - 帖子列表 +10. `SearchScreen.tsx` - 帖子搜索 +11. `PostDetailScreen.tsx` - 评论列表 +12. `MessageListScreen.tsx` - 会话列表 +13. `ChatScreen.tsx` - 消息列表 +14. `NotificationsScreen.tsx` - 通知列表 +15. `JoinGroupScreen.tsx` - 群组列表 +16. `GroupMembersScreen.tsx` - 群组成员 + +--- + +## 九、架构图 + +```mermaid +graph TB + subgraph "UI Layer" + HomeScreen[HomeScreen.tsx] + SearchScreen[SearchScreen.tsx] + ChatScreen[ChatScreen.tsx] + MessageListScreen[MessageListScreen.tsx] + NotificationsScreen[NotificationsScreen.tsx] + GroupMembersScreen[GroupMembersScreen.tsx] + end + + subgraph "Hooks Layer" + useCursorPagination[useCursorPagination.ts] + usePagination[usePagination.ts - 保留兼容] + end + + subgraph "Service Layer" + postService[postService.ts] + messageService[messageService.ts] + commentService[commentService.ts] + notificationService[notificationService.ts] + groupService[groupService.ts] + end + + subgraph "API Layer" + API[API Client] + end + + subgraph "Backend" + CursorAPI[游标分页 API] + end + + HomeScreen --> useCursorPagination + SearchScreen --> useCursorPagination + ChatScreen --> useCursorPagination + MessageListScreen --> useCursorPagination + NotificationsScreen --> useCursorPagination + GroupMembersScreen --> useCursorPagination + + useCursorPagination --> postService + useCursorPagination --> messageService + useCursorPagination --> commentService + useCursorPagination --> notificationService + useCursorPagination --> groupService + + postService --> API + messageService --> API + commentService --> API + notificationService --> API + groupService --> API + + API --> CursorAPI +``` + +--- + +## 十、注意事项 + +### 10.1 兼容性考虑 +- 保留现有的页码分页方法一段时间,避免一次性全量替换带来的风险 +- 通过功能开关或配置控制是否启用游标分页 + +### 10.2 消息列表特殊处理 +- 聊天消息需要支持双向加载(加载历史 + 加载最新) +- 需要处理好新消息的插入位置(头部 vs 尾部) + +### 10.3 缓存策略 +- 游标分页的缓存策略需要调整,不再基于页码 +- 可以考虑基于 cursor 字符串进行缓存 + +### 10.4 错误处理 +- 网络错误时需要显示重试选项 +- 游标失效时需要提示用户刷新列表 diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 88416f5..d18b9d8 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -83,6 +83,9 @@ export type { UsePaginationReturn, } from './usePagination'; +// ==================== 游标分页 Hooks ==================== +export { useCursorPagination } from './useCursorPagination'; + // ==================== 连接状态 Hooks ==================== export { useConnectionState } from './useConnectionState'; diff --git a/src/hooks/useCursorPagination.ts b/src/hooks/useCursorPagination.ts new file mode 100644 index 0000000..403316e --- /dev/null +++ b/src/hooks/useCursorPagination.ts @@ -0,0 +1,208 @@ +import { useState, useCallback, useRef } from 'react'; +import { + CursorPaginationState, + CursorPaginationConfig, + CursorDirection, + UseCursorPaginationReturn, + CursorFetchFunction, +} from '../infrastructure/pagination/types'; +import { CursorPaginationResponse } from '../types/dto'; + +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; + +/** + * 游标分页 Hook + * + * @param fetchFunction 数据获取函数 + * @param config 分页配置 + * @param extraParams 额外参数(会传递给 fetchFunction) + */ +export function useCursorPagination( + fetchFunction: CursorFetchFunction, + config: Partial = {}, + extraParams?: P +): UseCursorPaginationReturn { + const { pageSize = DEFAULT_PAGE_SIZE, bidirectional = false } = config; + + // 限制 pageSize 在有效范围内 + const effectivePageSize = Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE); + + const [state, setState] = useState>({ + items: [], + nextCursor: null, + prevCursor: null, + hasMore: false, + isLoading: false, + isRefreshing: false, + error: null, + isFirstLoad: true, + }); + + // 用于取消请求的标志 + const cancelledRef = useRef(false); + + // 加载更多(下一页) + const loadMore = useCallback(async () => { + if (state.isLoading || !state.hasMore) { + return; + } + + cancelledRef.current = false; + + setState(prev => ({ ...prev, isLoading: true, error: null })); + + try { + const response: CursorPaginationResponse = await fetchFunction({ + cursor: state.nextCursor || undefined, + direction: 'forward', + pageSize: effectivePageSize, + extraParams, + }); + + if (cancelledRef.current) return; + + setState(prev => ({ + ...prev, + items: [...prev.items, ...response.items], + nextCursor: response.next_cursor, + prevCursor: response.prev_cursor, + hasMore: response.has_more && response.next_cursor !== null, + isLoading: false, + isFirstLoad: false, + })); + } catch (error) { + if (cancelledRef.current) return; + + setState(prev => ({ + ...prev, + isLoading: false, + error: error instanceof Error ? error.message : '加载失败', + })); + } + }, [state.isLoading, state.hasMore, state.nextCursor, fetchFunction, effectivePageSize, extraParams]); + + // 加载上一页(双向分页) + const loadPrevious = useCallback(async () => { + if (!bidirectional || state.isLoading || !state.prevCursor) { + return; + } + + cancelledRef.current = false; + + setState(prev => ({ ...prev, isLoading: true, error: null })); + + try { + const response: CursorPaginationResponse = await fetchFunction({ + cursor: state.prevCursor, + direction: 'backward', + pageSize: effectivePageSize, + extraParams, + }); + + if (cancelledRef.current) return; + + setState(prev => ({ + ...prev, + // 向前加载时,新数据放在前面 + items: [...response.items, ...prev.items], + nextCursor: response.next_cursor, + prevCursor: response.prev_cursor, + hasMore: response.has_more, + isLoading: false, + isFirstLoad: false, + })); + } catch (error) { + if (cancelledRef.current) return; + + setState(prev => ({ + ...prev, + isLoading: false, + error: error instanceof Error ? error.message : '加载失败', + })); + } + }, [bidirectional, state.isLoading, state.prevCursor, fetchFunction, effectivePageSize, extraParams]); + + // 刷新数据 + const refresh = useCallback(async () => { + cancelledRef.current = false; + + setState(prev => ({ ...prev, isRefreshing: true, error: null })); + + try { + const response: CursorPaginationResponse = await fetchFunction({ + cursor: undefined, + direction: 'forward', + pageSize: effectivePageSize, + extraParams, + }); + + if (cancelledRef.current) return; + + setState({ + items: response.items, + nextCursor: response.next_cursor, + prevCursor: response.prev_cursor, + hasMore: response.has_more && response.next_cursor !== null, + isLoading: false, + isRefreshing: false, + error: null, + isFirstLoad: false, + }); + } catch (error) { + if (cancelledRef.current) return; + + setState(prev => ({ + ...prev, + isRefreshing: false, + error: error instanceof Error ? error.message : '刷新失败', + })); + } + }, [fetchFunction, effectivePageSize, extraParams]); + + // 重置状态 + const reset = useCallback(() => { + cancelledRef.current = true; + setState({ + items: [], + nextCursor: null, + prevCursor: null, + hasMore: false, + isLoading: false, + isRefreshing: false, + error: null, + isFirstLoad: true, + }); + }, []); + + // 设置数据(用于外部数据注入) + const setItems = useCallback( + ( + items: T[], + nextCursor: string | null, + prevCursor: string | null, + hasMore: boolean + ) => { + setState(prev => ({ + ...prev, + items, + nextCursor, + prevCursor, + hasMore, + isFirstLoad: false, + })); + }, + [] + ); + + return { + ...state, + loadMore, + loadPrevious, + refresh, + reset, + setItems, + }; +} + +export default useCursorPagination; \ No newline at end of file diff --git a/src/infrastructure/pagination/index.ts b/src/infrastructure/pagination/index.ts index de25c21..495dd7d 100644 --- a/src/infrastructure/pagination/index.ts +++ b/src/infrastructure/pagination/index.ts @@ -19,6 +19,16 @@ export type { FetchPageFunction, } from './types'; +// 导出游标分页相关类型 +export type { + CursorDirection, + CursorPaginationConfig, + CursorPaginationState, + CursorPaginationActions, + UseCursorPaginationReturn, + CursorFetchFunction, +} from './types'; + // 导出工具函数和常量 export { DEFAULT_PAGINATION_CONFIG, diff --git a/src/infrastructure/pagination/types.ts b/src/infrastructure/pagination/types.ts index 6c96bab..3ea56de 100644 --- a/src/infrastructure/pagination/types.ts +++ b/src/infrastructure/pagination/types.ts @@ -180,3 +180,79 @@ export function createPageCache( export function isCacheExpired(cache: PageCache, ttl: number): boolean { return Date.now() - cache.cachedAt > ttl; } + +// ==================== 游标分页相关类型 ==================== + +/** + * 游标分页方向 + */ +export type CursorDirection = 'forward' | 'backward'; + +/** + * 游标分页配置 + */ +export interface CursorPaginationConfig { + /** 每页数量 */ + pageSize: number; + /** 是否启用双向分页 */ + bidirectional?: boolean; +} + +/** + * 游标分页状态 + */ +export interface CursorPaginationState { + /** 数据项列表 */ + items: T[]; + /** 下一页游标 */ + nextCursor: string | null; + /** 上一页游标 */ + prevCursor: string | null; + /** 是否有更多数据 */ + hasMore: boolean; + /** 是否正在加载 */ + isLoading: boolean; + /** 是否正在刷新 */ + isRefreshing: boolean; + /** 错误信息 */ + error: string | null; + /** 是否为首次加载 */ + isFirstLoad: boolean; +} + +/** + * 游标分页操作 + */ +export interface CursorPaginationActions { + /** 加载更多(下一页) */ + loadMore: () => Promise; + /** 加载上一页(双向分页) */ + loadPrevious: () => Promise; + /** 刷新数据(重新从第一页加载) */ + refresh: () => Promise; + /** 重置状态 */ + reset: () => void; + /** 设置数据(用于外部数据注入) */ + setItems: (items: T[], nextCursor: string | null, prevCursor: string | null, hasMore: boolean) => void; +} + +/** + * 游标分页 Hook 返回值 + */ +export interface UseCursorPaginationReturn extends CursorPaginationState, CursorPaginationActions {} + +/** + * 游标分页数据获取函数 + */ +export type CursorFetchFunction = (params: { + cursor?: string; + direction: CursorDirection; + pageSize: number; + /** 额外参数 */ + extraParams?: P; +}) => Promise<{ + items: T[]; + next_cursor: string | null; + prev_cursor: string | null; + has_more: boolean; +}>; diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index c55e5ad..c889eb0 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -13,7 +13,6 @@ import { RefreshControl, StatusBar, TouchableOpacity, - NativeScrollEvent, NativeSyntheticEvent, Alert, Clipboard, @@ -33,6 +32,8 @@ import { PostCard, TabBar, SearchBar } from '../../components/business'; import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common'; import { HomeStackParamList, RootStackParamList } from '../../navigation/types'; import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive'; +import { useCursorPagination } from '../../hooks/useCursorPagination'; +import { CursorPaginationRequest } from '../../types/dto'; import { SearchScreen } from './SearchScreen'; import { CreatePostScreen } from '../create/CreatePostScreen'; import { navigationService } from '../../infrastructure/navigation/navigationService'; @@ -42,8 +43,6 @@ type NavigationProp = NativeStackNavigationProp & Na const TABS = ['推荐', '关注', '热门', '最新']; const TAB_ICONS = ['compass-outline', 'account-heart-outline', 'fire', 'clock-outline']; const DEFAULT_PAGE_SIZE = 20; -const SCROLL_BOTTOM_THRESHOLD = 240; -const LOAD_MORE_COOLDOWN_MS = 800; const SWIPE_TRANSLATION_THRESHOLD = 40; const SWIPE_COOLDOWN_MS = 300; const MOBILE_TAB_BAR_HEIGHT = 64; @@ -51,11 +50,12 @@ const MOBILE_TAB_FLOATING_MARGIN = 12; const MOBILE_FAB_GAP = 12; type ViewMode = 'list' | 'grid'; +type PostType = 'recommend' | 'follow' | 'hot' | 'latest'; export const HomeScreen: React.FC = () => { const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { fetchPosts, likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore(); + const { likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore(); const currentUser = useCurrentUser(); // 使用响应式 hook @@ -65,9 +65,6 @@ export const HomeScreen: React.FC = () => { isTablet, isDesktop, isWideScreen, - breakpoint, - orientation, - isLandscape } = useResponsive(); // 响应式间距 @@ -75,12 +72,6 @@ export const HomeScreen: React.FC = () => { const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 }); const [activeIndex, setActiveIndex] = useState(0); - const [posts, setPosts] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); - const [page, setPage] = useState(1); - const [hasMore, setHasMore] = useState(true); const [viewMode, setViewMode] = useState('list'); // 图片查看器状态 @@ -95,18 +86,56 @@ export const HomeScreen: React.FC = () => { const [showCreatePost, setShowCreatePost] = useState(false); // 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态 - const postIdsRef = React.useRef>(new Set()); - const inFlightRequestKeysRef = React.useRef>(new Set()); - const lastLoadMoreTriggerAtRef = useRef(0); + const postIdsRef = useRef>(new Set()); const lastSwipeAtRef = useRef(0); - // 用 ref 同步关键状态,避免 onWaterfallScroll 的陈旧闭包问题 - const pageRef = useRef(page); - const loadingMoreRef = useRef(loadingMore); - const hasMoreRef = useRef(hasMore); - pageRef.current = page; - loadingMoreRef.current = loadingMore; - hasMoreRef.current = hasMore; + // 获取当前 tab 对应的帖子类型 + const getPostType = useCallback((): PostType => { + switch (activeIndex) { + case 0: return 'recommend'; + case 1: return 'follow'; + case 2: return 'hot'; + case 3: return 'latest'; + default: return 'recommend'; + } + }, [activeIndex]); + + // 使用游标分页获取帖子列表 + const { + items: posts, + isLoading, + isRefreshing, + hasMore, + error, + loadMore, + refresh, + } = useCursorPagination( + async ({ cursor, pageSize, extraParams }) => { + const params: CursorPaginationRequest = { + cursor, + page_size: pageSize, + post_type: extraParams?.post_type, + }; + const response = await postService.getPostsCursor(params); + return response; + }, + { pageSize: DEFAULT_PAGE_SIZE }, + { post_type: getPostType() } + ); + + // Tab 切换时刷新数据 + useEffect(() => { + refresh(); + }, [activeIndex]); + + // 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新) + useEffect(() => { + if (posts.length === 0) return; + + // 更新 postIdsRef + const currentPostIds = new Set(posts.map(p => p.id)); + postIdsRef.current = currentPostIds; + }, [posts, storePosts]); // 根据屏幕尺寸确定网格列数 const gridColumns = useMemo(() => { @@ -154,147 +183,6 @@ export const HomeScreen: React.FC = () => { return insets.bottom + MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN + MOBILE_FAB_GAP - MOBILE_TAB_BAR_HEIGHT; }, [isMobile, insets.bottom]); - const appendUniquePosts = useCallback((prevPosts: Post[], incomingPosts: Post[]) => { - if (incomingPosts.length === 0) return prevPosts; - const seenIds = new Set(prevPosts.map(item => item.id)); - const dedupedIncoming = incomingPosts.filter(item => { - if (seenIds.has(item.id)) return false; - seenIds.add(item.id); - return true; - }); - return dedupedIncoming.length > 0 ? [...prevPosts, ...dedupedIncoming] : prevPosts; - }, []); - - const uniquePostsById = useCallback((items: Post[]) => { - if (items.length <= 1) return items; - const map = new Map(); - for (const item of items) { - map.set(item.id, item); - } - return Array.from(map.values()); - }, []); - - const getPostType = (): 'recommend' | 'follow' | 'hot' | 'latest' => { - switch (activeIndex) { - case 0: return 'recommend'; - case 1: return 'follow'; - case 2: return 'hot'; - case 3: return 'latest'; - default: return 'recommend'; - } - }; - - // 加载帖子列表 - const loadPosts = useCallback(async (pageNum: number = 1, isRefresh: boolean = false) => { - const postType = getPostType(); - const requestKey = `${postType}:${pageNum}`; - if (inFlightRequestKeysRef.current.has(requestKey)) { - return; - } - - try { - inFlightRequestKeysRef.current.add(requestKey); - if (isRefresh) { - setRefreshing(true); - } else if (pageNum === 1) { - setLoading(true); - } else { - setLoadingMore(true); - } - - const response = await fetchPosts(postType, pageNum); - const newPosts = response.list || []; - - if (isRefresh) { - setPosts(uniquePostsById(newPosts)); - setPage(1); - } else if (pageNum === 1) { - setPosts(uniquePostsById(newPosts)); - setPage(1); - } else { - setPosts(prev => appendUniquePosts(prev, newPosts)); - setPage(pageNum); - } - - const hasMoreByPage = response.total_pages > 0 ? response.page < response.total_pages : false; - const hasMoreBySize = newPosts.length >= (response.page_size || DEFAULT_PAGE_SIZE); - setHasMore(hasMoreByPage || hasMoreBySize); - } catch (error) { - console.error('Failed to load posts:', error); - } finally { - inFlightRequestKeysRef.current.delete(requestKey); - setLoading(false); - setRefreshing(false); - setLoadingMore(false); - } - }, [fetchPosts, activeIndex, appendUniquePosts, uniquePostsById]); - - // 切换Tab时重新加载 - useEffect(() => { - loadPosts(1, true); - }, [activeIndex]); - - // 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新) - useEffect(() => { - if (posts.length === 0) return; - - // 更新 postIdsRef - const currentPostIds = new Set(posts.map(p => p.id)); - postIdsRef.current = currentPostIds; - - // 从 store 中找到对应的帖子并同步状态 - let hasChanges = false; - const updatedPosts = posts.map(localPost => { - const storePost = storePosts.find(sp => sp.id === localPost.id); - if (storePost && ( - storePost.is_liked !== localPost.is_liked || - storePost.is_favorited !== localPost.is_favorited || - storePost.likes_count !== localPost.likes_count || - storePost.favorites_count !== localPost.favorites_count - )) { - hasChanges = true; - return { - ...localPost, - is_liked: storePost.is_liked, - is_favorited: storePost.is_favorited, - likes_count: storePost.likes_count, - favorites_count: storePost.favorites_count, - }; - } - return localPost; - }); - - if (hasChanges) { - setPosts(updatedPosts); - } - }, [storePosts]); - - // 下拉刷新 - const onRefresh = useCallback(() => { - loadPosts(1, true); - }, [loadPosts]); - - // 上拉加载更多 - const onEndReached = useCallback(() => { - if (!loadingMoreRef.current && hasMoreRef.current) { - loadPosts(pageRef.current + 1); - } - }, [loadPosts]); - - const onWaterfallScroll = useCallback((event: NativeSyntheticEvent) => { - if (loadingMoreRef.current || !hasMoreRef.current) return; - const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; - const distanceToBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height); - const now = Date.now(); - if (distanceToBottom <= SCROLL_BOTTOM_THRESHOLD) { - if (now - lastLoadMoreTriggerAtRef.current < LOAD_MORE_COOLDOWN_MS) { - return; - } - lastLoadMoreTriggerAtRef.current = now; - loadPosts(pageRef.current + 1); - } - }, [loadPosts]); - // 切换视图模式 const toggleViewMode = () => { setViewMode(prev => prev === 'list' ? 'grid' : 'list'); @@ -375,27 +263,27 @@ export const HomeScreen: React.FC = () => { if (!post?.id) return; try { await postService.sharePost(post.id); - } catch (error) { - console.error('上报分享次数失败:', error); + } catch (shareError) { + console.error('上报分享次数失败:', shareError); } const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`; Clipboard.setString(postUrl); Alert.alert('已复制', '帖子链接已复制到剪贴板'); }; - // 删除帖子 + // 删除帖子 - 由于 posts 来自 Hook,需要刷新列表 const handleDeletePost = async (postId: string) => { try { const success = await postService.deletePost(postId); if (success) { - // 从列表中移除已删除的帖子 - setPosts(prev => prev.filter(p => p.id !== postId)); + // 刷新列表以移除已删除的帖子 + refresh(); } else { console.error('删除帖子失败'); } - } catch (error) { - console.error('删除帖子失败:', error); - throw error; // 重新抛出错误,让 PostCard 处理错误提示 + } catch (deleteError) { + console.error('删除帖子失败:', deleteError); + throw deleteError; // 重新抛出错误,让 PostCard 处理错误提示 } }; @@ -545,12 +433,11 @@ export const HomeScreen: React.FC = () => { } ]} showsVerticalScrollIndicator={false} - onScroll={onWaterfallScroll} scrollEventThrottle={100} refreshControl={ @@ -594,7 +481,7 @@ export const HomeScreen: React.FC = () => { // 渲染空状态 const renderEmpty = () => { - if (loading) return null; + if (isLoading) return null; return ( { showsVerticalScrollIndicator={false} refreshControl={ } - onEndReached={onEndReached} + onEndReached={loadMore} onEndReachedThreshold={0.3} ListEmptyComponent={renderEmpty} - ListFooterComponent={loadingMore ? : null} + ListFooterComponent={isLoading ? : null} /> ); }; diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index af3d886..b04f000 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -32,6 +32,7 @@ import { Post, Comment, VoteResultDTO } from '../../types'; import { useUserStore } from '../../stores'; import { useCurrentUser } from '../../stores/authStore'; import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services'; +import { useCursorPagination } from '../../hooks/useCursorPagination'; import { CommentItem, VoteCard } from '../../components/business'; import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout } from '../../components/common'; import { RootStackParamList } from '../../navigation/types'; @@ -76,6 +77,30 @@ export const PostDetailScreen: React.FC = () => { const [comments, setComments] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); + + // 使用游标分页 Hook 管理评论列表 + const { + items: paginatedComments, + isLoading: isCommentsLoading, + isRefreshing: isCommentsRefreshing, + hasMore: hasMoreComments, + loadMore: loadMoreComments, + refresh: refreshComments, + error: commentsError, + } = useCursorPagination( + async ({ cursor, pageSize }) => { + return await commentService.getPostCommentsCursor(postId, { + cursor, + page_size: pageSize, + }); + }, + { pageSize: 20 } + ); + + // 同步分页评论到本地状态 + useEffect(() => { + setComments(paginatedComments); + }, [paginatedComments]); const [commentText, setCommentText] = useState(''); const [showImageModal, setShowImageModal] = useState(false); const [selectedImageIndex, setSelectedImageIndex] = useState(0); @@ -165,9 +190,8 @@ export const PostDetailScreen: React.FC = () => { } } - // 加载评论 - const commentsData = await commentService.getPostComments(postId); - setComments(commentsData.list); + // 加载评论(使用游标分页刷新) + await refreshComments(); } catch (error) { console.error('加载帖子详情失败:', error); } finally { @@ -261,12 +285,13 @@ export const PostDetailScreen: React.FC = () => { }; }, []); - // 下拉刷新 - const onRefresh = useCallback(() => { + // 下拉刷新 - 同时刷新帖子和评论 + const onRefresh = useCallback(async () => { setRefreshing(true); - loadPostDetail(); + await loadPostDetail(); + await refreshComments(); setRefreshing(false); - }, [loadPostDetail]); + }, [loadPostDetail, refreshComments]); const formatDateTime = (dateString?: string | null): string => { if (!dateString) return ''; @@ -1375,12 +1400,34 @@ export const PostDetailScreen: React.FC = () => { }} refreshControl={ } + onEndReached={loadMoreComments} + onEndReachedThreshold={0.3} + ListFooterComponent={ + isCommentsLoading ? ( + + + + ) : hasMoreComments ? ( + + + 加载更多评论 + + + ) : comments.length > 0 ? ( + + 没有更多评论了 + + ) : null + } /> ); @@ -1433,7 +1480,7 @@ export const PostDetailScreen: React.FC = () => { showsVerticalScrollIndicator={false} refreshControl={ { // 移动端单栏布局 return ( - { }} refreshControl={ } + onEndReached={loadMoreComments} + onEndReachedThreshold={0.3} + ListFooterComponent={ + isCommentsLoading ? ( + + + + ) : hasMoreComments ? ( + + + 加载更多评论 + + + ) : comments.length > 0 ? ( + + 没有更多评论了 + + ) : null + } /> {/* 评论输入框 - 跟随键盘 */} @@ -1905,4 +1974,22 @@ const styles = StyleSheet.create({ marginTop: spacing.md, textAlign: 'center', }, + // 评论加载更多样式 + commentsLoadingFooter: { + paddingVertical: spacing.md, + alignItems: 'center', + justifyContent: 'center', + }, + loadMoreButton: { + paddingVertical: spacing.md, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.background.paper, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.divider, + }, + noMoreComments: { + textAlign: 'center', + paddingVertical: spacing.md, + }, }); diff --git a/src/screens/home/SearchScreen.tsx b/src/screens/home/SearchScreen.tsx index ac4774a..0e728a4 100644 --- a/src/screens/home/SearchScreen.tsx +++ b/src/screens/home/SearchScreen.tsx @@ -4,13 +4,14 @@ * 支持响应式布局,宽屏下显示更大的搜索结果区域 */ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { View, FlatList, StyleSheet, TouchableOpacity, ScrollView, + RefreshControl, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; @@ -21,13 +22,15 @@ import { Post, User } from '../../types'; import { useUserStore } from '../../stores'; import { postService, authService } from '../../services'; import { PostCard, TabBar, SearchBar } from '../../components/business'; -import { Avatar, EmptyState, Text, ResponsiveGrid } from '../../components/common'; +import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common'; import { HomeStackParamList } from '../../navigation/types'; import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks/useResponsive'; +import { useCursorPagination } from '../../hooks/useCursorPagination'; type NavigationProp = NativeStackNavigationProp; const TABS = ['帖子', '用户']; +const DEFAULT_PAGE_SIZE = 20; type SearchType = 'posts' | 'users'; @@ -74,17 +77,47 @@ export const SearchScreen: React.FC = ({ onBack, navigation: const [searchText, setSearchText] = useState(''); const [activeIndex, setActiveIndex] = useState(0); - const [searchResults, setSearchResults] = useState<{ - posts: Post[]; - users: User[]; - }>({ - posts: [], - users: [], - }); const [hasSearched, setHasSearched] = useState(false); // 保存当前搜索关键词,用于Tab切换时重新搜索 const [currentKeyword, setCurrentKeyword] = useState(''); + // 使用游标分页进行帖子搜索 + const { + items: searchResults, + isLoading, + isRefreshing, + hasMore, + loadMore, + refresh, + reset, + } = useCursorPagination( + async ({ cursor, pageSize, extraParams }) => { + if (!extraParams?.query) { + return { items: [], next_cursor: null, prev_cursor: null, has_more: false }; + } + const response = await postService.searchPostsCursor(extraParams.query, { + cursor, + page_size: pageSize, + }); + return response; + }, + { pageSize: DEFAULT_PAGE_SIZE }, + { query: '' } + ); + + // 用户搜索结果(保持原有分页方式) + const [userResults, setUserResults] = useState([]); + const [userLoading, setUserLoading] = useState(false); + + // 当搜索词变化时重置 + useEffect(() => { + if (currentKeyword) { + refresh(); + } else { + reset(); + } + }, [currentKeyword]); + // 执行搜索 - 根据当前Tab执行对应类型的搜索 const performSearch = useCallback(async (keyword: string) => { if (!keyword.trim()) return; @@ -101,26 +134,20 @@ export const SearchScreen: React.FC = ({ onBack, navigation: const searchType = getSearchType(); if (searchType === 'posts') { - // 搜索帖子 - const postsResponse = await postService.searchPosts(trimmedKeyword, 1, 20); - setSearchResults(prev => ({ - ...prev, - posts: postsResponse.list - })); + // 帖子搜索由 useCursorPagination 处理,这里只需触发刷新 + refresh(); } else if (searchType === 'users') { - // 搜索用户 + // 用户搜索保持原有方式 + setUserLoading(true); const usersResponse = await authService.searchUsers(trimmedKeyword, 1, 20); - setSearchResults(prev => ({ - ...prev, - users: usersResponse.list - })); + setUserResults(usersResponse.list || []); } } catch (error) { console.error('搜索失败:', error); } setHasSearched(true); - }, [addSearchHistory, activeIndex]); + }, [addSearchHistory, activeIndex, refresh]); // 处理搜索提交 const handleSearch = () => { @@ -159,9 +186,9 @@ export const SearchScreen: React.FC = ({ onBack, navigation: // 渲染帖子搜索结果(使用响应式网格) const renderPostResults = () => { - const posts = searchResults.posts; + const posts = searchResults; - if (posts.length === 0) { + if (posts.length === 0 && !isLoading) { return ( = ({ onBack, navigation: + } > = ({ onBack, navigation: /> ))} + {isLoading && ( + + + + )} ); } @@ -220,15 +260,26 @@ export const SearchScreen: React.FC = ({ onBack, navigation: keyExtractor={item => item.id} contentContainerStyle={{ paddingBottom: responsivePadding }} showsVerticalScrollIndicator={false} + refreshControl={ + + } + onEndReached={loadMore} + onEndReachedThreshold={0.3} + ListFooterComponent={isLoading ? : null} /> ); }; // 渲染用户搜索结果 const renderUserResults = () => { - const users = searchResults.users; + const users = userResults; - if (users.length === 0) { + if (users.length === 0 && !userLoading) { return ( = ({ onBack, navigation: ))} + {userLoading && ( + + + + )} ); } @@ -329,6 +385,7 @@ export const SearchScreen: React.FC = ({ onBack, navigation: keyExtractor={item => item.id} contentContainerStyle={{ paddingVertical: responsiveGap }} showsVerticalScrollIndicator={false} + ListFooterComponent={userLoading ? : null} /> ); }; @@ -501,20 +558,20 @@ const styles = StyleSheet.create({ }, tabWrapper: { backgroundColor: colors.background.paper, - paddingTop: spacing.xs, - paddingBottom: spacing.xs, + borderBottomWidth: 1, + borderBottomColor: `${colors.divider}50`, }, suggestionsContainer: { flex: 1, }, section: { - marginTop: spacing.lg, + marginTop: spacing.md, }, sectionHeader: { flexDirection: 'row', - alignItems: 'center', justifyContent: 'space-between', - marginBottom: spacing.md, + alignItems: 'center', + marginBottom: spacing.sm, }, sectionTitle: { fontWeight: '600', @@ -528,17 +585,32 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', backgroundColor: colors.background.paper, - borderRadius: borderRadius.md, + borderRadius: borderRadius.lg, + borderWidth: 1, + borderColor: colors.divider, }, tagText: { marginLeft: spacing.xs, }, - // 移动端用户列表样式 + userCard: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + flexDirection: 'row', + alignItems: 'center', + }, + userCardInfo: { + flex: 1, + marginLeft: spacing.md, + }, + userCardName: { + fontWeight: '600', + color: colors.text.primary, + }, userItem: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.background.paper, - borderRadius: borderRadius.md, + borderRadius: borderRadius.lg, }, userInfo: { flex: 1, @@ -549,28 +621,15 @@ const styles = StyleSheet.create({ color: colors.text.primary, }, followingBadge: { - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: colors.primary.light + '30', + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: `${colors.primary.main}14`, alignItems: 'center', justifyContent: 'center', }, - // 桌面端用户卡片样式 - userCard: { - backgroundColor: colors.background.paper, - borderRadius: borderRadius.lg, + loadingMore: { + paddingVertical: spacing.md, alignItems: 'center', - justifyContent: 'center', - minHeight: 180, - }, - userCardInfo: { - alignItems: 'center', - marginTop: spacing.md, - }, - userCardName: { - fontWeight: '600', - color: colors.text.primary, - marginBottom: spacing.xs, }, }); diff --git a/src/screens/message/GroupMembersScreen.tsx b/src/screens/message/GroupMembersScreen.tsx index 3612f99..ffb281b 100644 --- a/src/screens/message/GroupMembersScreen.tsx +++ b/src/screens/message/GroupMembersScreen.tsx @@ -2,6 +2,7 @@ * GroupMembersScreen 群成员管理界面 * 显示群成员列表,支持管理员管理成员 * 支持响应式网格布局 + * 使用游标分页 */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; @@ -27,6 +28,7 @@ import { groupService } from '../../services/groupService'; import { groupManager } from '../../stores/groupManager'; import { Avatar, Text, Button, Loading, EmptyState, Divider, ResponsiveContainer } from '../../components/common'; import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive'; +import { useCursorPagination } from '../../hooks/useCursorPagination'; import { GroupMemberResponse, GroupRole, @@ -68,12 +70,34 @@ const GroupMembersScreen: React.FC = () => { return GRID_CONFIG.mobile; }, [width]); - // 成员列表状态 - const [members, setMembers] = useState([]); + // 使用游标分页 Hook 管理成员列表 + const { + items: members, + isLoading, + isRefreshing, + hasMore, + loadMore, + refresh, + error, + } = useCursorPagination( + async ({ cursor, pageSize }) => { + return await groupService.getGroupMembersCursor(groupId, { + cursor, + page_size: pageSize, + }); + }, + { pageSize: 50 } + ); + + // 本地成员状态(用于乐观更新) + const [localMembers, setLocalMembers] = useState([]); const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [page, setPage] = useState(1); - const [hasMore, setHasMore] = useState(true); + + // 同步分页数据到本地状态 + useEffect(() => { + setLocalMembers(members); + setLoading(false); + }, [members]); // 当前用户的成员信息 const [currentMember, setCurrentMember] = useState(null); @@ -91,65 +115,31 @@ const GroupMembersScreen: React.FC = () => { const isOwner = currentMember?.role === 'owner'; const isAdmin = currentMember?.role === 'admin' || isOwner; - // 加载成员列表 - const loadMembers = useCallback( - async ( - pageNum: number = 1, - refresh: boolean = false, - forceRefresh: boolean = false - ) => { - if (!hasMore && !refresh) return; - - try { - const response = await groupManager.getMembers(groupId, pageNum, 50, forceRefresh); - - if (refresh) { - setMembers(response.list); - setPage(1); - } else { - setMembers(prev => [...prev, ...response.list]); - } - - setHasMore(response.list.length === 50); - - // 查找当前用户的成员信息 - const myMember = response.list.find(m => m.user_id === currentUser?.id); - if (myMember) { - setCurrentMember(myMember); - } - } catch (error) { - console.error('加载成员列表失败:', error); + // 查找当前用户的成员信息 + useEffect(() => { + const myMember = localMembers.find(m => m.user_id === currentUser?.id); + if (myMember) { + setCurrentMember(myMember); } + }, [localMembers, currentUser]); + + // 下拉刷新 + const onRefresh = useCallback(async () => { + setLoading(true); + await refresh(); setLoading(false); - setRefreshing(false); - }, [groupId, currentUser, hasMore]); + }, [refresh]); // 初始加载 useEffect(() => { - loadMembers(1, true, true); + refresh(); }, [groupId]); - // 下拉刷新 - const onRefresh = useCallback(() => { - setRefreshing(true); - setHasMore(true); - loadMembers(1, true, true); - }, [loadMembers]); - - // 加载更多 - const loadMore = useCallback(() => { - if (!loading && hasMore) { - const nextPage = page + 1; - setPage(nextPage); - loadMembers(nextPage); - } - }, [loading, hasMore, page, loadMembers]); - // 按角色分组 const groupMembers = useCallback((): MemberGroup[] => { - const owners = members.filter(m => m.role === 'owner'); - const admins = members.filter(m => m.role === 'admin'); - const normalMembers = members.filter(m => m.role === 'member'); + const owners = localMembers.filter(m => m.role === 'owner'); + const admins = localMembers.filter(m => m.role === 'admin'); + const normalMembers = localMembers.filter(m => m.role === 'member'); const groups: MemberGroup[] = []; @@ -164,7 +154,7 @@ const GroupMembersScreen: React.FC = () => { } return groups; - }, [members]); + }, [localMembers]); // 打开操作菜单 const openActionModal = (member: GroupMemberResponse) => { @@ -203,7 +193,7 @@ const GroupMembersScreen: React.FC = () => { }); // 更新本地数据 - setMembers(prev => prev.map(m => { + setLocalMembers(prev => prev.map(m => { if (m.user_id === selectedMember.user_id) { return { ...m, role: newRole }; } @@ -245,14 +235,14 @@ const GroupMembersScreen: React.FC = () => { await groupService.muteMember(groupId, selectedMember.user_id, newMuted ? -1 : 0); // 更新本地数据 - setMembers(prev => prev.map(m => { + setLocalMembers(prev => prev.map(m => { if (m.user_id === selectedMember.user_id) { return { ...m, muted: newMuted }; } return m; })); // 强制刷新远端状态,避免命中旧缓存导致解禁后仍显示禁言 - await loadMembers(1, true, true); + await refresh(); setActionModalVisible(false); Alert.alert('成功', `已${actionText}`); @@ -286,7 +276,7 @@ const GroupMembersScreen: React.FC = () => { await groupService.removeMember(groupId, selectedMember.user_id); // 更新本地数据 - setMembers(prev => prev.filter(m => m.user_id !== selectedMember.user_id)); + setLocalMembers(prev => prev.filter(m => m.user_id !== selectedMember.user_id)); setActionModalVisible(false); Alert.alert('成功', '已移除成员'); @@ -320,7 +310,7 @@ const GroupMembersScreen: React.FC = () => { }); // 更新本地数据 - setMembers(prev => prev.map(m => { + setLocalMembers(prev => prev.map(m => { if (m.user_id === selectedMember.user_id) { return { ...m, nickname: newNickname.trim() }; } @@ -422,7 +412,7 @@ const GroupMembersScreen: React.FC = () => { // 渲染空状态 const renderEmpty = () => { - if (loading) return ; + if (loading || isLoading) return ; return ( { keyExtractor={(item) => item.title} refreshControl={ { onEndReached={loadMore} onEndReachedThreshold={0.3} showsVerticalScrollIndicator={false} + ListFooterComponent={ + isLoading ? ( + + + + ) : hasMore ? ( + + + 加载更多成员 + + + ) : localMembers.length > 0 ? ( + + 没有更多成员了 + + ) : null + } renderItem={({ item: group }) => ( {renderSectionHeader(group.title, group.data.length)} @@ -724,6 +731,19 @@ const styles = StyleSheet.create({ flex: 1, marginHorizontal: spacing.xs, }, + // 分页加载样式 + loadingFooter: { + paddingVertical: spacing.md, + alignItems: 'center', + }, + loadMoreBtn: { + paddingVertical: spacing.md, + alignItems: 'center', + }, + noMoreText: { + textAlign: 'center', + paddingVertical: spacing.md, + }, }); export default GroupMembersScreen; diff --git a/src/screens/message/JoinGroupScreen.tsx b/src/screens/message/JoinGroupScreen.tsx index 378b1f1..e6df582 100644 --- a/src/screens/message/JoinGroupScreen.tsx +++ b/src/screens/message/JoinGroupScreen.tsx @@ -1,5 +1,15 @@ -import React, { useState } from 'react'; -import { View, StyleSheet, TextInput, TouchableOpacity, Alert, ActivityIndicator, Clipboard } from 'react-native'; +import React, { useState, useCallback } from 'react'; +import { + View, + StyleSheet, + TextInput, + TouchableOpacity, + Alert, + ActivityIndicator, + Clipboard, + FlatList, + RefreshControl, +} from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { MaterialCommunityIcons } from '@expo/vector-icons'; @@ -11,6 +21,8 @@ import { groupService } from '../../services/groupService'; import { groupManager } from '../../stores/groupManager'; import { RootStackParamList } from '../../navigation/types'; import { GroupResponse, JoinType } from '../../types/dto'; +import { useCursorPagination } from '../../hooks/useCursorPagination'; +import { EmptyState } from '../../components/common'; type NavigationProp = NativeStackNavigationProp; @@ -18,10 +30,29 @@ const JoinGroupScreen: React.FC = () => { const navigation = useNavigation(); const [keyword, setKeyword] = useState(''); const [searching, setSearching] = useState(false); - const [joining, setJoining] = useState(false); - const [group, setGroup] = useState(null); + const [joiningGroupId, setJoiningGroupId] = useState(null); + const [searchedGroup, setSearchedGroup] = useState(null); const [searched, setSearched] = useState(false); + // 使用游标分页 Hook 管理群组列表 + const { + items: groups, + isLoading, + isRefreshing, + hasMore, + loadMore, + refresh, + error, + } = useCursorPagination( + async ({ cursor, pageSize }) => { + return await groupService.getGroupsCursor({ + cursor, + page_size: pageSize, + }); + }, + { pageSize: 20 } + ); + const getJoinTypeText = (joinType: JoinType) => { if (joinType === 0) return '允许加入'; if (joinType === 1) return '需要审批'; @@ -39,9 +70,9 @@ const JoinGroupScreen: React.FC = () => { setSearched(true); try { const result = await groupManager.getGroup(trimmed, true); - setGroup(result); + setSearchedGroup(result); } catch (error: any) { - setGroup(null); + setSearchedGroup(null); const message = error?.response?.data?.message || error?.message || ''; if (String(message).includes('不存在') || error?.response?.status === 404) { Alert.alert('未找到', '未搜索到该群聊,请确认群ID是否正确'); @@ -53,9 +84,9 @@ const JoinGroupScreen: React.FC = () => { } }; - const handleJoin = async () => { + const handleJoin = async (group: GroupResponse) => { if (!group?.id) return; - setJoining(true); + setJoiningGroupId(String(group.id)); try { await groupService.joinGroup(group.id); Alert.alert('成功', '操作已提交', [ @@ -71,13 +102,12 @@ const JoinGroupScreen: React.FC = () => { '操作失败,请稍后重试'; Alert.alert('操作失败', String(message)); } finally { - setJoining(false); + setJoiningGroupId(null); } }; - const handleCopyGroupId = () => { - if (!group?.id) return; - Clipboard.setString(String(group.id)); + const handleCopyGroupId = (groupId: string | number) => { + Clipboard.setString(String(groupId)); Alert.alert('已复制', '群号已复制到剪贴板'); }; @@ -87,6 +117,99 @@ const JoinGroupScreen: React.FC = () => { return `${raw.slice(0, 6)}...${raw.slice(-4)}`; }; + const renderGroupItem = ({ item: group }: { item: GroupResponse }) => { + const isJoining = joiningGroupId === String(group.id); + + return ( + + + + + + {group.name} + + + + {!!group.description && ( + + {group.description} + + )} + + + 成员 {group.member_count}/{group.max_members} + + + {getJoinTypeText(group.join_type)} + + + + + 群号:{formatGroupNo(group.id)} + + handleCopyGroupId(group.id)}> + + + 复制 + + + + handleJoin(group)} + disabled={isJoining} + > + {isJoining ? ( + + ) : ( + <> + + + 申请入群 + + + )} + + + ); + }; + + const renderEmptyList = () => { + if (isLoading) return null; + return ( + + ); + }; + + const renderSearchResult = () => { + if (!searched) return null; + + if (searchedGroup) { + return ( + + + 搜索结果 + + {renderGroupItem({ item: searchedGroup })} + + ); + } + + if (!searching) { + return ( + + 暂无搜索结果,请检查群ID后重试 + + ); + } + + return null; + }; + return ( @@ -100,23 +223,25 @@ const JoinGroupScreen: React.FC = () => { - 搜索群聊(群ID) + + 搜索群聊(群ID) + {searching ? ( @@ -126,58 +251,49 @@ const JoinGroupScreen: React.FC = () => { - {group && ( - - - - - {group.name} - - - {!!group.description && ( - - {group.description} - - )} - - - 成员 {group.member_count}/{group.max_members} - - - {getJoinTypeText(group.join_type)} - - - - - 群号:{formatGroupNo(group.id)} - - - - 复制 - - - - {joining ? ( - - ) : ( - <> - - 申请入群 - - )} - - - )} + {/* 搜索结果 */} + {renderSearchResult()} - {searched && !group && !searching && ( - - 暂无搜索结果,请检查群ID后重试 + {/* 群组列表 */} + + + 推荐群组 - )} + String(item.id)} + refreshControl={ + + } + onEndReached={loadMore} + onEndReachedThreshold={0.3} + ListEmptyComponent={renderEmptyList} + ListFooterComponent={ + isLoading ? ( + + + + ) : hasMore ? ( + + + 加载更多 + + + ) : groups.length > 0 ? ( + + 没有更多群组了 + + ) : null + } + showsVerticalScrollIndicator={false} + /> + ); @@ -214,6 +330,7 @@ const styles = StyleSheet.create({ backgroundColor: colors.background.paper, borderRadius: borderRadius.lg, padding: spacing.lg, + flex: 1, }, label: { marginBottom: spacing.xs, @@ -242,12 +359,23 @@ const styles = StyleSheet.create({ justifyContent: 'center', marginLeft: spacing.sm, }, + searchResultSection: { + marginBottom: spacing.lg, + }, + sectionTitle: { + marginBottom: spacing.sm, + fontWeight: '600', + }, + listSection: { + flex: 1, + }, groupCard: { borderWidth: 1, borderColor: colors.divider, borderRadius: borderRadius.md, padding: spacing.md, backgroundColor: colors.background.default, + marginBottom: spacing.md, }, groupHeader: { flexDirection: 'row', @@ -259,6 +387,7 @@ const styles = StyleSheet.create({ }, groupName: { marginBottom: spacing.xs, + fontWeight: '600', }, groupDesc: { marginTop: spacing.sm, @@ -303,6 +432,19 @@ const styles = StyleSheet.create({ }, emptyText: { marginTop: spacing.sm, + textAlign: 'center', + }, + loadingFooter: { + paddingVertical: spacing.md, + alignItems: 'center', + }, + loadMoreBtn: { + paddingVertical: spacing.md, + alignItems: 'center', + }, + noMoreText: { + textAlign: 'center', + paddingVertical: spacing.md, }, }); diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index 943538f..918e8f7 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -30,12 +30,13 @@ import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, fontSizes, shadows, borderRadius } from '../../theme'; import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments, extractTextFromSegmentsAsync, MessageSegment } from '../../types/dto'; -import { authService } from '../../services'; +import { authService, messageService } from '../../services'; import { useUserStore, useAuthStore } from '../../stores'; // 【新架构】使用MessageManager hooks -import { useMessageList, messageManager, useMessageListRefresh, useCreateConversation } from '../../stores'; +import { messageManager, useMessageListRefresh, useCreateConversation, useUnreadCount, useMarkAsRead } from '../../stores'; import { Avatar, Text, EmptyState, ResponsiveContainer } from '../../components/common'; import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive'; +import { useCursorPagination } from '../../hooks/useCursorPagination'; import { RootStackParamList, MessageStackParamList } from '../../navigation/types'; import { getUserCache } from '../../services/database'; // 导入 EmbeddedChat 组件用于桌面端双栏布局 @@ -160,22 +161,43 @@ export const MessageListScreen: React.FC = () => { const { isDesktop, isTablet, width } = useResponsive(); const isWideScreen = useBreakpointGTE('lg'); - // 【新架构】使用MessageManager的hook获取数据 + // 【游标分页】使用 useCursorPagination hook 获取会话列表 const { - conversations, + items: conversations, isLoading, + isRefreshing, + hasMore, + loadMore, refresh, - totalUnreadCount, - systemUnreadCount, - markAllAsRead, - isMarking, - } = useMessageList(); + error: paginationError, + } = useCursorPagination( + async ({ cursor, pageSize }) => { + return await messageService.getConversationsCursor({ + cursor, + page_size: pageSize, + }); + }, + { pageSize: 20 } + ); + + // 使用 MessageManager 获取未读数和系统通知数 + const { totalUnreadCount, systemUnreadCount } = useUnreadCount(); + const { markAllAsRead, isMarking } = useMarkAsRead(null); // 【新架构】使用MessageManager的hook创建会话 const { createConversation } = useCreateConversation(); // 本地刷新状态(仅用于下拉刷新的UI显示) const [refreshing, setRefreshing] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + + // 上拉加载更多 + const onEndReached = useCallback(async () => { + if (isLoading || loadingMore || !hasMore) return; + setLoadingMore(true); + await loadMore(); + setLoadingMore(false); + }, [isLoading, loadingMore, hasMore, loadMore]); // 搜索相关状态 const [isSearchMode, setIsSearchMode] = useState(false); @@ -885,7 +907,7 @@ export const MessageListScreen: React.FC = () => { - {isLoading ? ( + {isLoading && conversations.length === 0 ? ( @@ -901,6 +923,8 @@ export const MessageListScreen: React.FC = () => { ]} showsVerticalScrollIndicator={false} ListEmptyComponent={renderEmpty} + onEndReached={onEndReached} + onEndReachedThreshold={0.5} refreshControl={ { tintColor={colors.primary.main} /> } + ListFooterComponent={ + loadingMore ? ( + + + 加载中... + + ) : null + } /> )} @@ -1261,6 +1293,17 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingVertical: spacing.xl * 2, }, + loadingMoreContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.md, + }, + loadingMoreText: { + marginLeft: spacing.sm, + fontSize: 14, + color: '#999', + }, searchModeContainer: { flex: 1, backgroundColor: '#FAFAFA', diff --git a/src/screens/message/NotificationsScreen.tsx b/src/screens/message/NotificationsScreen.tsx index f7ddef1..bef0104 100644 --- a/src/screens/message/NotificationsScreen.tsx +++ b/src/screens/message/NotificationsScreen.tsx @@ -1,7 +1,7 @@ /** * 通知页 NotificationsScreen * 胡萝卜BBS - 系统消息列表 - * 使用新的系统消息API + * 【游标分页】使用 messageService.getSystemMessagesCursor * 支持响应式布局 */ @@ -26,6 +26,7 @@ import { messageService } from '../../services/messageService'; import { commentService } from '../../services/commentService'; import { SystemMessageItem } from '../../components/business'; import { Text, EmptyState, ResponsiveContainer } from '../../components/common'; +import { useCursorPagination } from '../../hooks/useCursorPagination'; import { RootStackParamList } from '../../navigation/types'; import { useMessageManagerSystemUnreadCount, useUserStore } from '../../stores'; @@ -70,14 +71,32 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack // Web端使用更大的容器宽度 const containerMaxWidth = isDesktop ? 1200 : isTablet ? 1000 : 900; - const [messages, setMessages] = useState([]); const [activeType, setActiveType] = useState('all'); - const [refreshing, setRefreshing] = useState(false); - const [loading, setLoading] = useState(true); - const [hasMore, setHasMore] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); const [unreadCount, setUnreadCount] = useState(0); + // 【游标分页】使用 useCursorPagination hook 获取系统消息列表 + const { + items: messages, + isLoading, + isRefreshing, + hasMore, + loadMore, + refresh, + error: paginationError, + } = useCursorPagination( + async ({ cursor, pageSize }) => { + return await messageService.getSystemMessagesCursor({ + cursor, + page_size: pageSize, + }); + }, + { pageSize: 20 } + ); + + // 本地刷新状态(仅用于下拉刷新的UI显示) + const [refreshing, setRefreshing] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + // 同一 flag 只要有人审批过,就将待处理消息同步展示为已处理状态 const displayMessages = useMemo(() => { const reviewedByFlag = new Map(); @@ -116,21 +135,6 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack }); }, [messages]); - // 获取系统消息数据 - const fetchMessages = useCallback(async () => { - try { - setLoading(true); - const response = await messageService.getSystemMessages(50, 1); - // 添加防御性检查,确保 messages 数组存在 - setMessages(response.messages || []); - setHasMore(response.has_more ?? false); - } catch (error) { - console.error('获取系统消息失败:', error); - } finally { - setLoading(false); - } - }, []); - // 获取未读数 const fetchUnreadCount = useCallback(async () => { try { @@ -145,27 +149,25 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack const handleMarkAllRead = useCallback(async () => { try { await messageService.markAllSystemMessagesRead(); - setMessages(prev => prev.map(m => ({ ...m, is_read: true }))); + // 刷新消息列表 + await refresh(); setUnreadCount(0); setSystemUnreadCount(0); // 同步更新全局 TabBar 红点 fetchMessageUnreadCount(); - // 刷新消息列表 - fetchMessages(); } catch (error) { console.error('一键已读失败:', error); } - }, [fetchMessages, fetchMessageUnreadCount, setSystemUnreadCount]); + }, [refresh, fetchMessageUnreadCount, setSystemUnreadCount]); // 页面加载和获得焦点时刷新,并自动标记所有消息为已读 useEffect(() => { if (isFocused) { - fetchMessages(); fetchUnreadCount(); // 进入界面自动标记所有消息为已读 handleMarkAllRead(); } - }, [isFocused, fetchMessages, fetchUnreadCount, handleMarkAllRead]); + }, [isFocused, fetchUnreadCount, handleMarkAllRead]); // 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式) useEffect(() => { @@ -190,29 +192,17 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack // 下拉刷新 const onRefresh = useCallback(async () => { setRefreshing(true); - await Promise.all([fetchMessages(), fetchUnreadCount()]); + await Promise.all([refresh(), fetchUnreadCount()]); setRefreshing(false); - }, [fetchMessages, fetchUnreadCount]); + }, [refresh, fetchUnreadCount]); // 加载更多 - const loadMore = useCallback(async () => { - if (loadingMore || !hasMore || messages.length === 0) return; - - try { - setLoadingMore(true); - // 使用时间戳或seq作为游标分页(后端使用page分页) - const nextPage = Math.floor((messages.length / 20)) + 1; - const response = await messageService.getSystemMessages(20, nextPage); - // 添加防御性检查 - const newMessages = response.messages || []; - setMessages(prev => [...prev, ...newMessages]); - setHasMore(response.has_more ?? false); - } catch (error) { - console.error('加载更多失败:', error); - } finally { - setLoadingMore(false); - } - }, [loadingMore, hasMore, messages]); + const onEndReached = useCallback(async () => { + if (isLoading || loadingMore || !hasMore) return; + setLoadingMore(true); + await loadMore(); + setLoadingMore(false); + }, [isLoading, loadingMore, hasMore, loadMore]); // 标记单条消息已读并处理导航 const extractPostIdFromActionUrl = (actionUrl?: string): string | null => { @@ -262,9 +252,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack const messageId = String(message.id); const wasUnread = message.is_read !== true; await messageService.markSystemMessageRead(messageId); - setMessages(prev => - prev.map(m => (String(m.id) === messageId ? { ...m, is_read: true } : m)) - ); + // 【游标分页】不再直接修改 messages 状态,而是通过刷新获取最新数据 if (wasUnread) { setUnreadCount(prev => Math.max(0, prev - 1)); decrementSystemUnreadCount(1); @@ -431,7 +419,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack {/* 消息列表 */} - {loading ? ( + {isLoading && messages.length === 0 ? ( @@ -444,7 +432,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack showsVerticalScrollIndicator={false} ListEmptyComponent={renderEmpty} ListFooterComponent={renderFooter} - onEndReached={loadMore} + onEndReached={onEndReached} onEndReachedThreshold={0.3} refreshControl={ void }> = ({ onBack {/* 消息列表 */} - {loading ? ( + {isLoading && messages.length === 0 ? ( @@ -518,7 +506,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack showsVerticalScrollIndicator={false} ListEmptyComponent={renderEmpty} ListFooterComponent={renderFooter} - onEndReached={loadMore} + onEndReached={onEndReached} onEndReachedThreshold={0.3} refreshControl={ > { + try { + const response = await api.get>( + `/comments/post/${postId}/cursor`, + { params } + ); + return response.data; + } catch (error) { + console.error('获取帖子评论列表失败:', error); + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; + } + } + + /** + * 获取评论回复列表(游标分页) + * GET /api/v1/comments/:id/replies/cursor + * @param commentId 评论ID + * @param params 游标分页请求参数 + */ + async getCommentRepliesCursor( + commentId: string, + params: CursorPaginationRequest = {} + ): Promise> { + try { + const response = await api.get>( + `/comments/${commentId}/replies/cursor`, + { params } + ); + return response.data; + } catch (error) { + console.error('获取评论回复列表失败:', error); + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; + } + } } // 导出评论服务实例 diff --git a/src/services/groupService.ts b/src/services/groupService.ts index 147aab8..330193d 100644 --- a/src/services/groupService.ts +++ b/src/services/groupService.ts @@ -23,6 +23,8 @@ import { MyMemberInfoResponse, SetGroupAvatarRequest, HandleGroupRequestAction, + CursorPaginationRequest, + CursorPaginationResponse, } from '../types/dto'; // 群组服务类(纯 API 层) @@ -321,6 +323,86 @@ class GroupService { ); } + // ==================== 游标分页方法 ==================== + + /** + * 获取群组列表(游标分页) + * GET /api/v1/groups/cursor + * @param params 游标分页请求参数 + */ + async getGroupsCursor( + params: CursorPaginationRequest = {} + ): Promise> { + try { + const response = await api.get>('/groups/cursor', { + params, + }); + return response.data; + } catch (error) { + console.error('获取群组列表失败:', error); + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; + } + } + + /** + * 获取群组成员列表(游标分页) + * GET /api/v1/groups/:id/members/cursor + * @param groupId 群组ID + * @param params 游标分页请求参数 + */ + async getGroupMembersCursor( + groupId: number | string, + params: CursorPaginationRequest = {} + ): Promise> { + try { + const response = await api.get>( + `/groups/${encodeURIComponent(String(groupId))}/members/cursor`, + { params } + ); + return response.data; + } catch (error) { + console.error('获取群组成员列表失败:', error); + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; + } + } + + /** + * 获取群公告列表(游标分页) + * GET /api/v1/groups/:id/announcements/cursor + * @param groupId 群组ID + * @param params 游标分页请求参数 + */ + async getGroupAnnouncementsCursor( + groupId: number | string, + params: CursorPaginationRequest = {} + ): Promise> { + try { + const response = await api.get>( + `/groups/${encodeURIComponent(String(groupId))}/announcements/cursor`, + { params } + ); + return response.data; + } catch (error) { + console.error('获取群公告列表失败:', error); + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; + } + } + // ==================== 兼容旧API的方法(将逐步废弃) ==================== /** diff --git a/src/services/messageService.ts b/src/services/messageService.ts index 15f57ae..27a8722 100644 --- a/src/services/messageService.ts +++ b/src/services/messageService.ts @@ -15,8 +15,11 @@ import { UnreadCountResponse, ConversationUnreadCountResponse, SystemMessageListResponse, + SystemMessageResponse, SystemUnreadCountResponse, MessageSegment, + CursorPaginationRequest, + CursorPaginationResponse, } from '../types/dto'; import { getConversationCache, @@ -555,6 +558,85 @@ class MessageService { await api.put('/messages/system/read-all'); } + // ==================== 游标分页方法 ==================== + + /** + * 获取会话列表(游标分页) + * GET /api/v1/conversations/cursor + * @param params 游标分页请求参数 + */ + async getConversationsCursor( + params: CursorPaginationRequest = {} + ): Promise> { + try { + const response = await api.get>( + '/conversations/cursor', + { params } + ); + return response.data; + } catch (error) { + console.error('获取会话列表失败:', error); + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; + } + } + + /** + * 获取消息列表(游标分页) + * GET /api/v1/conversations/:id/messages/cursor + * @param conversationId 会话ID + * @param params 游标分页请求参数 + */ + async getMessagesCursor( + conversationId: string, + params: CursorPaginationRequest = {} + ): Promise> { + try { + const response = await api.get>( + `/conversations/${encodeURIComponent(conversationId)}/messages/cursor`, + { params } + ); + return response.data; + } catch (error) { + console.error('获取消息列表失败:', error); + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; + } + } + + /** + * 获取系统消息列表(游标分页) + * GET /api/v1/messages/system/cursor + * @param params 游标分页请求参数 + */ + async getSystemMessagesCursor( + params: CursorPaginationRequest = {} + ): Promise> { + try { + const response = await api.get>( + '/messages/system/cursor', + { params } + ); + return response.data; + } catch (error) { + console.error('获取系统消息列表失败:', error); + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; + } + } + // ==================== 兼容旧API的方法(将逐步废弃) ==================== /** diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 2062e2e..f808b70 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -5,6 +5,7 @@ import { api, PaginatedData } from './api'; import { Notification, NotificationBadge, NotificationType } from '../types'; +import { CursorPaginationRequest, CursorPaginationResponse } from '../types/dto'; // 通知列表响应 interface NotificationListResponse { @@ -151,6 +152,33 @@ class NotificationService { async getMentionNotifications(page = 1, pageSize = 20): Promise> { return this.getNotifications(page, pageSize, 'mention'); } + + // ==================== 游标分页方法 ==================== + + /** + * 获取通知列表(游标分页) + * GET /api/v1/notifications/cursor + * @param params 游标分页请求参数 + */ + async getNotificationsCursor( + params: CursorPaginationRequest = {} + ): Promise> { + try { + const response = await api.get>( + '/notifications/cursor', + { params } + ); + return response.data; + } catch (error) { + console.error('获取通知列表失败:', error); + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; + } + } } // 导出通知服务实例 diff --git a/src/services/postService.ts b/src/services/postService.ts index 32e1ecb..f4ece35 100644 --- a/src/services/postService.ts +++ b/src/services/postService.ts @@ -5,6 +5,7 @@ import { api, PaginatedData } from './api'; import { Post, CreatePostInput } from '../types'; +import { CursorPaginationRequest, CursorPaginationResponse } from '../types/dto'; // 帖子列表响应 interface PostListResponse { @@ -281,6 +282,92 @@ class PostService { return false; } } + + // ==================== 游标分页方法 ==================== + + /** + * 获取帖子列表(游标分页) + * GET /api/v1/posts/cursor + * @param params 游标分页请求参数(包含 post_type 可选:recommend, follow, hot, latest) + */ + async getPostsCursor( + params: CursorPaginationRequest = {} + ): Promise> { + try { + const response = await api.get>('/posts/cursor', { + params: { + cursor: params.cursor, + page_size: params.page_size, + post_type: params.post_type, + }, + }); + return response.data; + } catch (error) { + console.error('获取帖子列表失败:', error); + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; + } + } + + /** + * 搜索帖子(游标分页) + * GET /api/v1/posts/search/cursor + * @param query 搜索关键词 + * @param params 游标分页请求参数 + */ + async searchPostsCursor( + query: string, + params: CursorPaginationRequest = {} + ): Promise> { + try { + const response = await api.get>('/posts/search/cursor', { + params: { + ...params, + keyword: query, + }, + }); + return response.data; + } catch (error) { + console.error('搜索帖子失败:', error); + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; + } + } + + /** + * 获取用户帖子列表(游标分页) + * GET /api/v1/users/:id/posts/cursor + * @param userId 用户ID + * @param params 游标分页请求参数 + */ + async getUserPostsCursor( + userId: string, + params: CursorPaginationRequest = {} + ): Promise> { + try { + const response = await api.get>( + `/users/${userId}/posts/cursor`, + { params } + ); + return response.data; + } catch (error) { + console.error('获取用户帖子列表失败:', error); + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; + } + } } // 导出帖子服务实例 diff --git a/src/types/dto.ts b/src/types/dto.ts index bf6efb3..ab43a3c 100644 --- a/src/types/dto.ts +++ b/src/types/dto.ts @@ -499,6 +499,36 @@ export interface SystemUnreadCountResponse { // 设备类型 export type DeviceType = 'ios' | 'android' | 'web'; +// ==================== 游标分页相关 DTO ==================== + +/** + * 游标分页请求参数 + */ +export interface CursorPaginationRequest { + /** 游标字符串(可选,首次请求不传) */ + cursor?: string; + /** 分页方向:forward 或 backward(默认 forward) */ + direction?: 'forward' | 'backward'; + /** 每页数量(默认 20,最大 100) */ + page_size?: number; + /** 帖子类型筛选(可选):recommend, follow, hot, latest */ + post_type?: 'recommend' | 'follow' | 'hot' | 'latest'; +} + +/** + * 游标分页响应 + */ +export interface CursorPaginationResponse { + /** 数据项列表 */ + items: T[]; + /** 下一页游标 */ + next_cursor: string | null; + /** 上一页游标 */ + prev_cursor: string | null; + /** 是否有更多数据 */ + has_more: boolean; +} + // 设备Token响应 export interface DeviceTokenResponse { id: number; From 97c7762f2b7e34c1a34f29850caf7f090c1f5c61 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Sat, 21 Mar 2026 01:36:40 +0800 Subject: [PATCH 06/36] feat(navigation): add profile stack navigator and fix API endpoints - Add ProfileStackNavigatorComponent with full navigation stack (Profile, Settings, EditProfile, AccountSecurity, MyPosts, Bookmarks, NotificationSettings, BlockedUsers) - Add null checks for response data in authService login, register, and refreshToken methods - Fix postService API routes to use correct endpoints (/posts instead of /posts/cursor) - Remove completed cursor pagination design document --- .dockerignore | 2 +- plans/frontend_cursor_pagination_design.md | 1131 ------------------- src/navigation/SimpleMobileTabNavigator.tsx | 93 +- src/services/authService.ts | 9 + src/services/postService.ts | 36 +- 5 files changed, 118 insertions(+), 1153 deletions(-) delete mode 100644 plans/frontend_cursor_pagination_design.md diff --git a/.dockerignore b/.dockerignore index 7ffff64..eb1e6ac 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,6 @@ dist dist-web dist-android dist-android-update.zip -android +android screenshots *.log diff --git a/plans/frontend_cursor_pagination_design.md b/plans/frontend_cursor_pagination_design.md deleted file mode 100644 index 00268ed..0000000 --- a/plans/frontend_cursor_pagination_design.md +++ /dev/null @@ -1,1131 +0,0 @@ -# 前端游标分页适配方案设计文档 - -## 一、背景说明 - -后端已完成游标分页功能实现,共改造了 11 个接口。前端需要对接这些新的游标分页接口,替换现有的基于页码的分页方式。 - -### 后端游标分页接口列表 - -| 模块 | 接口路径 | 排序方式 | -|------|----------|----------| -| 帖子 | GET /posts/cursor | created_at DESC | -| 帖子搜索 | GET /posts/search/cursor | created_at DESC | -| 用户帖子 | GET /users/:id/posts/cursor | created_at DESC | -| 会话列表 | GET /conversations/cursor | updated_at DESC | -| 消息列表 | GET /conversations/:id/messages/cursor | seq DESC | -| 评论列表 | GET /comments/post/:id/cursor | created_at DESC | -| 评论回复 | GET /comments/:id/replies/cursor | created_at ASC | -| 通知列表 | GET /notifications/cursor | created_at DESC | -| 群组列表 | GET /groups/cursor | created_at DESC | -| 群成员 | GET /groups/:id/members/cursor | join_time DESC | -| 群公告 | GET /groups/:id/announcements/cursor | created_at DESC | - -### 后端 API 规范 - -**请求参数:** -- `cursor` - 游标字符串(首次请求不传) -- `direction` - 分页方向:`forward` 或 `backward`(默认 forward) -- `page_size` - 每页数量(默认 20,最大 100) - -**响应格式:** -```json -{ - "items": [...], - "next_cursor": "xxx", - "prev_cursor": "xxx", - "has_more": true -} -``` - ---- - -## 二、现有前端分页机制分析 - -### 2.1 分页类型定义 (`src/infrastructure/pagination/types.ts`) - -```typescript -// 现有分页状态 - 基于页码 -interface PaginationState { - currentPage: number; - pageSize: number; - totalLoaded: number; - hasMore: boolean; - isLoading: boolean; - lastLoadTime: number; - cursor?: string | number | null; // 已支持但未充分使用 - hasError: boolean; - error?: string; -} - -// 现有 FetchPageFunction -type FetchPageFunction = ( - page: number, - pageSize: number, - cursor?: string | number | null -) => Promise<{ - data: T[]; - hasMore: boolean; - cursor?: string | number | null; -}>; -``` - -### 2.2 分页状态管理器 (`PaginationStateManager.ts`) - -- 使用 `currentPage` 作为分页基准 -- 基于页码的缓存机制(`pageCaches: Map>`) -- `loadMore()` 方法自动递增页码 - -### 2.3 usePagination Hook (`usePagination.ts`) - -- 暴露 `currentPage`、`loadMore`、`refresh` 等方法 -- 内部调用 `PaginationStateManager` 进行状态管理 -- 已有 `cursor` 支持,但 UI 层仍使用页码 - -### 2.4 现有 API 服务层 - -**postService.ts:** -```typescript -// 现有分页方式 -async getPosts(page = 1, pageSize = 20, tab?: string): Promise> -``` - -**messageService.ts:** -```typescript -// 现有分页方式 - 基于 seq -async getMessages(conversationId, afterSeq?, beforeSeq?, limit?): Promise -``` - -### 2.5 现有页面组件分页方式 - -各页面使用**手动管理状态**的方式: -```typescript -const [page, setPage] = useState(1); -const [hasMore, setHasMore] = useState(true); - -const loadMore = useCallback(() => { - if (!loading && hasMore) { - const nextPage = page + 1; - setPage(nextPage); - loadData(nextPage); - } -}, [loading, hasMore, page, loadData]); -``` - -**涉及页面:** -- `HomeScreen.tsx` - 帖子列表 -- `SearchScreen.tsx` - 帖子搜索 -- `PostDetailScreen.tsx` - 帖子详情(评论列表) -- `MessageListScreen.tsx` - 会话列表 -- `ChatScreen.tsx` - 消息列表 -- `NotificationsScreen.tsx` - 通知列表 -- `GroupMembersScreen.tsx` - 群组成员 -- `JoinGroupScreen.tsx` - 群组列表 - ---- - -## 三、游标分页类型定义设计 - -### 3.1 新增游标分页相关类型 (`src/infrastructure/pagination/types.ts`) - -```typescript -// ==================== 游标分页类型 ==================== - -/** - * 游标分页方向 - */ -export type CursorDirection = 'forward' | 'backward'; - -/** - * 游标分页请求参数 - */ -export interface CursorPageRequest { - cursor?: string | null; - direction?: CursorDirection; - page_size?: number; -} - -/** - * 游标分页响应 - */ -export interface CursorPageResponse { - items: T[]; - next_cursor: string | null; - prev_cursor: string | null; - has_more: boolean; -} - -/** - * 游标分页状态 - 扩展现有 PaginationState - */ -export interface CursorPaginationState { - // 游标相关 - nextCursor: string | null; - prevCursor: string | null; - currentCursor: string | null; - - // 方向 - direction: CursorDirection; - - // 通用 - hasMore: boolean; - isLoading: boolean; - hasError: boolean; - error?: string; -} - -/** - * 游标分页配置 - */ -export interface CursorPaginationConfig { - pageSize: number; - prefetchThreshold: number; - enablePrefetch: boolean; - maxRetries: number; - retryDelay: number; -} - -/** - * 游标分页加载函数 - */ -export type CursorFetchFunction = ( - request: CursorPageRequest -) => Promise>; - -/** - * 游标分页加载结果 - */ -export interface CursorLoadMoreResult { - success: boolean; - data: T[]; - hasMore: boolean; - nextCursor: string | null; - prevCursor: string | null; - fromCache: boolean; - error?: string; -} - -/** - * 游标分页刷新结果 - */ -export interface CursorRefreshResult { - success: boolean; - data: T[]; - hasMore: boolean; - nextCursor: string | null; - error?: string; -} -``` - -### 3.2 新增游标分页 DTO 类型 (`src/types/dto.ts`) - -```typescript -// ==================== 游标分页 DTO ==================== - -/** - * 游标分页响应基础结构 - */ -export interface CursorPaginatedResponse { - items: T[]; - next_cursor: string | null; - prev_cursor: string | null; - has_more: boolean; -} - -/** - * 帖子列表游标分页响应 - */ -export interface PostCursorResponse extends CursorPaginatedResponse {} - -/** - * 会话列表游标分页响应 - */ -export interface ConversationCursorResponse extends CursorPaginatedResponse {} - -/** - * 消息列表游标分页响应 - */ -export interface MessageCursorResponse extends CursorPaginatedResponse {} - -/** - * 评论列表游标分页响应 - */ -export interface CommentCursorResponse extends CursorPaginatedResponse {} - -/** - * 通知列表游标分页响应 - */ -export interface NotificationCursorResponse extends CursorPaginatedResponse {} - -/** - * 群组列表游标分页响应 - */ -export interface GroupCursorResponse extends CursorPaginatedResponse {} - -/** - * 群成员列表游标分页响应 - */ -export interface GroupMemberCursorResponse extends CursorPaginatedResponse {} - -/** - * 群公告列表游标分页响应 - */ -export interface GroupAnnouncementCursorResponse extends CursorPaginatedResponse {} -``` - ---- - -## 四、API 服务层改造设计 - -### 4.1 新增游标分页 API 方法 - -#### postService.ts 改造 -```typescript -class PostService { - // ==================== 游标分页方法 ==================== - - /** - * 获取帖子列表(游标分页) - * GET /api/v1/posts/cursor - */ - async getPostsCursor( - request: CursorPageRequest = {} - ): Promise> { - const params: Record = { - page_size: request.page_size || 20, - }; - if (request.cursor) params.cursor = request.cursor; - if (request.direction) params.direction = request.direction; - - const response = await api.get('/posts/cursor', params); - return { - items: response.data.items, - next_cursor: response.data.next_cursor, - prev_cursor: response.data.prev_cursor, - has_more: response.data.has_more, - }; - } - - /** - * 搜索帖子(游标分页) - * GET /api/v1/posts/search/cursor - */ - async searchPostsCursor( - keyword: string, - request: CursorPageRequest = {} - ): Promise> { - const params: Record = { - keyword, - page_size: request.page_size || 20, - }; - if (request.cursor) params.cursor = request.cursor; - if (request.direction) params.direction = request.direction; - - const response = await api.get('/posts/search/cursor', params); - return response.data; - } - - /** - * 获取用户帖子列表(游标分页) - * GET /api/v1/users/:id/posts/cursor - */ - async getUserPostsCursor( - userId: string, - request: CursorPageRequest = {} - ): Promise> { - const params: Record = { - page_size: request.page_size || 20, - }; - if (request.cursor) params.cursor = request.cursor; - if (request.direction) params.direction = request.direction; - - const response = await api.get( - `/users/${userId}/posts/cursor`, - params - ); - return response.data; - } -} -``` - -#### messageService.ts 改造 -```typescript -class MessageService { - // ==================== 会话列表游标分页 ==================== - - /** - * 获取会话列表(游标分页) - * GET /api/v1/conversations/cursor - */ - async getConversationsCursor( - request: CursorPageRequest = {} - ): Promise> { - const params: Record = { - page_size: request.page_size || 20, - }; - if (request.cursor) params.cursor = request.cursor; - if (request.direction) params.direction = request.direction; - - const response = await api.get('/conversations/cursor', params); - return response.data; - } - - // ==================== 消息列表游标分页 ==================== - - /** - * 获取消息列表(游标分页) - * GET /api/v1/conversations/:id/messages/cursor - */ - async getMessagesCursor( - conversationId: string, - request: CursorPageRequest = {} - ): Promise> { - const params: Record = { - page_size: request.page_size || 20, - }; - if (request.cursor) params.cursor = request.cursor; - if (request.direction) params.direction = request.direction; - - const response = await api.get( - `/conversations/${encodeURIComponent(conversationId)}/messages/cursor`, - params - ); - return response.data; - } -} -``` - -#### 新增 commentService.ts -```typescript -// 新建文件: src/services/commentService.ts - -import { api } from './api'; -import { CommentCursorResponse, CursorPageRequest, CursorPageResponse } from '../types/dto'; -import { CommentDTO } from '../types/dto'; - -class CommentService { - /** - * 获取帖子评论列表(游标分页) - * GET /api/v1/comments/post/:id/cursor - */ - async getPostCommentsCursor( - postId: string, - request: CursorPageRequest = {} - ): Promise> { - const params: Record = { - page_size: request.page_size || 20, - }; - if (request.cursor) params.cursor = request.cursor; - if (request.direction) params.direction = request.direction; - - const response = await api.get( - `/comments/post/${postId}/cursor`, - params - ); - return response.data; - } - - /** - * 获取评论回复列表(游标分页) - * GET /api/v1/comments/:id/replies/cursor - */ - async getCommentRepliesCursor( - commentId: string, - request: CursorPageRequest = {} - ): Promise> { - const params: Record = { - page_size: request.page_size || 20, - }; - if (request.cursor) params.cursor = request.cursor; - if (request.direction) params.direction = request.direction; - - const response = await api.get( - `/comments/${commentId}/replies/cursor`, - params - ); - return response.data; - } -} - -export const commentService = new CommentService(); -``` - -#### 新增 notificationService.ts -```typescript -// 新建文件: src/services/notificationService.ts - -import { api } from './api'; -import { NotificationCursorResponse, CursorPageRequest, CursorPageResponse } from '../types/dto'; -import { NotificationDTO } from '../types/dto'; - -class NotificationService { - /** - * 获取通知列表(游标分页) - * GET /api/v1/notifications/cursor - */ - async getNotificationsCursor( - request: CursorPageRequest = {} - ): Promise> { - const params: Record = { - page_size: request.page_size || 20, - }; - if (request.cursor) params.cursor = request.cursor; - if (request.direction) params.direction = request.direction; - - const response = await api.get('/notifications/cursor', params); - return response.data; - } -} - -export const notificationService = new NotificationService(); -``` - -#### 新增 groupService.ts (扩展现有群组服务) -```typescript -// 在现有 groupService.ts 中添加游标分页方法 - -class GroupService { - /** - * 获取群组列表(游标分页) - * GET /api/v1/groups/cursor - */ - async getGroupsCursor( - request: CursorPageRequest = {} - ): Promise> { - const params: Record = { - page_size: request.page_size || 20, - }; - if (request.cursor) params.cursor = request.cursor; - if (request.direction) params.direction = request.direction; - - const response = await api.get('/groups/cursor', params); - return response.data; - } - - /** - * 获取群成员列表(游标分页) - * GET /api/v1/groups/:id/members/cursor - */ - async getGroupMembersCursor( - groupId: string, - request: CursorPageRequest = {} - ): Promise> { - const params: Record = { - page_size: request.page_size || 20, - }; - if (request.cursor) params.cursor = request.cursor; - if (request.direction) params.direction = request.direction; - - const response = await api.get( - `/groups/${groupId}/members/cursor`, - params - ); - return response.data; - } - - /** - * 获取群公告列表(游标分页) - * GET /api/v1/groups/:id/announcements/cursor - */ - async getGroupAnnouncementsCursor( - groupId: string, - request: CursorPageRequest = {} - ): Promise> { - const params: Record = { - page_size: request.page_size || 20, - }; - if (request.cursor) params.cursor = request.cursor; - if (request.direction) params.direction = request.direction; - - const response = await api.get( - `/groups/${groupId}/announcements/cursor`, - params - ); - return response.data; - } -} -``` - ---- - -## 五、分页 Hook 改造设计 - -### 5.1 新增 useCursorPagination Hook (`src/hooks/useCursorPagination.ts`) - -```typescript -/** - * 游标分页 Hook - * - * 提供基于游标的分页功能,包括: - * - loadMore: 加载下一页 - * - loadPrevious: 加载上一页(双向游标支持) - * - refresh: 刷新数据 - * - 状态管理 - */ - -import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; -import { CursorPaginationState, CursorPaginationConfig } from '../infrastructure/pagination/types'; - -export interface UseCursorPaginationOptions { - /** 分页键 */ - key: string | null; - /** 数据获取函数 */ - fetchFunction: CursorFetchFunction; - /** 分页配置 */ - config?: Partial; - /** 初始数据 */ - initialData?: T[]; - /** 是否自动加载第一页 */ - autoLoad?: boolean; - /** 是否启用自动预加载 */ - enableAutoPrefetch?: boolean; -} - -export interface UseCursorPaginationReturn { - /** 当前数据列表 */ - data: T[]; - /** 是否正在加载 */ - isLoading: boolean; - /** 是否正在刷新 */ - isRefreshing: boolean; - /** 是否有更多数据 */ - hasMore: boolean; - /** 是否有上一页 */ - hasPrevious: boolean; - /** 是否有错误 */ - hasError: boolean; - /** 错误信息 */ - error?: string; - /** 当前游标 */ - currentCursor: string | null; - /** 加载下一页 */ - loadMore: () => Promise>; - /** 加载上一页 */ - loadPrevious: () => Promise>; - /** 刷新数据 */ - refresh: () => Promise>; - /** 重置分页状态 */ - reset: () => void; -} - -export function useCursorPagination( - options: UseCursorPaginationOptions -): UseCursorPaginationReturn { - const { - key, - fetchFunction, - config: userConfig, - initialData = [], - autoLoad = false, - enableAutoPrefetch = true, - } = options; - - // 合并配置 - const config = useMemo(() => ({ - ...DEFAULT_CURSOR_PAGINATION_CONFIG, - ...userConfig, - }), [userConfig]); - - // 数据状态 - const [data, setData] = useState(initialData); - const [state, setState] = useState({ - nextCursor: null, - prevCursor: null, - currentCursor: null, - direction: 'forward', - hasMore: true, - isLoading: false, - hasError: false, - error: undefined, - }); - - // Refs - const autoLoadRef = useRef(false); - const isMountedRef = useRef(true); - - // 派生的 UI 状态 - const isRefreshing = state.isLoading && data.length > 0; - const hasPrevious = state.prevCursor !== null; - - /** - * 加载下一页 - */ - const loadMore = useCallback(async (): Promise> => { - if (!key) { - return { success: false, data: [], hasMore: false, nextCursor: null, prevCursor: null, fromCache: false, error: 'No pagination key' }; - } - - try { - setState(prev => ({ ...prev, isLoading: true, hasError: false })); - - const response = await fetchFunction({ - cursor: state.nextCursor, - direction: 'forward', - page_size: config.pageSize, - }); - - if (isMountedRef.current) { - setData(prev => [...prev, ...response.items]); - setState({ - nextCursor: response.next_cursor, - prevCursor: response.prev_cursor, - currentCursor: response.next_cursor, - direction: 'forward', - hasMore: response.has_more, - isLoading: false, - hasError: false, - }); - } - - return { - success: true, - data: response.items, - hasMore: response.has_more, - nextCursor: response.next_cursor, - prevCursor: response.prev_cursor, - fromCache: false, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - if (isMountedRef.current) { - setState(prev => ({ ...prev, isLoading: false, hasError: true, error: errorMessage })); - } - return { success: false, data: [], hasMore: false, nextCursor: null, prevCursor: null, fromCache: false, error: errorMessage }; - } - }, [key, fetchFunction, state.nextCursor, config.pageSize]); - - /** - * 加载上一页 - */ - const loadPrevious = useCallback(async (): Promise> => { - if (!key || !state.prevCursor) { - return { success: false, data: [], hasMore: false, nextCursor: null, prevCursor: null, fromCache: false, error: 'No previous page' }; - } - - try { - setState(prev => ({ ...prev, isLoading: true, hasError: false })); - - const response = await fetchFunction({ - cursor: state.prevCursor, - direction: 'backward', - page_size: config.pageSize, - }); - - if (isMountedRef.current) { - setData(prev => [...response.items, ...prev]); - setState({ - nextCursor: response.next_cursor, - prevCursor: response.prev_cursor, - currentCursor: response.prev_cursor, - direction: 'backward', - hasMore: response.has_more, - isLoading: false, - hasError: false, - }); - } - - return { - success: true, - data: response.items, - hasMore: response.has_more, - nextCursor: response.next_cursor, - prevCursor: response.prev_cursor, - fromCache: false, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - if (isMountedRef.current) { - setState(prev => ({ ...prev, isLoading: false, hasError: true, error: errorMessage })); - } - return { success: false, data: [], hasMore: false, nextCursor: null, prevCursor: null, fromCache: false, error: errorMessage }; - } - }, [key, fetchFunction, state.prevCursor, config.pageSize]); - - /** - * 刷新数据 - */ - const refresh = useCallback(async (): Promise> => { - if (!key) { - return { success: false, data: [], hasMore: false, nextCursor: null, error: 'No pagination key' }; - } - - try { - setState(prev => ({ ...prev, isLoading: true, hasError: false })); - - const response = await fetchFunction({ - cursor: null, - direction: 'forward', - page_size: config.pageSize, - }); - - if (isMountedRef.current) { - setData(response.items); - setState({ - nextCursor: response.next_cursor, - prevCursor: null, - currentCursor: null, - direction: 'forward', - hasMore: response.has_more, - isLoading: false, - hasError: false, - }); - } - - return { - success: true, - data: response.items, - hasMore: response.has_more, - nextCursor: response.next_cursor, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - if (isMountedRef.current) { - setState(prev => ({ ...prev, isLoading: false, hasError: true, error: errorMessage })); - } - return { success: false, data: [], hasMore: false, nextCursor: null, error: errorMessage }; - } - }, [key, fetchFunction, config.pageSize]); - - /** - * 重置分页状态 - */ - const reset = useCallback(() => { - if (isMountedRef.current) { - setData([]); - setState({ - nextCursor: null, - prevCursor: null, - currentCursor: null, - direction: 'forward', - hasMore: true, - isLoading: false, - hasError: false, - }); - autoLoadRef.current = false; - } - }, []); - - // 自动加载第一页 - useEffect(() => { - if (!key || !autoLoad || autoLoadRef.current) return; - if (data.length > 0) return; - - autoLoadRef.current = true; - refresh(); - }, [key, autoLoad, data.length, refresh]); - - // 组件卸载时清理 - useEffect(() => { - return () => { - isMountedRef.current = false; - }; - }, []); - - return { - data, - isLoading: state.isLoading, - isRefreshing, - hasMore: state.hasMore, - hasPrevious, - hasError: state.hasError, - error: state.error, - currentCursor: state.currentCursor, - loadMore, - loadPrevious, - refresh, - reset, - }; -} -``` - ---- - -## 六、各列表页面改造计划 - -### 6.1 帖子列表 (HomeScreen.tsx) - -```typescript -// 改造后的分页方式 -const { - data: posts, - isLoading, - hasMore, - loadMore, - refresh, -} = useCursorPagination({ - key: 'home-posts', - fetchFunction: async (request) => { - return postService.getPostsCursor(request); - }, - config: { pageSize: 20 }, - autoLoad: true, -}); - -// UI 层使用 FlatList/FlashList 的 onEndReached - 0} - onRefresh={refresh} -/> -``` - -### 6.2 会话列表 (MessageListScreen.tsx) - -```typescript -const { - data: conversations, - isLoading, - hasMore, - loadMore, - refresh, -} = useCursorPagination({ - key: 'conversations', - fetchFunction: async (request) => { - return messageService.getConversationsCursor(request); - }, - autoLoad: true, -}); -``` - -### 6.3 消息列表 (ChatScreen.tsx) - -```typescript -// 聊天消息需要支持双向加载 -const { - data: messages, - isLoading, - hasMore, - hasPrevious, - loadMore, // 加载更多历史 - loadPrevious, // 加载更新的消息 - refresh, -} = useCursorPagination({ - key: `chat-${conversationId}`, - fetchFunction: async (request) => { - return messageService.getMessagesCursor(conversationId, request); - }, -}); -``` - -### 6.4 帖子搜索 (SearchScreen.tsx) - -```typescript -const { - data: searchResults, - isLoading, - hasMore, - loadMore, - refresh, -} = useCursorPagination({ - key: `search-${keyword}`, - fetchFunction: async (request) => { - return postService.searchPostsCursor(keyword, request); - }, - autoLoad: !!keyword, -}); -``` - -### 6.5 评论列表 (PostDetailScreen.tsx) - -```typescript -const { - data: comments, - isLoading, - hasMore, - loadMore, - refresh, -} = useCursorPagination({ - key: `comments-${postId}`, - fetchFunction: async (request) => { - return commentService.getPostCommentsCursor(postId, request); - }, -}); -``` - -### 6.6 通知列表 (NotificationsScreen.tsx) - -```typescript -const { - data: notifications, - isLoading, - hasMore, - loadMore, - refresh, -} = useCursorPagination({ - key: 'notifications', - fetchFunction: async (request) => { - return notificationService.getNotificationsCursor(request); - }, -}); -``` - -### 6.7 群组列表 (JoinGroupScreen.tsx) - -```typescript -const { - data: groups, - isLoading, - hasMore, - loadMore, - refresh, -} = useCursorPagination({ - key: 'groups', - fetchFunction: async (request) => { - return groupService.getGroupsCursor(request); - }, -}); -``` - -### 6.8 群组成员 (GroupMembersScreen.tsx) - -```typescript -const { - data: members, - isLoading, - hasMore, - loadMore, - refresh, -} = useCursorPagination({ - key: `group-members-${groupId}`, - fetchFunction: async (request) => { - return groupService.getGroupMembersCursor(groupId, request); - }, -}); -``` - ---- - -## 七、需要修改的文件清单 - -### 7.1 新增文件 - -| 文件路径 | 说明 | -|----------|------| -| `src/hooks/useCursorPagination.ts` | 新增游标分页 Hook | -| `src/services/commentService.ts` | 新增评论服务(含游标分页) | -| `src/services/notificationService.ts` | 新增通知服务(含游标分页) | -| `src/types/cursor.ts` | 新增游标分页相关类型定义 | - -### 7.2 需要修改的文件 - -| 文件路径 | 修改内容 | -|----------|----------| -| `src/types/dto.ts` | 新增游标分页 DTO 类型 | -| `src/services/postService.ts` | 新增游标分页方法(保留旧方法兼容) | -| `src/services/messageService.ts` | 新增会话/消息游标分页方法 | -| `src/services/groupService.ts` | 新增群组相关游标分页方法 | -| `src/infrastructure/pagination/types.ts` | 新增游标分页类型定义 | -| `src/infrastructure/pagination/CursorPaginationStateManager.ts` | 新增游标分页状态管理器(可选) | -| `src/screens/home/HomeScreen.tsx` | 改造为使用 useCursorPagination | -| `src/screens/home/SearchScreen.tsx` | 改造为使用 useCursorPagination | -| `src/screens/home/PostDetailScreen.tsx` | 改造评论列表使用 useCursorPagination | -| `src/screens/message/MessageListScreen.tsx` | 改造为使用 useCursorPagination | -| `src/screens/message/ChatScreen.tsx` | 改造消息列表使用 useCursorPagination | -| `src/screens/message/NotificationsScreen.tsx` | 改造为使用 useCursorPagination | -| `src/screens/message/JoinGroupScreen.tsx` | 改造为使用 useCursorPagination | -| `src/screens/message/GroupMembersScreen.tsx` | 改造为使用 useCursorPagination | - ---- - -## 八、实现顺序建议 - -### 阶段一:基础设施(优先级高) -1. 在 `src/types/dto.ts` 新增游标分页 DTO 类型 -2. 在 `src/infrastructure/pagination/types.ts` 新增游标分页类型 -3. 创建 `src/hooks/useCursorPagination.ts` - -### 阶段二:API 服务层 -4. 改造 `postService.ts` - 新增游标分页方法 -5. 改造 `messageService.ts` - 新增游标分页方法 -6. 创建 `commentService.ts` -7. 创建 `notificationService.ts` -8. 改造 `groupService.ts` - 新增游标分页方法 - -### 阶段三:UI 组件改造(按依赖顺序) -9. `HomeScreen.tsx` - 帖子列表 -10. `SearchScreen.tsx` - 帖子搜索 -11. `PostDetailScreen.tsx` - 评论列表 -12. `MessageListScreen.tsx` - 会话列表 -13. `ChatScreen.tsx` - 消息列表 -14. `NotificationsScreen.tsx` - 通知列表 -15. `JoinGroupScreen.tsx` - 群组列表 -16. `GroupMembersScreen.tsx` - 群组成员 - ---- - -## 九、架构图 - -```mermaid -graph TB - subgraph "UI Layer" - HomeScreen[HomeScreen.tsx] - SearchScreen[SearchScreen.tsx] - ChatScreen[ChatScreen.tsx] - MessageListScreen[MessageListScreen.tsx] - NotificationsScreen[NotificationsScreen.tsx] - GroupMembersScreen[GroupMembersScreen.tsx] - end - - subgraph "Hooks Layer" - useCursorPagination[useCursorPagination.ts] - usePagination[usePagination.ts - 保留兼容] - end - - subgraph "Service Layer" - postService[postService.ts] - messageService[messageService.ts] - commentService[commentService.ts] - notificationService[notificationService.ts] - groupService[groupService.ts] - end - - subgraph "API Layer" - API[API Client] - end - - subgraph "Backend" - CursorAPI[游标分页 API] - end - - HomeScreen --> useCursorPagination - SearchScreen --> useCursorPagination - ChatScreen --> useCursorPagination - MessageListScreen --> useCursorPagination - NotificationsScreen --> useCursorPagination - GroupMembersScreen --> useCursorPagination - - useCursorPagination --> postService - useCursorPagination --> messageService - useCursorPagination --> commentService - useCursorPagination --> notificationService - useCursorPagination --> groupService - - postService --> API - messageService --> API - commentService --> API - notificationService --> API - groupService --> API - - API --> CursorAPI -``` - ---- - -## 十、注意事项 - -### 10.1 兼容性考虑 -- 保留现有的页码分页方法一段时间,避免一次性全量替换带来的风险 -- 通过功能开关或配置控制是否启用游标分页 - -### 10.2 消息列表特殊处理 -- 聊天消息需要支持双向加载(加载历史 + 加载最新) -- 需要处理好新消息的插入位置(头部 vs 尾部) - -### 10.3 缓存策略 -- 游标分页的缓存策略需要调整,不再基于页码 -- 可以考虑基于 cursor 字符串进行缓存 - -### 10.4 错误处理 -- 网络错误时需要显示重试选项 -- 游标失效时需要提示用户刷新列表 diff --git a/src/navigation/SimpleMobileTabNavigator.tsx b/src/navigation/SimpleMobileTabNavigator.tsx index 1591417..c50c951 100644 --- a/src/navigation/SimpleMobileTabNavigator.tsx +++ b/src/navigation/SimpleMobileTabNavigator.tsx @@ -40,8 +40,21 @@ export type ScheduleStackParamList = { CourseDetail: { course: any; relatedCourses?: any[] }; }; +// Profile Stack 类型定义 +export type ProfileStackParamList = { + Profile: undefined; + Settings: undefined; + EditProfile: undefined; + AccountSecurity: undefined; + MyPosts: undefined; + Bookmarks: undefined; + NotificationSettings: undefined; + BlockedUsers: undefined; +}; + // ==================== Stack Navigators ==================== const ScheduleStack = createNativeStackNavigator(); +const ProfileStack = createNativeStackNavigator(); // ==================== 常量 ==================== const TAB_BAR_HEIGHT = 64; @@ -80,6 +93,84 @@ function ScheduleStackNavigatorComponent() { ); } +/** + * Profile Stack Navigator 组件 + */ +function ProfileStackNavigatorComponent() { + return ( + + + + + + + + + + + ); +} + /** * 主组件:SimpleMobileTabNavigator */ @@ -108,7 +199,7 @@ export function SimpleMobileTabNavigator() { case 'ScheduleTab': return ; case 'ProfileTab': - return ; + return ; default: return ; } diff --git a/src/services/authService.ts b/src/services/authService.ts index fa28a9d..c32f953 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -150,6 +150,9 @@ class AuthService { async login(data: LoginRequest): Promise { const response = await api.post('/auth/login', data); + if (!response.data) { + throw new Error('登录响应数据为空'); + } if (response.data.token) { await api.setToken(response.data.token); } @@ -165,6 +168,9 @@ class AuthService { async register(data: RegisterRequest): Promise { const response = await api.post('/auth/register', data); + if (!response.data) { + throw new Error('注册响应数据为空'); + } if (response.data.token) { await api.setToken(response.data.token); } @@ -233,6 +239,9 @@ class AuthService { try { const response = await api.post('/auth/refresh'); + if (!response.data) { + return null; + } if (response.data.token) { await api.setToken(response.data.token); } diff --git a/src/services/postService.ts b/src/services/postService.ts index f4ece35..72afa79 100644 --- a/src/services/postService.ts +++ b/src/services/postService.ts @@ -287,19 +287,17 @@ class PostService { /** * 获取帖子列表(游标分页) - * GET /api/v1/posts/cursor + * GET /api/v1/posts * @param params 游标分页请求参数(包含 post_type 可选:recommend, follow, hot, latest) */ - async getPostsCursor( - params: CursorPaginationRequest = {} - ): Promise> { - try { - const response = await api.get>('/posts/cursor', { - params: { - cursor: params.cursor, - page_size: params.page_size, - post_type: params.post_type, - }, + async getPostsCursor( + params: CursorPaginationRequest = {} + ): Promise> { + try { + const response = await api.get>('/posts', { + cursor: params.cursor, + page_size: params.page_size, + post_type: params.post_type, }); return response.data; } catch (error) { @@ -315,7 +313,7 @@ class PostService { /** * 搜索帖子(游标分页) - * GET /api/v1/posts/search/cursor + * GET /api/v1/posts/search * @param query 搜索关键词 * @param params 游标分页请求参数 */ @@ -324,11 +322,9 @@ class PostService { params: CursorPaginationRequest = {} ): Promise> { try { - const response = await api.get>('/posts/search/cursor', { - params: { - ...params, - keyword: query, - }, + const response = await api.get>('/posts/search', { + ...params, + keyword: query, }); return response.data; } catch (error) { @@ -344,7 +340,7 @@ class PostService { /** * 获取用户帖子列表(游标分页) - * GET /api/v1/users/:id/posts/cursor + * GET /api/v1/users/:id/posts * @param userId 用户ID * @param params 游标分页请求参数 */ @@ -354,8 +350,8 @@ class PostService { ): Promise> { try { const response = await api.get>( - `/users/${userId}/posts/cursor`, - { params } + `/users/${userId}/posts`, + params ); return response.data; } catch (error) { From a8c78f0c3f5e666da52374fcda3469b9b00fefb2 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Sat, 21 Mar 2026 03:22:28 +0800 Subject: [PATCH 07/36] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A5dev=E5=88=86?= =?UTF-8?q?=E6=94=AF=E5=B9=B6=E4=BF=9D=E7=95=99=E5=BD=93=E5=89=8D=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/business/SystemMessageItem.tsx | 4 +- src/hooks/useCursorPagination.ts | 111 +++++++++- src/infrastructure/pagination/types.ts | 2 + src/navigation/SimpleMobileTabNavigator.tsx | 10 +- src/screens/home/HomeScreen.tsx | 10 +- src/screens/message/MessageListScreen.tsx | 6 +- src/screens/message/NotificationsScreen.tsx | 205 +++++++++++------- src/screens/profile/AccountSecurityScreen.tsx | 12 +- src/screens/profile/BlockedUsersScreen.tsx | 10 +- src/screens/profile/EditProfileScreen.tsx | 5 +- .../profile/NotificationSettingsScreen.tsx | 12 +- src/screens/profile/ProfileScreen.tsx | 6 +- src/screens/profile/SettingsScreen.tsx | 10 +- src/screens/schedule/ScheduleScreen.tsx | 16 +- src/services/messageService.ts | 45 +++- src/services/postService.ts | 125 ++++++++++- 16 files changed, 461 insertions(+), 128 deletions(-) diff --git a/src/components/business/SystemMessageItem.tsx b/src/components/business/SystemMessageItem.tsx index 64894ec..4d8109b 100644 --- a/src/components/business/SystemMessageItem.tsx +++ b/src/components/business/SystemMessageItem.tsx @@ -203,8 +203,8 @@ const SystemMessageItem: React.FC = ({ alignItems: 'flex-start', padding: containerPadding, backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: colors.divider, + // 移除底部边框,使用卡片式设计 + marginBottom: 1, }, iconContainer: { marginRight: spacing.md, diff --git a/src/hooks/useCursorPagination.ts b/src/hooks/useCursorPagination.ts index 403316e..9ba0a56 100644 --- a/src/hooks/useCursorPagination.ts +++ b/src/hooks/useCursorPagination.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import { CursorPaginationState, CursorPaginationConfig, @@ -13,7 +13,7 @@ const MAX_PAGE_SIZE = 100; /** * 游标分页 Hook - * + * * @param fetchFunction 数据获取函数 * @param config 分页配置 * @param extraParams 额外参数(会传递给 fetchFunction) @@ -23,7 +23,7 @@ export function useCursorPagination( config: Partial = {}, extraParams?: P ): UseCursorPaginationReturn { - const { pageSize = DEFAULT_PAGE_SIZE, bidirectional = false } = config; + const { pageSize = DEFAULT_PAGE_SIZE, bidirectional = false, autoLoad = true } = config; // 限制 pageSize 在有效范围内 const effectivePageSize = Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE); @@ -32,7 +32,7 @@ export function useCursorPagination( items: [], nextCursor: null, prevCursor: null, - hasMore: false, + hasMore: true, // 初始设为 true,以便首次加载可以执行 isLoading: false, isRefreshing: false, error: null, @@ -42,12 +42,28 @@ export function useCursorPagination( // 用于取消请求的标志 const cancelledRef = useRef(false); + // 用于追踪是否已自动加载 + const autoLoadRef = useRef(false); + + // 用于追踪是否为首次加载的 ref + const isFirstLoadRef = useRef(true); + + // 用于追踪是否正在加载中(防止重复调用) + const isLoadingRef = useRef(false); + // 加载更多(下一页) const loadMore = useCallback(async () => { - if (state.isLoading || !state.hasMore) { + // 使用 ref 来检查是否为首次加载,避免依赖项变化 + const isFirstLoad = isFirstLoadRef.current; + + // 首次加载时允许执行(跳过 hasMore 检查) + // 使用 isLoadingRef 防止重复调用 + if (isLoadingRef.current || state.isLoading || (!isFirstLoad && !state.hasMore)) { return; } + // 标记正在加载 + isLoadingRef.current = true; cancelledRef.current = false; setState(prev => ({ ...prev, isLoading: true, error: null })); @@ -60,7 +76,13 @@ export function useCursorPagination( extraParams, }); - if (cancelledRef.current) return; + if (cancelledRef.current) { + isLoadingRef.current = false; + return; + } + + // 标记首次加载完成 + isFirstLoadRef.current = false; setState(prev => ({ ...prev, @@ -71,14 +93,21 @@ export function useCursorPagination( isLoading: false, isFirstLoad: false, })); + + isLoadingRef.current = false; } catch (error) { - if (cancelledRef.current) return; + if (cancelledRef.current) { + isLoadingRef.current = false; + return; + } setState(prev => ({ ...prev, isLoading: false, error: error instanceof Error ? error.message : '加载失败', })); + + isLoadingRef.current = false; } }, [state.isLoading, state.hasMore, state.nextCursor, fetchFunction, effectivePageSize, extraParams]); @@ -163,11 +192,14 @@ export function useCursorPagination( // 重置状态 const reset = useCallback(() => { cancelledRef.current = true; + autoLoadRef.current = false; + isFirstLoadRef.current = true; + isLoadingRef.current = false; setState({ items: [], nextCursor: null, prevCursor: null, - hasMore: false, + hasMore: true, // 重置后也设为 true,以便可以重新加载 isLoading: false, isRefreshing: false, error: null, @@ -195,6 +227,69 @@ export function useCursorPagination( [] ); + // 自动加载第一页 + useEffect(() => { + // 使用 ref 防止重复加载,不依赖 loadMore 函数引用 + // 只检查 autoLoad 和 autoLoadRef,不再依赖 state.items.length + if (!autoLoad || autoLoadRef.current) return; + + autoLoadRef.current = true; + + // 直接调用加载逻辑,避免依赖 loadMore 函数引用 + const loadInitialData = async () => { + if (isLoadingRef.current) return; + + isLoadingRef.current = true; + cancelledRef.current = false; + + setState(prev => ({ ...prev, isLoading: true, error: null })); + + try { + const response: CursorPaginationResponse = await fetchFunction({ + cursor: undefined, + direction: 'forward', + pageSize: effectivePageSize, + extraParams, + }); + + if (cancelledRef.current) { + isLoadingRef.current = false; + return; + } + + isFirstLoadRef.current = false; + + setState(prev => ({ + ...prev, + items: response.items, + nextCursor: response.next_cursor, + prevCursor: response.prev_cursor, + hasMore: response.has_more && response.next_cursor !== null, + isLoading: false, + isFirstLoad: false, + })); + + isLoadingRef.current = false; + } catch (error) { + if (cancelledRef.current) { + isLoadingRef.current = false; + return; + } + + setState(prev => ({ + ...prev, + isLoading: false, + error: error instanceof Error ? error.message : '加载失败', + })); + + isLoadingRef.current = false; + } + }; + + loadInitialData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoLoad]); // 只依赖 autoLoad,确保只执行一次 + return { ...state, loadMore, diff --git a/src/infrastructure/pagination/types.ts b/src/infrastructure/pagination/types.ts index 3ea56de..f07466a 100644 --- a/src/infrastructure/pagination/types.ts +++ b/src/infrastructure/pagination/types.ts @@ -196,6 +196,8 @@ export interface CursorPaginationConfig { pageSize: number; /** 是否启用双向分页 */ bidirectional?: boolean; + /** 是否自动加载第一页,默认为 true */ + autoLoad?: boolean; } /** diff --git a/src/navigation/SimpleMobileTabNavigator.tsx b/src/navigation/SimpleMobileTabNavigator.tsx index c50c951..6d81c2f 100644 --- a/src/navigation/SimpleMobileTabNavigator.tsx +++ b/src/navigation/SimpleMobileTabNavigator.tsx @@ -269,6 +269,9 @@ export function SimpleMobileTabNavigator() { ); }; + // 计算底部安全距离 + const bottomSafeArea = TAB_BAR_HEIGHT + TAB_BAR_FLOATING_MARGIN * 2 + insets.bottom; + return ( {/* 内容区域 */} @@ -331,7 +334,9 @@ const styles = StyleSheet.create({ }, content: { flex: 1, - marginBottom: TAB_BAR_HEIGHT + TAB_BAR_FLOATING_MARGIN * 2, + }, + bottomSpacer: { + backgroundColor: 'transparent', }, tabBar: { position: 'absolute', @@ -340,8 +345,7 @@ const styles = StyleSheet.create({ height: TAB_BAR_HEIGHT, backgroundColor: colors.background.paper, borderRadius: 24, - borderTopWidth: 1, - borderTopColor: `${colors.divider}88`, + // 移除顶部边框,避免与内容之间出现线条 flexDirection: 'row', alignItems: 'center', justifyContent: 'space-around', diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index c889eb0..66f37d9 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -179,8 +179,9 @@ export const HomeScreen: React.FC = () => { if (!isMobile) { return undefined; } - // 往底部导航栏靠近一个按钮的位置 - return insets.bottom + MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN + MOBILE_FAB_GAP - MOBILE_TAB_BAR_HEIGHT; + // TabBar 悬浮在底部,发帖按钮需要在 TabBar 上方 + // TabBar 高度 64 + TabBar 浮动间距 12 + 按钮与 TabBar 间距 16 + return MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN * 2 + MOBILE_FAB_GAP + insets.bottom; }, [isMobile, insets.bottom]); // 切换视图模式 @@ -375,6 +376,11 @@ export const HomeScreen: React.FC = () => { const columns: Post[][] = Array.from({ length: gridColumns }, () => []); const columnHeights: number[] = Array(gridColumns).fill(0); + // 防御性检查:确保 posts 存在且是数组 + if (!posts || !Array.isArray(posts) || posts.length === 0) { + return columns; + } + // 计算单列宽度 const totalGap = (gridColumns - 1) * responsiveGap; const columnWidth = (width - responsivePadding * 2 - totalGap) / gridColumns; diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index 918e8f7..a3a4d29 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -249,10 +249,12 @@ export const MessageListScreen: React.FC = () => { }, [width]); // 给底部列表预留安全空间,避免被 TabBar 遮挡导致最后几项无法完整滑出 + // TabBar 悬浮:高度 64 + 浮动间距 12*2 = 88 const listBottomInset = useMemo(() => { if (isWideScreen) return spacing.lg; - return tabBarHeight + insets.bottom + spacing.md; - }, [isWideScreen, tabBarHeight, insets.bottom]); + const FLOATING_TAB_BAR_HEIGHT = 64 + 24; // TabBar高度 + 上下浮动间距 + return FLOATING_TAB_BAR_HEIGHT + insets.bottom + spacing.md; + }, [isWideScreen, insets.bottom]); // 【新架构】页面获得焦点时初始化MessageManager useEffect(() => { diff --git a/src/screens/message/NotificationsScreen.tsx b/src/screens/message/NotificationsScreen.tsx index bef0104..028c566 100644 --- a/src/screens/message/NotificationsScreen.tsx +++ b/src/screens/message/NotificationsScreen.tsx @@ -5,7 +5,7 @@ * 支持响应式布局 */ -import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { View, FlatList, @@ -20,7 +20,7 @@ import { useIsFocused } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing } from '../../theme'; +import { colors, spacing, borderRadius } from '../../theme'; import { SystemMessageResponse } from '../../types/dto'; import { messageService } from '../../services/messageService'; import { commentService } from '../../services/commentService'; @@ -75,6 +75,17 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack const [unreadCount, setUnreadCount] = useState(0); // 【游标分页】使用 useCursorPagination hook 获取系统消息列表 + // 使用 useCallback 包裹 fetchFunction,避免无限重新创建导致循环 + const fetchSystemMessages = useCallback( + async ({ cursor, pageSize }: { cursor?: string; pageSize: number }) => { + return await messageService.getSystemMessagesCursor({ + cursor, + page_size: pageSize, + }); + }, + [] + ); + const { items: messages, isLoading, @@ -83,15 +94,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack loadMore, refresh, error: paginationError, - } = useCursorPagination( - async ({ cursor, pageSize }) => { - return await messageService.getSystemMessagesCursor({ - cursor, - page_size: pageSize, - }); - }, - { pageSize: 20 } - ); + } = useCursorPagination(fetchSystemMessages, { pageSize: 20 }); // 本地刷新状态(仅用于下拉刷新的UI显示) const [refreshing, setRefreshing] = useState(false); @@ -161,13 +164,16 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack }, [refresh, fetchMessageUnreadCount, setSystemUnreadCount]); // 页面加载和获得焦点时刷新,并自动标记所有消息为已读 + // 使用 ref 防止重复执行,避免无限循环 + const hasInitializedRef = useRef(false); useEffect(() => { - if (isFocused) { + if (isFocused && !hasInitializedRef.current) { + hasInitializedRef.current = true; fetchUnreadCount(); // 进入界面自动标记所有消息为已读 handleMarkAllRead(); } - }, [isFocused, fetchUnreadCount, handleMarkAllRead]); + }, [isFocused]); // 只依赖 isFocused,函数使用 ref 稳定引用 // 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式) useEffect(() => { @@ -328,8 +334,8 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack return ( {shouldShowBackButton ? ( - @@ -338,9 +344,16 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack ) : ( )} - - 系统通知 - + + + 系统通知 + + {unreadCount > 0 && ( + + {unreadCount > 99 ? '99+' : unreadCount} + + )} + ); @@ -388,30 +401,34 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack } return m.system_type === type.key; }).length; + const isActive = activeType === type.key; return ( setActiveType(type.key)} + activeOpacity={0.8} > {type.title} {count > 0 && ( - - {count} - + + + {count} + + )} ); @@ -463,29 +480,33 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack } return m.system_type === type.key; }).length; + const isActive = activeType === type.key; return ( setActiveType(type.key)} + activeOpacity={0.8} > {type.title} {count > 0 && ( - - {count} - + + + {count} + + )} ); @@ -529,13 +550,70 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: colors.background.default, }, + // Header 样式 - 美化版 + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + backgroundColor: colors.background.paper, + // 移除底部边框,使用更柔和的分隔方式 + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + headerWide: { + paddingHorizontal: spacing.xl, + paddingVertical: spacing.lg, + }, + headerTitleContainer: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + headerTitle: { + fontSize: 18, + fontWeight: '600', + color: colors.text.primary, + }, + headerTitleWide: { + fontSize: 20, + }, + unreadBadge: { + backgroundColor: colors.primary.main, + borderRadius: 10, + paddingHorizontal: 6, + paddingVertical: 2, + marginLeft: spacing.sm, + minWidth: 20, + alignItems: 'center', + justifyContent: 'center', + }, + unreadBadgeText: { + color: colors.text.inverse, + fontSize: 11, + fontWeight: '600', + }, + backButton: { + width: 40, + height: 40, + justifyContent: 'center', + alignItems: 'flex-start', + }, + backButtonPlaceholder: { + width: 40, + }, + // 分类筛选 - 美化版(使用分段式设计) filterContainer: { flexDirection: 'row', - paddingHorizontal: spacing.lg, + paddingHorizontal: spacing.md, paddingVertical: spacing.sm, backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: colors.divider, + // 移除底部边框,让界面更简洁 }, filterContainerWideWeb: { paddingHorizontal: spacing.xl, @@ -548,55 +626,36 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingHorizontal: spacing.md, paddingVertical: spacing.sm, - marginRight: spacing.sm, - borderRadius: 16, + marginRight: spacing.xs, + borderRadius: borderRadius.lg, backgroundColor: colors.background.default, }, filterTagWide: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md, - marginRight: spacing.md, + marginRight: spacing.sm, }, filterTagActive: { - backgroundColor: colors.primary.light + '30', + backgroundColor: colors.primary.main, + }, + filterTagTextActive: { + fontWeight: '600', }, filterCount: { marginLeft: spacing.xs, - }, - // Header 样式 - header: { - flexDirection: 'row', + backgroundColor: colors.primary.light + '40', + paddingHorizontal: 6, + paddingVertical: 1, + borderRadius: 8, + minWidth: 18, alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: spacing.lg, - paddingVertical: spacing.md, - backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: colors.divider, }, - headerWide: { - paddingHorizontal: spacing.xl, - paddingVertical: spacing.lg, - }, - backButton: { - width: 40, - height: 40, - justifyContent: 'center', - alignItems: 'flex-start', - }, - backButtonPlaceholder: { - width: 40, - }, - headerTitle: { - fontSize: 18, - fontWeight: '600', - color: colors.text.primary, - }, - headerTitleWide: { - fontSize: 20, + filterCountActive: { + backgroundColor: 'rgba(255, 255, 255, 0.25)', }, listContent: { flexGrow: 1, + paddingTop: spacing.sm, }, listContentWide: { paddingHorizontal: spacing.xl, diff --git a/src/screens/profile/AccountSecurityScreen.tsx b/src/screens/profile/AccountSecurityScreen.tsx index e65bcb7..1fb1e2f 100644 --- a/src/screens/profile/AccountSecurityScreen.tsx +++ b/src/screens/profile/AccountSecurityScreen.tsx @@ -7,7 +7,7 @@ import { ActivityIndicator, ScrollView, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { Text, ResponsiveContainer } from '../../components/common'; @@ -17,9 +17,13 @@ import { showPrompt } from '../../services/promptService'; import { useAuthStore } from '../../stores'; export const AccountSecurityScreen: React.FC = () => { - const { isWideScreen } = useResponsive(); + const { isWideScreen, isMobile } = useResponsive(); + const insets = useSafeAreaInsets(); const currentUser = useAuthStore((state) => state.currentUser); const fetchCurrentUser = useAuthStore((state) => state.fetchCurrentUser); + + // 底部间距,避免被 TabBar 遮挡 + const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md; const [email, setEmail] = useState(currentUser?.email || ''); const [verificationCode, setVerificationCode] = useState(''); @@ -330,10 +334,10 @@ export const AccountSecurityScreen: React.FC = () => { {isWideScreen ? ( - {content} + {content} ) : ( - {content} + {content} )} ); diff --git a/src/screens/profile/BlockedUsersScreen.tsx b/src/screens/profile/BlockedUsersScreen.tsx index 9ba463b..c91117b 100644 --- a/src/screens/profile/BlockedUsersScreen.tsx +++ b/src/screens/profile/BlockedUsersScreen.tsx @@ -8,7 +8,7 @@ import { View, Alert, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { Avatar, Button, EmptyState, ResponsiveContainer, Text } from '../../components/common'; @@ -16,15 +16,21 @@ import { authService } from '../../services'; import { colors, spacing, borderRadius, shadows } from '../../theme'; import { User } from '../../types'; import { RootStackParamList } from '../../navigation/types'; +import { useResponsive } from '../../hooks'; type NavigationProp = NativeStackNavigationProp; export const BlockedUsersScreen: React.FC = () => { const navigation = useNavigation(); + const { isMobile } = useResponsive(); + const insets = useSafeAreaInsets(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [processingUserId, setProcessingUserId] = useState(null); + + // 底部间距,避免被 TabBar 遮挡 + const listBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md; const loadBlockedUsers = useCallback(async () => { try { @@ -115,7 +121,7 @@ export const BlockedUsersScreen: React.FC = () => { data={users} keyExtractor={item => item.id} renderItem={renderItem} - contentContainerStyle={[styles.listContent, users.length === 0 && styles.emptyContent]} + contentContainerStyle={[styles.listContent, users.length === 0 && styles.emptyContent, { paddingBottom: listBottomInset }]} refreshControl={ { const [uploadingAvatar, setUploadingAvatar] = useState(false); const [uploadingCover, setUploadingCover] = useState(false); const [saving, setSaving] = useState(false); - const bottomSafeDistance = isMobile ? insets.bottom + 92 : spacing.xl; + // 底部间距,避免被 TabBar 遮挡 + const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.lg; // 根据屏幕宽度计算封面高度 const coverHeight = isWideScreen ? Math.min((width * 9) / 16, 300) : (width * 9) / 16; @@ -471,7 +472,7 @@ export const EditProfileScreen: React.FC = () => { {/* 保存按钮 */} - + { const [vibrationEnabled, setVibrationEnabledState] = useState(true); const [pushEnabled, setPushEnabled] = useState(true); const [soundEnabled, setSoundEnabled] = useState(true); - const { isWideScreen } = useResponsive(); + const { isWideScreen, isMobile } = useResponsive(); + const insets = useSafeAreaInsets(); + + // 底部间距,避免被 TabBar 遮挡 + const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md; // 加载设置 useEffect(() => { @@ -150,12 +154,12 @@ export const NotificationSettingsScreen: React.FC = () => { {isWideScreen ? ( - + {renderContent()} ) : ( - + {renderContent()} )} diff --git a/src/screens/profile/ProfileScreen.tsx b/src/screens/profile/ProfileScreen.tsx index c460d9b..165ebac 100644 --- a/src/screens/profile/ProfileScreen.tsx +++ b/src/screens/profile/ProfileScreen.tsx @@ -55,10 +55,12 @@ export const ProfileScreen: React.FC = () => { const { isDesktop, isTablet, width } = useResponsive(); // 页面滚动底部安全间距,避免内容被底部 TabBar 遮挡 + // TabBar 悬浮:高度 64 + 浮动间距 12*2 = 88 const scrollBottomInset = useMemo(() => { if (isDesktop) return spacing.lg; - return tabBarHeight + insets.bottom + spacing.md; - }, [isDesktop, tabBarHeight, insets.bottom]); + const FLOATING_TAB_BAR_HEIGHT = 64 + 24; // TabBar高度 + 上下浮动间距 + return FLOATING_TAB_BAR_HEIGHT + insets.bottom + spacing.md; + }, [isDesktop, insets.bottom]); const [activeTab, setActiveTab] = useState(0); const [posts, setPosts] = useState([]); diff --git a/src/screens/profile/SettingsScreen.tsx b/src/screens/profile/SettingsScreen.tsx index e61abc4..9f5c434 100644 --- a/src/screens/profile/SettingsScreen.tsx +++ b/src/screens/profile/SettingsScreen.tsx @@ -20,6 +20,7 @@ import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { useAuthStore } from '../../stores'; import { Text, ResponsiveContainer } from '../../components/common'; import { useResponsive } from '../../hooks'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; interface SettingsItem { key: string; @@ -67,6 +68,11 @@ export const SettingsScreen: React.FC = () => { const navigation = useNavigation(); const { logout } = useAuthStore(); const { isWideScreen } = useResponsive(); + const insets = useSafeAreaInsets(); + const { isMobile } = useResponsive(); + + // 底部间距,避免被 TabBar 遮挡 + const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md; // 处理设置项点击 const handleItemPress = (key: string) => { @@ -200,12 +206,12 @@ export const SettingsScreen: React.FC = () => { {isWideScreen ? ( - + {renderContent()} ) : ( - + {renderContent()} )} diff --git a/src/screens/schedule/ScheduleScreen.tsx b/src/screens/schedule/ScheduleScreen.tsx index 6db217c..327110f 100644 --- a/src/screens/schedule/ScheduleScreen.tsx +++ b/src/screens/schedule/ScheduleScreen.tsx @@ -19,7 +19,7 @@ import { } from 'react-native'; import { PanGestureHandler, State } from 'react-native-gesture-handler'; import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -50,8 +50,8 @@ const DEFAULT_SECTION_HEIGHT = 105; const WEEK_SELECTOR_HEIGHT = 44; // 星期标题行高度 const HEADER_HEIGHT = 40; -// 底部Tab栏高度(用于避让) -const TAB_BAR_HEIGHT = 80; +// 悬浮TabBar高度(TabBar高度64 + 浮动间距12*2 = 88) +const FLOATING_TAB_BAR_HEIGHT = 88; // 大节时间配置(左侧显示 1-6) const GROUPED_TIME_SLOTS: TimeSlot[] = [ @@ -150,6 +150,7 @@ export const ScheduleScreen: React.FC = () => { const navigation = useNavigation>(); // 使用响应式 hook 检测屏幕尺寸 const { width: screenWidth, height: screenHeight, isMobile, platform } = useResponsive(); + const insets = useSafeAreaInsets(); const isWeb = platform.isWeb; const [currentWeek, setCurrentWeek] = useState(INITIAL_WEEK); const [courses, setCourses] = useState([]); @@ -166,18 +167,19 @@ export const ScheduleScreen: React.FC = () => { }, [screenWidth, isWeb]); // 动态计算每节课的高度 - // 手机端:第六节课底部刚好跟底部导航栏持平(可用高度 = 屏幕高度 - 周选择器 - 星期标题 - 底部Tab栏) + // 手机端:第六节课底部刚好在悬浮TabBar上方(可用高度 = 屏幕高度 - 周选择器 - 星期标题 - 悬浮TabBar - 安全区域) // 电脑端:第六节课刚好填到底部(可用高度 = 屏幕高度 - 周选择器 - 星期标题) const sectionHeight = useMemo((): number => { const totalHeaderHeight = WEEK_SELECTOR_HEIGHT + HEADER_HEIGHT; - const bottomOffset = isMobile ? TAB_BAR_HEIGHT : 0; + // 悬浮TabBar高度 + 安全区域底部 + const bottomOffset = isMobile ? FLOATING_TAB_BAR_HEIGHT + insets.bottom : 0; // 可用高度 = 屏幕高度 - 顶部区域 - 底部偏移 const availableHeight = screenHeight - totalHeaderHeight - bottomOffset; // 6节课,每节课高度 = 可用高度 / 6 const calculatedHeight = Math.floor(availableHeight / 6); // 确保高度不低于默认值 return Math.max(DEFAULT_SECTION_HEIGHT, calculatedHeight); - }, [screenHeight, isMobile]); + }, [screenHeight, isMobile, insets.bottom]); const [isAddModalVisible, setIsAddModalVisible] = useState(false); const [pendingDay, setPendingDay] = useState(0); const [pendingMergedSection, setPendingMergedSection] = useState(1); @@ -718,7 +720,7 @@ export const ScheduleScreen: React.FC = () => { }; return ( - + {/* 周选择器 */} diff --git a/src/services/messageService.ts b/src/services/messageService.ts index 27a8722..4c15f1d 100644 --- a/src/services/messageService.ts +++ b/src/services/messageService.ts @@ -621,11 +621,46 @@ class MessageService { params: CursorPaginationRequest = {} ): Promise> { try { - const response = await api.get>( - '/messages/system/cursor', - { params } - ); - return response.data; + // 始终传递 cursor 参数(空字符串表示首次请求),确保使用游标分页 + const requestParams: Record = { + cursor: params.cursor || '', + page_size: params.page_size, + }; + + const response = await api.get('/messages/system', requestParams); + + const data = response.data; + + // 兼容两种 API 响应格式 + if (data && typeof data === 'object') { + // 游标分页格式 + if (Array.isArray(data.items)) { + return { + items: data.items, + next_cursor: data.next_cursor ?? null, + prev_cursor: data.prev_cursor ?? null, + has_more: data.has_more ?? false, + }; + } + // 传统分页格式 - 转换为游标格式 + if (Array.isArray(data.list)) { + const currentPage = data.page || 1; + const totalPages = data.total_pages || 1; + return { + items: data.list, + next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, + prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, + has_more: currentPage < totalPages, + }; + } + } + + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; } catch (error) { console.error('获取系统消息列表失败:', error); return { diff --git a/src/services/postService.ts b/src/services/postService.ts index 72afa79..4f81844 100644 --- a/src/services/postService.ts +++ b/src/services/postService.ts @@ -294,12 +294,57 @@ class PostService { params: CursorPaginationRequest = {} ): Promise> { try { - const response = await api.get>('/posts', { - cursor: params.cursor, - page_size: params.page_size, - post_type: params.post_type, - }); - return response.data; + // 构建请求参数 + // 后端使用 `tab` 参数区分帖子类型,使用 `cursor` 参数启用游标分页 + // 始终传递 cursor 参数(空字符串表示首次请求),确保使用游标分页 + const requestParams: Record = { + cursor: params.cursor || '', // 空字符串表示首次请求 + page_size: params.page_size, + }; + + // 将 post_type 映射为后端的 tab 参数 + if (params.post_type) { + requestParams.tab = params.post_type; + } + + const response = await api.get('/posts', requestParams); + + const data = response.data; + + // 兼容两种 API 响应格式: + // 1. 游标分页格式:{ items, next_cursor, prev_cursor, has_more } + // 2. 传统分页格式:{ list, total, page, page_size, total_pages } + if (data && typeof data === 'object') { + // 游标分页格式 + if (Array.isArray(data.items)) { + return { + items: data.items, + next_cursor: data.next_cursor ?? null, + prev_cursor: data.prev_cursor ?? null, + has_more: data.has_more ?? false, + }; + } + // 传统分页格式 - 转换为游标格式 + if (Array.isArray(data.list)) { + const currentPage = data.page || 1; + const totalPages = data.total_pages || 1; + return { + items: data.list, + // 使用页码作为游标 + next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, + prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, + has_more: currentPage < totalPages, + }; + } + } + + // 默认返回空数据 + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; } catch (error) { console.error('获取帖子列表失败:', error); return { @@ -322,11 +367,41 @@ class PostService { params: CursorPaginationRequest = {} ): Promise> { try { - const response = await api.get>('/posts/search', { + const response = await api.get('/posts/search', { ...params, keyword: query, }); - return response.data; + + const data = response.data; + + // 兼容两种 API 响应格式 + if (data && typeof data === 'object') { + if (Array.isArray(data.items)) { + return { + items: data.items, + next_cursor: data.next_cursor ?? null, + prev_cursor: data.prev_cursor ?? null, + has_more: data.has_more ?? false, + }; + } + if (Array.isArray(data.list)) { + const currentPage = data.page || 1; + const totalPages = data.total_pages || 1; + return { + items: data.list, + next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, + prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, + has_more: currentPage < totalPages, + }; + } + } + + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; } catch (error) { console.error('搜索帖子失败:', error); return { @@ -349,11 +424,41 @@ class PostService { params: CursorPaginationRequest = {} ): Promise> { try { - const response = await api.get>( + const response = await api.get( `/users/${userId}/posts`, params ); - return response.data; + + const data = response.data; + + // 兼容两种 API 响应格式 + if (data && typeof data === 'object') { + if (Array.isArray(data.items)) { + return { + items: data.items, + next_cursor: data.next_cursor ?? null, + prev_cursor: data.prev_cursor ?? null, + has_more: data.has_more ?? false, + }; + } + if (Array.isArray(data.list)) { + const currentPage = data.page || 1; + const totalPages = data.total_pages || 1; + return { + items: data.list, + next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, + prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, + has_more: currentPage < totalPages, + }; + } + } + + return { + items: [], + next_cursor: null, + prev_cursor: null, + has_more: false, + }; } catch (error) { console.error('获取用户帖子列表失败:', error); return { From 37ddfa8ed6c4e32c794e8d3236db7aa31ca4c741 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Sat, 21 Mar 2026 12:08:45 +0800 Subject: [PATCH 08/36] feat(ui): redesign system message item with card-style layout and add title validation - Refactor SystemMessageItem with modern card design, gradient colors, and improved visual hierarchy - Add unread indicator, status badges, and enhanced avatar badges for message types - Update CreatePostScreen to require title input and improve placeholder text - Add Android back button handling for notification screens in embedded mode - Replace manual shadow styles with theme shadow utilities across message screens --- src/components/business/SystemMessageItem.tsx | 439 +++++++++++------- src/screens/create/CreatePostScreen.tsx | 13 +- src/screens/message/MessageListScreen.tsx | 14 + src/screens/message/NotificationsScreen.tsx | 33 +- 4 files changed, 326 insertions(+), 173 deletions(-) diff --git a/src/components/business/SystemMessageItem.tsx b/src/components/business/SystemMessageItem.tsx index 4d8109b..b10b238 100644 --- a/src/components/business/SystemMessageItem.tsx +++ b/src/components/business/SystemMessageItem.tsx @@ -1,15 +1,15 @@ /** - * SystemMessageItem 系统消息项组件 + * SystemMessageItem 系统消息项组件 - 美化版 * 根据系统消息类型显示不同图标和内容 - * 参考 QQ 10000 系统消息和微信服务通知样式 + * 采用卡片式设计,符合胡萝卜BBS整体风格 */ import React from 'react'; -import { View, TouchableOpacity, StyleSheet } from 'react-native'; +import { View, TouchableOpacity, StyleSheet, Animated } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { formatDistanceToNow } from 'date-fns'; import { zhCN } from 'date-fns/locale'; -import { colors, spacing, borderRadius } from '../../theme'; +import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme'; import { SystemMessageResponse, SystemMessageType } from '../../types/dto'; import Text from '../common/Text'; import Avatar from '../common/Avatar'; @@ -17,15 +17,16 @@ import Avatar from '../common/Avatar'; interface SystemMessageItemProps { message: SystemMessageResponse; onPress?: () => void; - onAvatarPress?: () => void; // 头像点击回调 + onAvatarPress?: () => void; onRequestAction?: (approve: boolean) => void; requestActionLoading?: boolean; + index?: number; // 用于交错动画 } // 系统消息类型到图标和颜色的映射 const getSystemMessageIcon = ( systemType: SystemMessageType -): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string } => { +): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string; gradient: string[] } => { switch (systemType) { case 'like_post': case 'like_comment': @@ -33,75 +34,87 @@ const getSystemMessageIcon = ( case 'favorite_post': return { icon: 'heart', - color: colors.error.main, - bgColor: colors.error.light + '20', + color: '#FF4757', + bgColor: '#FFE4E6', + gradient: ['#FF4757', '#FF6B7A'], }; case 'comment': return { - icon: 'comment', - color: colors.info.main, - bgColor: colors.info.light + '20', + icon: 'comment-text', + color: '#2196F3', + bgColor: '#E3F2FD', + gradient: ['#2196F3', '#64B5F6'], }; case 'reply': return { icon: 'reply', - color: colors.success.main, - bgColor: colors.success.light + '20', + color: '#4CAF50', + bgColor: '#E8F5E9', + gradient: ['#4CAF50', '#81C784'], }; case 'follow': return { icon: 'account-plus', - color: '#9C27B0', // 紫色 - bgColor: '#9C27B020', + color: '#9C27B0', + bgColor: '#F3E5F5', + gradient: ['#9C27B0', '#BA68C8'], }; case 'mention': return { icon: 'at', - color: colors.warning.main, - bgColor: colors.warning.light + '20', + color: '#FF9800', + bgColor: '#FFF3E0', + gradient: ['#FF9800', '#FFB74D'], }; case 'system': return { icon: 'cog', - color: colors.text.secondary, - bgColor: colors.background.disabled, + color: '#607D8B', + bgColor: '#ECEFF1', + gradient: ['#607D8B', '#90A4AE'], }; case 'announcement': case 'announce': return { icon: 'bullhorn', color: colors.primary.main, - bgColor: colors.primary.light + '20', + bgColor: colors.primary.light + '30', + gradient: [colors.primary.main, colors.primary.light], }; case 'group_invite': return { icon: 'account-multiple-plus', - color: colors.primary.main, - bgColor: colors.primary.light + '20', + color: '#00BCD4', + bgColor: '#E0F7FA', + gradient: ['#00BCD4', '#4DD0E1'], }; case 'group_join_apply': return { icon: 'account-clock', - color: colors.warning.main, - bgColor: colors.warning.light + '20', + color: '#FF9800', + bgColor: '#FFF3E0', + gradient: ['#FF9800', '#FFB74D'], }; case 'group_join_approved': return { icon: 'check-circle', - color: colors.success.main, - bgColor: colors.success.light + '20', + color: '#4CAF50', + bgColor: '#E8F5E9', + gradient: ['#4CAF50', '#81C784'], }; case 'group_join_rejected': return { icon: 'close-circle', - color: colors.error.main, - bgColor: colors.error.light + '20', + color: '#F44336', + bgColor: '#FFEBEE', + gradient: ['#F44336', '#E57373'], }; default: return { icon: 'bell', - color: colors.text.secondary, - bgColor: colors.background.disabled, + color: '#757575', + bgColor: '#F5F5F5', + gradient: ['#757575', '#9E9E9E'], }; } }; @@ -109,7 +122,6 @@ const getSystemMessageIcon = ( // 获取系统消息标题 const getSystemMessageTitle = (message: SystemMessageResponse): string => { const { system_type, extra_data } = message; - // 兼容后端返回的 actor_name 和 operator_name const operatorName = extra_data?.actor_name || extra_data?.operator_name; const groupName = extra_data?.group_name || extra_data?.target_title; @@ -163,6 +175,7 @@ const SystemMessageItem: React.FC = ({ onAvatarPress, onRequestAction, requestActionLoading = false, + index = 0, }) => { // 格式化时间 const formatTime = (dateString: string): string => { @@ -179,7 +192,6 @@ const SystemMessageItem: React.FC = ({ const { icon, color, bgColor } = getSystemMessageIcon(message.system_type); const title = getSystemMessageTitle(message); const { extra_data } = message; - // 兼容后端返回的 actor_name 和 operator_name const operatorName = extra_data?.actor_name || extra_data?.operator_name; const operatorAvatar = extra_data?.avatar_url || extra_data?.operator_avatar; const groupAvatar = extra_data?.group_avatar; @@ -188,99 +200,7 @@ const SystemMessageItem: React.FC = ({ (message.system_type === 'group_invite' || message.system_type === 'group_join_apply') && requestStatus === 'pending' && !!onRequestAction; - - // 使用固定的响应式值,兼容移动端和Web端 - const containerPadding = spacing.md; - const avatarSize = 44; - const iconSize = 22; - const contentFontSize = 14; - const titleFontSize = 14; - const timeFontSize = 12; - - const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'flex-start', - padding: containerPadding, - backgroundColor: colors.background.paper, - // 移除底部边框,使用卡片式设计 - marginBottom: 1, - }, - iconContainer: { - marginRight: spacing.md, - }, - iconWrapper: { - width: avatarSize, - height: avatarSize, - borderRadius: avatarSize / 2, - justifyContent: 'center', - alignItems: 'center', - }, - content: { - flex: 1, - justifyContent: 'center', - minWidth: 0, - }, - titleRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: spacing.xs, - }, - title: { - fontWeight: '600', - flex: 1, - marginRight: spacing.sm, - fontSize: titleFontSize, - }, - time: { - fontSize: timeFontSize, - }, - messageContent: { - lineHeight: 20, - fontSize: contentFontSize, - }, - extraInfo: { - flexDirection: 'row', - alignItems: 'center', - marginTop: spacing.xs, - backgroundColor: colors.background.default, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - borderRadius: borderRadius.sm, - alignSelf: 'flex-start', - }, - extraText: { - marginLeft: spacing.xs, - flex: 1, - }, - actionsRow: { - flexDirection: 'row', - marginTop: spacing.sm, - }, - actionBtn: { - paddingVertical: spacing.xs, - paddingHorizontal: spacing.md, - borderRadius: borderRadius.sm, - borderWidth: 1, - marginRight: spacing.sm, - }, - rejectBtn: { - borderColor: colors.error.light, - backgroundColor: colors.error.light + '18', - }, - approveBtn: { - borderColor: colors.success.light, - backgroundColor: colors.success.light + '18', - }, - arrowContainer: { - marginLeft: spacing.sm, - justifyContent: 'center', - alignItems: 'center', - height: 20, - width: 24, - }, - }); + const isUnread = message.is_read !== true; // 判断是否显示操作者头像 const showOperatorAvatar = ['follow', 'like_post', 'like_comment', 'like_reply', 'favorite_post', 'comment', 'reply', 'mention', 'group_join_apply'].includes( @@ -288,31 +208,68 @@ const SystemMessageItem: React.FC = ({ ); const showGroupAvatar = message.system_type === 'group_invite'; + // 获取状态标签 + const getStatusBadge = () => { + if (requestStatus === 'accepted') { + return ( + + + 已同意 + + ); + } + if (requestStatus === 'rejected') { + return ( + + + 已拒绝 + + ); + } + return null; + }; + return ( + {/* 左侧未读指示器 */} + {isUnread && } + {/* 图标/头像区域 */} {showGroupAvatar ? ( - + + + + + + ) : showOperatorAvatar ? ( - + + + + + + ) : ( - + )} @@ -321,36 +278,49 @@ const SystemMessageItem: React.FC = ({ {/* 标题行 */} - - {title} - - + + + {title} + + {getStatusBadge()} + + {formatTime(message.created_at)} {/* 消息内容 */} - + {message.content} - {/* 附加信息 - 优先显示 target_title,图标根据 target_type 区分 */} + {/* 附加信息 */} {(extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview) && !['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected'].includes(message.system_type) && (() => { - const isCommentType = ['comment', 'reply'].includes(extra_data?.target_type ?? ''); - const previewText = extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview; - const iconName = isCommentType ? 'comment-outline' : 'file-document-outline'; - return ( - - - - {previewText} - - - ); - })()} + const isCommentType = ['comment', 'reply'].includes(extra_data?.target_type ?? ''); + const previewText = extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview; + const iconName = isCommentType ? 'comment-text-outline' : 'file-document-outline'; + return ( + + + + {previewText} + + + ); + })()} + {/* 操作按钮 */} {isActionable && ( = ({ onPress={() => onRequestAction?.(false)} disabled={requestActionLoading} > - 拒绝 + + 拒绝 onRequestAction?.(true)} disabled={requestActionLoading} > - 同意 + + 同意 )} - {/* 右侧箭头(如果有跳转) */} + {/* 右侧箭头 */} {onPress && ( @@ -381,4 +353,151 @@ const SystemMessageItem: React.FC = ({ ); }; +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'flex-start', + padding: spacing.md, + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + marginHorizontal: spacing.md, + marginBottom: spacing.sm, + ...shadows.sm, + }, + unreadContainer: { + backgroundColor: colors.primary.light + '08', + borderLeftWidth: 3, + borderLeftColor: colors.primary.main, + }, + unreadIndicator: { + position: 'absolute', + left: 6, + top: '50%', + marginTop: -4, + width: 8, + height: 8, + borderRadius: borderRadius.full, + backgroundColor: colors.primary.main, + }, + iconContainer: { + marginRight: spacing.md, + }, + avatarWrapper: { + position: 'relative', + }, + iconWrapper: { + width: 48, + height: 48, + borderRadius: borderRadius.lg, + justifyContent: 'center', + alignItems: 'center', + }, + typeIconBadge: { + position: 'absolute', + bottom: -2, + right: -2, + width: 20, + height: 20, + borderRadius: borderRadius.full, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: colors.background.paper, + }, + content: { + flex: 1, + justifyContent: 'center', + minWidth: 0, + }, + titleRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.xs, + }, + titleLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + marginRight: spacing.sm, + }, + title: { + fontWeight: '500', + fontSize: fontSizes.md, + color: colors.text.primary, + }, + unreadTitle: { + fontWeight: '600', + color: colors.text.primary, + }, + statusBadge: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.xs, + paddingVertical: 2, + borderRadius: borderRadius.sm, + marginLeft: spacing.xs, + }, + statusText: { + fontSize: 10, + fontWeight: '500', + marginLeft: 2, + }, + time: { + fontSize: fontSizes.sm, + flexShrink: 0, + }, + messageContent: { + lineHeight: 20, + fontSize: fontSizes.sm, + }, + extraInfo: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.xs, + backgroundColor: colors.primary.light + '12', + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: borderRadius.md, + alignSelf: 'flex-start', + }, + extraText: { + marginLeft: spacing.xs, + flex: 1, + fontWeight: '500', + }, + actionsRow: { + flexDirection: 'row', + marginTop: spacing.sm, + gap: spacing.sm, + }, + actionBtn: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.xs, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.md, + borderWidth: 1, + gap: spacing.xs, + }, + rejectBtn: { + borderColor: '#FFCDD2', + backgroundColor: '#FFEBEE', + }, + approveBtn: { + borderColor: '#C8E6C9', + backgroundColor: '#E8F5E9', + }, + actionBtnText: { + fontWeight: '500', + }, + arrowContainer: { + marginLeft: spacing.sm, + justifyContent: 'center', + alignItems: 'center', + height: 20, + width: 20, + }, +}); + export default SystemMessageItem; diff --git a/src/screens/create/CreatePostScreen.tsx b/src/screens/create/CreatePostScreen.tsx index f622470..17d9297 100644 --- a/src/screens/create/CreatePostScreen.tsx +++ b/src/screens/create/CreatePostScreen.tsx @@ -337,6 +337,11 @@ export const CreatePostScreen: React.FC = (props) => { // 防止重复点击 if (posting) return; + if (!title.trim()) { + Alert.alert('错误', '请输入帖子标题'); + return; + } + if (!content.trim()) { Alert.alert('错误', '请输入帖子内容'); return; @@ -365,7 +370,7 @@ export const CreatePostScreen: React.FC = (props) => { // 创建投票帖子 await voteService.createVotePost({ - title: title.trim() || '无标题', + title: title.trim(), content: content.trim(), images: imageUrls, vote_options: validOptions, @@ -383,7 +388,7 @@ export const CreatePostScreen: React.FC = (props) => { } else { if (isEditMode && editPostID) { const updated = await postService.updatePost(editPostID, { - title: title.trim() || '无标题', + title: title.trim(), content: content.trim(), images: imageUrls, }); @@ -403,7 +408,7 @@ export const CreatePostScreen: React.FC = (props) => { } else { // 创建普通帖子 await postService.createPost({ - title: title.trim() || '无标题', + title: title.trim(), content: content.trim(), images: imageUrls, }); @@ -562,7 +567,7 @@ export const CreatePostScreen: React.FC = (props) => { ]} value={title} onChangeText={setTitle} - placeholder="添加标题(可选)" + placeholder="请输入标题(必填)" placeholderTextColor={colors.text.hint} maxLength={MAX_TITLE_LENGTH} /> diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index a3a4d29..84def36 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -22,6 +22,7 @@ import { Animated, TextInput, Keyboard, + BackHandler, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useIsFocused } from '@react-navigation/native'; @@ -283,6 +284,19 @@ export const MessageListScreen: React.FC = () => { } }, [isFocused]); + // 处理 Android 物理返回键 - 当显示通知页面时,返回键关闭通知页面 + useEffect(() => { + const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { + if (showNotifications) { + setShowNotifications(false); + return true; // 阻止默认行为 + } + return false; + }); + + return () => backHandler.remove(); + }, [showNotifications]); + // 下拉刷新 const onRefresh = useCallback(async () => { setRefreshing(true); diff --git a/src/screens/message/NotificationsScreen.tsx b/src/screens/message/NotificationsScreen.tsx index 028c566..d8ef14b 100644 --- a/src/screens/message/NotificationsScreen.tsx +++ b/src/screens/message/NotificationsScreen.tsx @@ -14,13 +14,14 @@ import { RefreshControl, ActivityIndicator, Dimensions, + BackHandler, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useIsFocused } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing, borderRadius } from '../../theme'; +import { colors, spacing, borderRadius, shadows } from '../../theme'; import { SystemMessageResponse } from '../../types/dto'; import { messageService } from '../../services/messageService'; import { commentService } from '../../services/commentService'; @@ -182,6 +183,21 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack } }, [isFocused, onBack]); + // 处理 Android 物理返回键 - 当 onBack 存在时(内嵌模式),拦截返回键 + useEffect(() => { + if (!onBack) return; + + const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { + if (isFocused) { + onBack(); + return true; // 阻止默认行为 + } + return false; + }); + + return () => backHandler.remove(); + }, [isFocused, onBack]); + // 筛选消息 const filteredMessages = activeType === 'all' ? displayMessages @@ -558,12 +574,7 @@ const styles = StyleSheet.create({ paddingHorizontal: spacing.lg, paddingVertical: spacing.md, backgroundColor: colors.background.paper, - // 移除底部边框,使用更柔和的分隔方式 - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.05, - shadowRadius: 2, - elevation: 2, + ...shadows.sm, }, headerWide: { paddingHorizontal: spacing.xl, @@ -613,7 +624,6 @@ const styles = StyleSheet.create({ paddingHorizontal: spacing.md, paddingVertical: spacing.sm, backgroundColor: colors.background.paper, - // 移除底部边框,让界面更简洁 }, filterContainerWideWeb: { paddingHorizontal: spacing.xl, @@ -629,6 +639,8 @@ const styles = StyleSheet.create({ marginRight: spacing.xs, borderRadius: borderRadius.lg, backgroundColor: colors.background.default, + borderWidth: 1, + borderColor: 'transparent', }, filterTagWide: { paddingHorizontal: spacing.lg, @@ -637,6 +649,7 @@ const styles = StyleSheet.create({ }, filterTagActive: { backgroundColor: colors.primary.main, + borderColor: colors.primary.main, }, filterTagTextActive: { fontWeight: '600', @@ -655,7 +668,8 @@ const styles = StyleSheet.create({ }, listContent: { flexGrow: 1, - paddingTop: spacing.sm, + paddingTop: spacing.md, + paddingBottom: spacing.lg, }, listContentWide: { paddingHorizontal: spacing.xl, @@ -679,6 +693,7 @@ const styles = StyleSheet.create({ flex: 1, justifyContent: 'center', alignItems: 'center', + paddingTop: spacing['3xl'], }, emptyContainerWeb: { minHeight: 500, From 25071d23039532ab464abab4759a673af8d947bd Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Sat, 21 Mar 2026 14:15:41 +0800 Subject: [PATCH 09/36] =?UTF-8?q?ci:=20=E6=B7=BB=E5=8A=A0Gradle=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E3=80=81node=5Fmodules=E7=BC=93=E5=AD=98=E5=92=8CDock?= =?UTF-8?q?er=E6=9E=84=E5=BB=BA=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/build.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index a52ab8e..33ba30e 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -104,6 +104,7 @@ jobs: NODE_ENV: "production" NDK_NUM_JOBS: "4" CMAKE_BUILD_PARALLEL_LEVEL: "4" + GRADLE_USER_HOME: /root/.gradle permissions: contents: read steps: @@ -122,7 +123,28 @@ jobs: node-version: '25.6.1' cache: 'npm' + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + ~/.android/build-cache + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Cache node_modules + uses: actions/cache@v4 + id: cache-node-modules + with: + path: node_modules + key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-modules- + - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' run: npm ci - name: Generate Android native project @@ -292,6 +314,8 @@ jobs: labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 provenance: false + cache-from: type=gha + cache-to: type=gha,mode=max - name: Show image tags run: | From d273569911924a00e5704878421a07b14c12b6be Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Sat, 21 Mar 2026 20:55:36 +0800 Subject: [PATCH 10/36] refactor: migrate post operations to use case architecture with differential updates Migrate post-related operations from direct service/store calls to a new use case architecture featuring differential updates. Key changes include: (1) introduce ProcessPostUseCase to handle like, unlike, favorite, unfavorite, delete, and fetch operations with proper state management, (2) add useDifferentialPosts hook for efficient list rendering with minimal re-renders, (3) add PostDiffCalculator and PostUpdateBatcher in infrastructure layer for incremental updates, (4) deprecate NotificationItem component in favor of SystemMessageItem, (5) remove deprecated interactive methods from userStore, (6) add posts_cache table to local datasource, (7) clean up legacy MessageDTO and ConversationDTO types, (8) remove deprecated hook useBreakpointCheck, (9) refactor MessageBubble to use measureInWindow directly for better React Native compatibility --- plans/architecture-comparison-report.md | 394 ++++++++ src/components/business/NotificationItem.tsx | 147 --- src/components/business/index.ts | 1 - src/core/entities/Post.ts | 267 ++++++ src/core/usecases/ProcessPostUseCase.ts | 863 ++++++++++++++++++ src/data/datasources/LocalDataSource.ts | 6 + src/data/repositories/PostRepository.ts | 550 +++++++++++ .../interfaces/IPostRepository.ts | 190 ++++ src/hooks/index.ts | 16 + src/hooks/useDifferentialPosts.ts | 635 +++++++++++++ src/infrastructure/diff/PostDiffCalculator.ts | 430 +++++++++ src/infrastructure/diff/PostUpdateBatcher.ts | 505 ++++++++++ src/infrastructure/diff/index.ts | 46 +- src/infrastructure/diff/postTypes.ts | 325 +++++++ .../hooks/responsive/useBreakpointCheck.ts | 7 - src/screens/home/HomeScreen.tsx | 137 ++- src/screens/home/PostDetailScreen.tsx | 63 +- src/screens/message/MessageListScreen.tsx | 2 +- src/screens/message/NotificationsScreen.tsx | 10 +- .../components/ChatScreen/MessageBubble.tsx | 29 +- .../message/components/ChatScreen/types.ts | 2 - src/screens/profile/ProfileScreen.tsx | 12 +- src/screens/profile/UserScreen.tsx | 10 +- src/services/groupService.ts | 11 - src/services/messageService.ts | 26 - src/services/postService.ts | 42 +- src/stores/messageManagerHooks.ts | 5 +- src/stores/userStore.ts | 177 +--- src/types/dto.ts | 31 - src/types/index.ts | 28 - 30 files changed, 4395 insertions(+), 572 deletions(-) create mode 100644 plans/architecture-comparison-report.md delete mode 100644 src/components/business/NotificationItem.tsx create mode 100644 src/core/entities/Post.ts create mode 100644 src/core/usecases/ProcessPostUseCase.ts create mode 100644 src/data/repositories/PostRepository.ts create mode 100644 src/data/repositories/interfaces/IPostRepository.ts create mode 100644 src/hooks/useDifferentialPosts.ts create mode 100644 src/infrastructure/diff/PostDiffCalculator.ts create mode 100644 src/infrastructure/diff/PostUpdateBatcher.ts create mode 100644 src/infrastructure/diff/postTypes.ts delete mode 100644 src/presentation/hooks/responsive/useBreakpointCheck.ts diff --git a/plans/architecture-comparison-report.md b/plans/architecture-comparison-report.md new file mode 100644 index 0000000..16db566 --- /dev/null +++ b/plans/architecture-comparison-report.md @@ -0,0 +1,394 @@ +# Messages模块与PostService/Manager架构对比分析报告 + +## 一、Messages模块架构分析 + +### 1.1 架构层次结构 + +Messages模块采用了**清晰的分层架构**,各层职责明确: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 表现层 (Screens) │ +│ ChatScreen, MessageListScreen, NotificationsScreen │ +├─────────────────────────────────────────────────────────────┤ +│ Hooks层 │ +│ useDifferentialMessages, useChatScreen │ +├─────────────────────────────────────────────────────────────┤ +│ 用例层 (UseCases) │ +│ ProcessMessageUseCase │ +├─────────────────────────────────────────────────────────────┤ +│ 领域层 (Entities) │ +│ Message, Conversation, GroupNotice │ +├─────────────────────────────────────────────────────────────┤ +│ 仓库层 (Repositories) │ +│ MessageRepository, IMessageRepository │ +├─────────────────────────────────────────────────────────────┤ +│ 数据源层 (DataSources) │ +│ SSEClient, LocalDataSource, ApiDataSource │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 1.2 核心组件详解 + +#### 1.2.1 领域实体层 (Core/Entities) +- **Message.ts**: 定义消息、会话、群通知等核心领域模型 +- 包含工厂函数:`createMessage`, `createConversation` +- 包含业务逻辑函数:`isMessageFromCurrentUser`, `shouldIncrementUnread`, `buildTextContent` + +#### 1.2.2 用例层 (Core/UseCases) +- **ProcessMessageUseCase**: 核心业务逻辑编排器 + - 订阅SSE事件(chat, group_message, read, recall, typing, group_notice等) + - 消息去重处理(processedMessageIds Set) + - 已读状态保护(pendingReadMap + 版本号机制) + - 用户信息缓存与去重请求(pendingUserRequests Map) + - 事件发布订阅模式(subscribers Set) + +#### 1.2.3 仓库层 (Data/Repositories) +- **IMessageRepository**: 定义数据访问接口 + - 消息操作:getMessages, saveMessage, updateMessageStatus, markAsRead + - 会话操作:getConversations, saveConversation, updateUnreadCount + - 同步操作:syncMessages, getServerLastSeq + +- **MessageRepository**: 实现接口 + - 封装SQLite数据库操作 + - 消息与CachedMessage的转换 + +#### 1.2.4 映射器层 (Data/Mappers) +- **MessageMapper**: 数据转换 + - `fromApiResponse`: API响应 → 应用模型 + - `fromDbRecord`: 数据库记录 → 应用模型 + - `toDbRecord`: 应用模型 → 数据库记录 + - `toApiRequest`: 应用模型 → API请求 + - 创建消息片段:`createTextSegment`, `createImageSegment` + +#### 1.2.5 差异计算基础设施 (Infrastructure/Diff) +- **MessageDiffCalculator**: 消息列表差异计算 + - 计算added, updated, deleted, moved, unchanged + - 变化比例检测(changeRatio > 0.5 时触发重置) + - 增量差异计算(基于缓存) + +- **MessageUpdateBatcher**: 批量更新处理器 + - 16ms批量间隔(约60fps) + - 去重和合并更新 + - 100ms最大等待时间 + - 统计信息:totalBatches, totalUpdates, averageBatchSize + +- **types.ts**: 完整的类型定义 + - MessageUpdateType枚举:ADD, UPDATE, DELETE, MOVE, BATCH_* + - DiffResult, DiffConfig等接口 + +#### 1.2.6 Hook层 +- **useDifferentialMessages**: 差异更新Hook + - 集成DiffCalculator和Batcher + - 自动处理批量更新 + - 提供flush, reset, forceUpdate方法 + - 支持配置:enableDiff, enableBatching, maxMessageCount, changeRatioThreshold + +### 1.3 数据流 + +``` +SSE事件 → ProcessMessageUseCase → 事件发布 → useChatScreen → useDifferentialMessages + ↓ ↓ + MessageRepository 差异计算 + 批量处理 + ↓ ↓ + SQLite数据库 优化后的消息列表 +``` + +### 1.4 状态管理方式 + +- **发布-订阅模式**:ProcessMessageUseCase作为事件中心 +- **本地SQLite缓存**:消息持久化存储 +- **内存状态**:通过Hook返回,组件直接消费 +- **差异更新**:通过MessageDiffCalculator + MessageUpdateBatcher减少重渲染 + +--- + +## 二、PostService/Manager架构分析 + +### 2.1 架构层次结构 + +Post模块采用了**扁平化架构**,Service和Manager层职责混合: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 表现层 (Screens) │ +│ HomeScreen, PostDetailScreen, CreatePostScreen │ +├─────────────────────────────────────────────────────────────┤ +│ Hooks层 │ +│ useCursorPagination (通用), usePrefetch │ +├─────────────────────────────────────────────────────────────┤ +│ Store层 (Zustand) │ +│ useUserStore (直接操作状态), postManager (缓存管理) │ +├─────────────────────────────────────────────────────────────┤ +│ Service层 │ +│ postService (API调用 + 状态更新) │ +├─────────────────────────────────────────────────────────────┤ +│ 数据源 │ +│ API (直接调用) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 核心组件详解 + +#### 2.2.1 Service层 (Services) +- **postService.ts**: 帖子服务 + - CRUD操作:getPosts, getPost, createPost, updatePost, deletePost + - 互动操作:likePost, unlikePost, favoritePost, unfavoritePost + - 分页支持:传统分页 + 游标分页 + - **问题**:直接操作useUserStore(违反分层原则) + +#### 2.2.2 Manager层 (Stores) +- **postManager.ts**: 帖子缓存管理器 + - 继承CacheBus(发布订阅基类) + - 内存缓存:listCache, detailCache + - 请求去重:pendingRequests Map + - TTL管理:LIST_TTL=30s, DETAIL_TTL=60s + - 后台刷新机制 + +#### 2.2.3 Store层 (Zustand) +- **useUserStore**: 用户状态存储 + - 直接存储posts数组 + - 帖子操作副作用直接修改状态 + - 混合了用户数据和帖子数据 + +#### 2.2.4 映射器层 (Data/Mappers) +- **PostMapper**: 数据转换 + - `fromApiResponse`: API响应 → 应用模型 + - `fromApiResponseList`: 批量转换 + - `toApiRequest`: 应用模型 → API请求 + - 相比MessageMapper缺少数据库记录转换方法 + +### 2.3 数据流 + +``` +HomeScreen → useCursorPagination → postService.getPostsCursor + ↓ + useUserStore.setState(posts) + ↓ + PostCard组件渲染 +``` + +### 2.4 状态管理方式 + +- **Zustand Store**:集中式状态管理 +- **内存缓存**:postManager提供应用级缓存 +- **请求去重**:dedupe方法防止重复请求 +- **直接修改**:postService直接调用useUserStore.setState + +--- + +## 三、主要架构差异 + +### 3.1 分层复杂度 + +| 维度 | Messages模块 | Post模块 | +|------|-------------|---------| +| 层次深度 | 6层(Entity→UseCase→Repository→Mapper→Hook→Screen) | 4层(Service→Store→Hook→Screen) | +| 用例层 | 独立ProcessMessageUseCase | 无 | +| 仓库接口 | IMessageRepository抽象接口 | 无 | +| 基础设施 | Diff模块(Calculator + Batcher) | 无 | + +### 3.2 数据持久化 + +| 维度 | Messages模块 | Post模块 | +|------|-------------|---------| +| 本地存储 | SQLite数据库 | 无 | +| 缓存抽象 | MessageRepository封装 | postManager内存缓存 | +| 离线支持 | 完整 | 不支持 | + +### 3.3 实时更新 + +| 维度 | Messages模块 | Post模块 | +|------|-------------|---------| +| 推送机制 | SSE实时推送 | 轮询/下拉刷新 | +| 事件订阅 | ProcessMessageUseCase发布订阅 | 无 | +| 增量更新 | MessageDiffCalculator | 无 | + +### 3.4 状态管理 + +| 维度 | Messages模块 | Post模块 | +|------|-------------|---------| +| 管理方式 | 发布订阅 + Hook局部状态 | Zustand全局Store | +| 状态来源 | useChatScreen, useDifferentialMessages | useUserStore | +| 批量更新 | MessageUpdateBatcher | 无 | +| 差异检测 | MessageDiffCalculator | 无 | + +### 3.5 依赖方向 + +**Messages模块(正确)**: +``` +Hook → UseCase → Repository → DataSource + ↓ + Entity(不依赖外部) +``` + +**Post模块(问题)**: +``` +Service → Store(循环依赖风险) + ↓ +API直接调用 +``` + +### 3.6 核心问题总结 + +| # | 问题 | Messages | Post | +|---|------|---------|------| +| 1 | 违反分层原则 | 无 | postService直接操作useUserStore | +| 2 | 缺少UseCase层 | ProcessMessageUseCase | 无业务逻辑编排 | +| 3 | 缺少Repository抽象 | IMessageRepository | 无数据访问接口 | +| 4 | 无差异更新 | useDifferentialMessages | 无 | +| 5 | 无批量处理 | MessageUpdateBatcher | 无 | +| 6 | 无本地持久化 | SQLite | 无 | +| 7 | 无实时推送 | SSE | 无 | + +--- + +## 四、对齐建议 + +### 4.1 架构重构目标 + +将Post模块对齐到Messages模块的架构模式: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 表现层 (Screens) │ +│ HomeScreen, PostDetailScreen │ +├─────────────────────────────────────────────────────────────┤ +│ Hooks层 │ +│ useDifferentialPosts (新增) │ +├─────────────────────────────────────────────────────────────┤ +│ 用例层 (UseCases) │ +│ ProcessPostUseCase (新增) │ +├─────────────────────────────────────────────────────────────┤ +│ 领域层 (Entities) │ +│ Post, PostComment (新增) │ +├─────────────────────────────────────────────────────────────┤ +│ 仓库层 (Repositories) │ +│ PostRepository, IPostRepository (新增) │ +├─────────────────────────────────────────────────────────────┤ +│ Service层 │ +│ postService (仅保留API调用,移除状态操作) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 4.2 具体改造项 + +#### 4.2.1 创建Post领域实体 +```typescript +// src/core/entities/Post.ts +export interface Post { + id: string; + authorId: string; + title: string; + content: string; + images: string[]; + likeCount: number; + commentCount: number; + // ... 其他字段 +} +``` + +#### 4.2.2 创建ProcessPostUseCase +```typescript +// src/core/usecases/ProcessPostUseCase.ts +class ProcessPostUseCase { + // 帖子增删改查 + // 点赞/收藏逻辑 + // 事件发布订阅 +} +``` + +#### 4.2.3 创建IPostRepository接口 +```typescript +// src/data/repositories/interfaces/IPostRepository.ts +interface IPostRepository { + getPosts(type: string, page: number, pageSize: number): Promise; + savePost(post: Post): Promise; + updatePost(postId: string, updates: Partial): Promise; + // ... +} +``` + +#### 4.2.4 创建PostRepository实现 +```typescript +// src/data/repositories/PostRepository.ts +// 封装SQLite操作,实现IPostRepository接口 +``` + +#### 4.2.5 创建useDifferentialPosts Hook +```typescript +// src/hooks/useDifferentialPosts.ts +// 类似useDifferentialMessages,处理帖子列表差异更新 +``` + +#### 4.2.6 改造postService +- 移除对useUserStore的直接依赖 +- 仅保留API调用职责 + +--- + +## 五、架构对比图 + +```mermaid +graph TB + subgraph "Messages模块 [理想架构]" + E1[Entity
Message.ts] + U1[UseCase
ProcessMessageUseCase] + R1[Repository
MessageRepository] + M1[Mapper
MessageMapper] + H1[Hook
useDifferentialMessages] + D1[Diff基础设施
DiffCalculator + Batcher] + S1[(SQLite)] + + E1 --> U1 + U1 --> R1 + U1 --> D1 + R1 --> S1 + M1 --> R1 + D1 --> H1 + H1 --> S1 + end + + subgraph "Post模块 [当前架构]" + E2[PostMapper] + SV2[postService
直接操作Store] + ST2[useUserStore
Zustand] + H2[Hook
useCursorPagination] + PM2[postManager
内存缓存] + + E2 --> SV2 + SV2 --> ST2 + H2 --> SV2 + PM2 --> ST2 + end + + style E1 fill:#90EE90 + style U1 fill:#90EE90 + style R1 fill:#90EE90 + style D1 fill:#90EE90 + style H1 fill:#90EE90 + style S1 fill:#90EE90 + + style SV2 fill:#FFB6C1 + style ST2 fill:#FFB6C1 +``` + +--- + +## 六、结论 + +Messages模块是一个**架构完善**的模块,具备: +1. 清晰的分层架构 +2. 独立的业务逻辑编排层(UseCase) +3. 完整的数据持久化(SQLite) +4. 高效的差异更新机制 +5. 实时事件推送能力 + +Post模块当前存在以下问题: +1. **违反分层原则**:Service直接操作Store +2. **缺少UseCase层**:业务逻辑分散 +3. **无数据抽象**:缺少Repository接口 +4. **无差异更新**:每次全量更新导致性能问题 +5. **无本地持久化**:无法离线使用 + +建议按照对齐建议逐步重构Post模块,使其架构与Messages模块对齐。 diff --git a/src/components/business/NotificationItem.tsx b/src/components/business/NotificationItem.tsx deleted file mode 100644 index b89322a..0000000 --- a/src/components/business/NotificationItem.tsx +++ /dev/null @@ -1,147 +0,0 @@ -/** - * NotificationItem 通知项组件 - * 根据通知类型显示不同图标和内容 - * - * @deprecated 请使用 SystemMessageItem 组件代替 - * 该组件使用旧的 Notification 类型,新功能请使用 SystemMessageItem - */ - -import React from 'react'; -import { View, TouchableOpacity, StyleSheet } from 'react-native'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { formatDistanceToNow } from 'date-fns'; -import { zhCN } from 'date-fns/locale'; -import { colors, spacing, borderRadius } from '../../theme'; -import { Notification, NotificationType } from '../../types'; -import Text from '../common/Text'; -import Avatar from '../common/Avatar'; - -interface NotificationItemProps { - notification: Notification; - onPress: () => void; -} - -// 通知类型到图标和颜色的映射 -const getNotificationIcon = (type: NotificationType): { icon: string; color: string } => { - switch (type) { - case 'like_post': - return { icon: 'heart', color: colors.error.main }; - case 'like_comment': - return { icon: 'heart', color: colors.error.main }; - case 'comment': - return { icon: 'comment', color: colors.info.main }; - case 'reply': - return { icon: 'reply', color: colors.info.main }; - case 'follow': - return { icon: 'account-plus', color: colors.primary.main }; - case 'mention': - return { icon: 'at', color: colors.warning.main }; - case 'system': - return { icon: 'information', color: colors.text.secondary }; - default: - return { icon: 'bell', color: colors.text.secondary }; - } -}; - -const NotificationItem: React.FC = ({ - notification, - onPress, -}) => { - // 格式化时间 - const formatTime = (dateString: string): string => { - try { - return formatDistanceToNow(new Date(dateString), { - addSuffix: true, - locale: zhCN, - }); - } catch { - return ''; - } - }; - - const { icon, color } = getNotificationIcon(notification.type); - - return ( - - {/* 通知图标/头像 */} - - {notification.type === 'follow' && notification.data.userId ? ( - - ) : ( - - - - )} - - - {/* 通知内容 */} - - - - {notification.content} - - - - {formatTime(notification.createdAt)} - - - - {/* 未读标记 */} - {!notification.isRead && } - - ); -}; - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'center', - padding: spacing.lg, - backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: colors.divider, - }, - unread: { - backgroundColor: colors.primary.light + '10', - }, - iconContainer: { - marginRight: spacing.md, - }, - iconWrapper: { - width: 40, - height: 40, - borderRadius: 20, - justifyContent: 'center', - alignItems: 'center', - }, - content: { - flex: 1, - }, - textContainer: { - marginBottom: spacing.xs, - }, - unreadText: { - fontWeight: '600', - }, - unreadDot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: colors.primary.main, - marginLeft: spacing.sm, - }, -}); - -export default NotificationItem; diff --git a/src/components/business/index.ts b/src/components/business/index.ts index dad9de0..7b319e5 100644 --- a/src/components/business/index.ts +++ b/src/components/business/index.ts @@ -5,7 +5,6 @@ export { default as PostCard } from './PostCard'; export { default as CommentItem } from './CommentItem'; export { default as UserProfileHeader } from './UserProfileHeader'; -export { default as NotificationItem } from './NotificationItem'; export { default as SystemMessageItem } from './SystemMessageItem'; export { default as SearchBar } from './SearchBar'; export { default as TabBar } from './TabBar'; diff --git a/src/core/entities/Post.ts b/src/core/entities/Post.ts new file mode 100644 index 0000000..0d008dc --- /dev/null +++ b/src/core/entities/Post.ts @@ -0,0 +1,267 @@ +/** + * Post Entity - 帖子领域实体 + * 定义帖子的核心属性和行为,不依赖任何外部框架 + */ + +// ==================== 帖子状态枚举 ==================== + +export type PostStatus = 'published' | 'draft' | 'deleted'; + +// ==================== 值对象 ==================== + +/** + * 帖子作者信息 + */ +export interface PostAuthor { + id: string; + username: string; + nickname?: string; + avatar?: string; +} + +/** + * 帖子图片信息 + */ +export interface PostImage { + url: string; + width?: number; + height?: number; + thumbnailUrl?: string; +} + +/** + * 帖子标签 + */ +export interface PostTag { + id: string; + name: string; +} + +// ==================== 主实体 ==================== + +/** + * 帖子实体 + */ +export interface Post { + /** 帖子唯一标识 */ + id: string; + /** 作者ID */ + authorId: string; + /** 作者信息 */ + author?: PostAuthor; + /** 帖子标题 */ + title: string; + /** 帖子内容 */ + content: string; + /** 图片列表 */ + images: PostImage[]; + /** 点赞数 (camelCase) */ + likesCount: number; + /** 评论数 (camelCase) */ + commentsCount: number; + /** 分享数 (camelCase) */ + sharesCount: number; + /** 浏览数 (camelCase) */ + viewsCount: number; + /** 收藏数 (camelCase) */ + favoritesCount: number; + /** 当前用户是否已点赞 (camelCase) */ + isLiked: boolean; + /** 当前用户是否已收藏 (camelCase) */ + isFavorited: boolean; + /** 是否置顶 (camelCase) */ + isPinned: boolean; + /** 帖子状态 */ + status: PostStatus; + /** 所属社区ID */ + communityId?: string; + /** 标签列表 */ + tags: string[]; + /** 创建时间 */ + createdAt: string; + /** 更新时间 */ + updatedAt: string; + + // ==================== 向后兼容字段 (snake_case) ==================== + // 这些字段用于与旧代码兼容,新代码应使用 camelCase 版本 + + /** @deprecated 请使用 likesCount */ + likes_count?: number; + /** @deprecated 请使用 commentsCount */ + comments_count?: number; + /** @deprecated 请使用 favoritesCount */ + favorites_count?: number; + /** @deprecated 请使用 sharesCount */ + shares_count?: number; + /** @deprecated 请使用 viewsCount */ + views_count?: number; + /** @deprecated 请使用 isLiked */ + is_liked?: boolean; + /** @deprecated 请使用 isFavorited */ + is_favorited?: boolean; + /** @deprecated 请使用 isPinned */ + is_pinned?: boolean; + /** @deprecated 请使用 authorId */ + user_id?: string; + /** @deprecated 请使用 createdAt */ + created_at?: string; + /** @deprecated 请使用 updatedAt */ + updated_at?: string; + /** @deprecated 请使用 communityId */ + community_id?: string; + /** @deprecated 请使用 status */ + status_str?: string; + /** 投票帖子标识 (部分旧代码使用) */ + is_vote?: boolean; + /** 锁定状态 (部分旧代码使用) */ + is_locked?: boolean; + /** 置顶评论 (部分旧代码使用) */ + top_comment?: any; +} + +// ==================== 工厂函数 ==================== + +/** + * 创建帖子作者信息 + */ +export const createPostAuthor = (data: Partial): PostAuthor => ({ + id: data.id || '', + username: data.username || '', + nickname: data.nickname, + avatar: data.avatar, +}); + +/** + * 创建帖子图片信息 + */ +export const createPostImage = (data: Partial): PostImage => ({ + url: data.url || '', + width: data.width, + height: data.height, + thumbnailUrl: data.thumbnailUrl, +}); + +/** + * 创建帖子实体 + */ +export const createPost = (data: Partial): Post => ({ + id: data.id || '', + authorId: data.authorId || '', + author: data.author, + title: data.title || '', + content: data.content || '', + images: data.images || [], + likesCount: data.likesCount || 0, + commentsCount: data.commentsCount || 0, + sharesCount: data.sharesCount || 0, + viewsCount: data.viewsCount || 0, + favoritesCount: data.favoritesCount || 0, + isLiked: data.isLiked || false, + isFavorited: data.isFavorited || false, + isPinned: data.isPinned || false, + status: data.status || 'published', + communityId: data.communityId, + tags: data.tags || [], + createdAt: data.createdAt || new Date().toISOString(), + updatedAt: data.updatedAt || new Date().toISOString(), +}); + +// ==================== 类型守卫 ==================== + +/** + * 检查是否为有效的帖子状态 + */ +export const isValidPostStatus = (status: string): status is PostStatus => { + return ['published', 'draft', 'deleted'].includes(status); +}; + +/** + * 检查帖子是否已发布 + */ +export const isPostPublished = (post: Post): boolean => { + return post.status === 'published'; +}; + +/** + * 检查帖子是否已删除 + */ +export const isPostDeleted = (post: Post): boolean => { + return post.status === 'deleted'; +}; + +/** + * 检查帖子是否为草稿 + */ +export const isPostDraft = (post: Post): boolean => { + return post.status === 'draft'; +}; + +// ==================== 辅助函数 ==================== + +/** + * 检查当前用户是否为帖子作者 + */ +export const isPostAuthor = (post: Post, userId: string): boolean => { + return post.authorId === userId; +}; + +/** + * 获取帖子的主要图片(第一张图片) + */ +export const getPostThumbnail = (post: Post): PostImage | undefined => { + return post.images[0]; +}; + +/** + * 检查帖子是否包含图片 + */ +export const hasPostImages = (post: Post): boolean => { + return post.images.length > 0; +}; + +/** + * 获取帖子的显示标题(优先使用标题,否则使用内容摘要) + */ +export const getPostDisplayTitle = (post: Post, maxLength: number = 50): string => { + if (post.title) { + return post.title; + } + if (post.content.length > maxLength) { + return post.content.substring(0, maxLength) + '...'; + } + return post.content; +}; + +/** + * 计算帖子的互动总数 + */ +export const getPostTotalEngagement = (post: Post): number => { + return post.likesCount + post.commentsCount + post.sharesCount + post.favoritesCount; +}; + +/** + * 格式化帖子创建时间为相对时间描述 + */ +export const formatPostTime = (post: Post): string => { + const createdAt = new Date(post.createdAt); + const now = new Date(); + const diffMs = now.getTime() - createdAt.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffSeconds < 60) { + return '刚刚'; + } + if (diffMinutes < 60) { + return `${diffMinutes}分钟前`; + } + if (diffHours < 24) { + return `${diffHours}小时前`; + } + if (diffDays < 7) { + return `${diffDays}天前`; + } + return createdAt.toLocaleDateString('zh-CN'); +}; \ No newline at end of file diff --git a/src/core/usecases/ProcessPostUseCase.ts b/src/core/usecases/ProcessPostUseCase.ts new file mode 100644 index 0000000..1a6d775 --- /dev/null +++ b/src/core/usecases/ProcessPostUseCase.ts @@ -0,0 +1,863 @@ +/** + * ProcessPostUseCase - 处理帖子用例 + * 处理帖子的业务逻辑,协调 Repository 和状态管理 + * 纯业务逻辑,无UI依赖 + */ + +import { postRepository } from '../../data/repositories/PostRepository'; +import type { Post } from '../entities/Post'; +import type { + GetPostsParams, + CreatePostData, + UpdatePostData, + PostsResult, + SearchPostsParams, +} from '../../data/repositories/interfaces/IPostRepository'; +import { PaginationStateManager } from '../../infrastructure/pagination/PaginationStateManager'; +import type { PaginationState } from '../../infrastructure/pagination/types'; + +// ==================== 事件类型定义 ==================== + +/** + * 帖子用例事件类型 + */ +export type PostUseCaseEventType = + | 'posts_loaded' + | 'post_created' + | 'post_updated' + | 'post_deleted' + | 'post_liked' + | 'post_unliked' + | 'post_favorited' + | 'post_unfavorited' + | 'loading_changed' + | 'error_occurred' + | 'state_changed'; + +/** + * 帖子用例事件 + */ +export interface PostUseCaseEvent { + type: PostUseCaseEventType; + payload: any; + timestamp: number; +} + +/** + * 事件订阅者类型 + */ +export type PostUseCaseSubscriber = (event: PostUseCaseEvent) => void; + +// ==================== 状态类型定义 ==================== + +/** + * 帖子列表状态 + */ +export interface PostsState { + /** 帖子列表 */ + posts: Post[]; + /** 是否正在加载 */ + isLoading: boolean; + /** 是否正在刷新 */ + isRefreshing: boolean; + /** 是否有更多数据 */ + hasMore: boolean; + /** 错误信息 */ + error: string | null; + /** 当前游标 */ + cursor: string | null; + /** 最后加载时间 */ + lastLoadTime: number | null; +} + +/** + * 帖子详情状态 + */ +export interface PostDetailState { + /** 帖子详情 */ + post: Post | null; + /** 是否正在加载 */ + isLoading: boolean; + /** 错误信息 */ + error: string | null; +} + +// ==================== 默认配置 ==================== + +const DEFAULT_PAGE_SIZE = 20; + +// ==================== ProcessPostUseCase 类 ==================== + +class ProcessPostUseCase { + // 单例实例 + private static instance: ProcessPostUseCase; + + // 订阅者管理 + private subscribers: Set = new Set(); + + // 状态管理 + private postsState: Map = new Map(); + private postDetailState: Map = new Map(); + + // 分页状态管理器 + private paginationManager: PaginationStateManager; + + // 当前用户ID + private currentUserId: string | null = null; + + // 私有构造函数 + private constructor() { + this.paginationManager = new PaginationStateManager(); + } + + /** + * 获取单例实例 + */ + static getInstance(): ProcessPostUseCase { + if (!ProcessPostUseCase.instance) { + ProcessPostUseCase.instance = new ProcessPostUseCase(); + } + return ProcessPostUseCase.instance; + } + + // ==================== 初始化和销毁 ==================== + + /** + * 初始化用例 + * @param currentUserId 当前用户ID + */ + initialize(currentUserId: string | null = null): void { + this.currentUserId = currentUserId; + } + + /** + * 销毁用例 + */ + destroy(): void { + this.subscribers.clear(); + this.postsState.clear(); + this.postDetailState.clear(); + this.paginationManager.clear(); + this.currentUserId = null; + } + + // ==================== 订阅机制 ==================== + + /** + * 订阅事件 + * @param subscriber 订阅者函数 + * @returns 取消订阅函数 + */ + subscribe(subscriber: PostUseCaseSubscriber): () => void { + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + } + + /** + * 通知所有订阅者 + * @param event 事件对象 + */ + private notifySubscribers(event: PostUseCaseEvent): void { + this.subscribers.forEach((subscriber) => { + try { + subscriber(event); + } catch (error) { + console.error('[ProcessPostUseCase] 订阅者执行失败:', error); + } + }); + } + + /** + * 通知状态变化 + * @param key 列表键 + */ + private notifyStateChanged(key: string): void { + const state = this.getPostsState(key); + this.notifySubscribers({ + type: 'state_changed', + payload: { key, state }, + timestamp: Date.now(), + }); + } + + // ==================== 状态管理 ==================== + + /** + * 获取帖子列表状态 + * @param key 列表键(默认为 'default') + */ + getPostsState(key: string = 'default'): PostsState { + let state = this.postsState.get(key); + if (!state) { + state = { + posts: [], + isLoading: false, + isRefreshing: false, + hasMore: true, + error: null, + cursor: null, + lastLoadTime: null, + }; + this.postsState.set(key, state); + } + return state; + } + + /** + * 获取帖子详情状态 + * @param postId 帖子ID + */ + getPostDetailState(postId: string): PostDetailState { + let state = this.postDetailState.get(postId); + if (!state) { + state = { + post: null, + isLoading: false, + error: null, + }; + this.postDetailState.set(postId, state); + } + return state; + } + + /** + * 更新帖子列表状态 + * @param key 列表键 + * @param partialState 部分状态 + */ + private updatePostsState(key: string, partialState: Partial): void { + const currentState = this.getPostsState(key); + this.postsState.set(key, { + ...currentState, + ...partialState, + }); + this.notifyStateChanged(key); + } + + /** + * 更新帖子详情状态 + * @param postId 帖子ID + * @param partialState 部分状态 + */ + private updatePostDetailState(postId: string, partialState: Partial): void { + const currentState = this.getPostDetailState(postId); + this.postDetailState.set(postId, { + ...currentState, + ...partialState, + }); + } + + // ==================== 帖子列表操作 ==================== + + /** + * 获取帖子列表 + * @param params 查询参数 + * @param key 列表键(用于区分不同的列表) + */ + async fetchPosts(params?: GetPostsParams, key: string = 'default'): Promise { + const state = this.getPostsState(key); + + // 更新加载状态 + this.updatePostsState(key, { isLoading: true, error: null }); + + try { + // 合并分页参数 + const queryParams: GetPostsParams = { + page: params?.page || 1, + pageSize: params?.pageSize || DEFAULT_PAGE_SIZE, + cursor: params?.cursor || state.cursor || undefined, + ...params, + }; + + const result = await postRepository.getPosts(queryParams); + + // 更新状态 + this.updatePostsState(key, { + posts: result.posts, + hasMore: result.hasMore, + cursor: result.nextCursor || null, + isLoading: false, + lastLoadTime: Date.now(), + }); + + // 通知订阅者 + this.notifySubscribers({ + type: 'posts_loaded', + payload: { key, posts: result.posts, hasMore: result.hasMore }, + timestamp: Date.now(), + }); + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '获取帖子列表失败'; + this.updatePostsState(key, { + isLoading: false, + error: errorMessage, + }); + + this.notifySubscribers({ + type: 'error_occurred', + payload: { key, error: errorMessage }, + timestamp: Date.now(), + }); + + throw error; + } + } + + /** + * 刷新帖子列表 + * @param key 列表键 + */ + async refreshPosts(key: string = 'default'): Promise { + // 更新刷新状态 + this.updatePostsState(key, { isRefreshing: true, error: null }); + + try { + const result = await postRepository.getPosts({ + page: 1, + pageSize: DEFAULT_PAGE_SIZE, + }); + + // 更新状态 + this.updatePostsState(key, { + posts: result.posts, + hasMore: result.hasMore, + cursor: result.nextCursor || null, + isRefreshing: false, + lastLoadTime: Date.now(), + }); + + // 重置分页状态 + this.paginationManager.reset(key); + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '刷新帖子列表失败'; + this.updatePostsState(key, { + isRefreshing: false, + error: errorMessage, + }); + + throw error; + } + } + + /** + * 加载更多帖子 + * @param key 列表键 + */ + async loadMorePosts(key: string = 'default'): Promise { + const state = this.getPostsState(key); + + // 检查是否还有更多数据 + if (!state.hasMore) { + return { + posts: [], + hasMore: false, + }; + } + + // 检查是否正在加载 + if (state.isLoading) { + return { + posts: [], + hasMore: state.hasMore, + }; + } + + // 更新加载状态 + this.updatePostsState(key, { isLoading: true, error: null }); + + try { + const queryParams: GetPostsParams = { + pageSize: DEFAULT_PAGE_SIZE, + cursor: state.cursor || undefined, + }; + + const result = await postRepository.getPosts(queryParams); + + // 合并新数据 + const newPosts = [...state.posts, ...result.posts]; + + // 更新状态 + this.updatePostsState(key, { + posts: newPosts, + hasMore: result.hasMore, + cursor: result.nextCursor || null, + isLoading: false, + lastLoadTime: Date.now(), + }); + + return { + posts: result.posts, + hasMore: result.hasMore, + nextCursor: result.nextCursor, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '加载更多帖子失败'; + this.updatePostsState(key, { + isLoading: false, + error: errorMessage, + }); + + throw error; + } + } + + // ==================== 帖子详情操作 ==================== + + /** + * 获取单个帖子详情 + * @param id 帖子ID + */ + async fetchPostById(id: string): Promise { + // 更新加载状态 + this.updatePostDetailState(id, { isLoading: true, error: null }); + + try { + const post = await postRepository.getPostById(id); + + // 更新状态 + this.updatePostDetailState(id, { + post, + isLoading: false, + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '获取帖子详情失败'; + this.updatePostDetailState(id, { + isLoading: false, + error: errorMessage, + }); + + throw error; + } + } + + // ==================== 帖子CRUD操作 ==================== + + /** + * 创建帖子 + * @param data 创建数据 + */ + async createPost(data: CreatePostData): Promise { + try { + const post = await postRepository.createPost(data); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_created', + payload: { post }, + timestamp: Date.now(), + }); + + // 更新所有列表(新帖子应该出现在列表顶部) + this.postsState.forEach((state, key) => { + this.updatePostsState(key, { + posts: [post, ...state.posts], + }); + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '创建帖子失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'create', error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + /** + * 更新帖子 + * @param id 帖子ID + * @param data 更新数据 + */ + async updatePost(id: string, data: UpdatePostData): Promise { + try { + const post = await postRepository.updatePost(id, data); + + // 更新详情状态 + this.updatePostDetailState(id, { post }); + + // 更新列表中的帖子 + this.updatePostInLists(id, post); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_updated', + payload: { postId: id, post }, + timestamp: Date.now(), + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '更新帖子失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'update', postId: id, error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + /** + * 删除帖子 + * @param id 帖子ID + */ + async deletePost(id: string): Promise { + try { + await postRepository.deletePost(id); + + // 从详情状态中移除 + this.postDetailState.delete(id); + + // 从所有列表中移除 + this.postsState.forEach((state, key) => { + const filteredPosts = state.posts.filter((post) => post.id !== id); + this.updatePostsState(key, { + posts: filteredPosts, + }); + }); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_deleted', + payload: { postId: id }, + timestamp: Date.now(), + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '删除帖子失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'delete', postId: id, error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + // ==================== 互动操作 ==================== + + /** + * 点赞帖子 + * @param id 帖子ID + */ + async likePost(id: string): Promise { + try { + const post = await postRepository.likePost(id); + + // 更新详情状态 + this.updatePostDetailState(id, { post }); + + // 更新列表中的帖子 + this.updatePostInLists(id, post); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_liked', + payload: { postId: id, post }, + timestamp: Date.now(), + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '点赞失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'like', postId: id, error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + /** + * 取消点赞 + * @param id 帖子ID + */ + async unlikePost(id: string): Promise { + try { + const post = await postRepository.unlikePost(id); + + // 更新详情状态 + this.updatePostDetailState(id, { post }); + + // 更新列表中的帖子 + this.updatePostInLists(id, post); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_unliked', + payload: { postId: id, post }, + timestamp: Date.now(), + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '取消点赞失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'unlike', postId: id, error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + /** + * 收藏帖子 + * @param id 帖子ID + */ + async favoritePost(id: string): Promise { + try { + const post = await postRepository.favoritePost(id); + + // 更新详情状态 + this.updatePostDetailState(id, { post }); + + // 更新列表中的帖子 + this.updatePostInLists(id, post); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_favorited', + payload: { postId: id, post }, + timestamp: Date.now(), + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '收藏失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'favorite', postId: id, error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + /** + * 取消收藏 + * @param id 帖子ID + */ + async unfavoritePost(id: string): Promise { + try { + const post = await postRepository.unfavoritePost(id); + + // 更新详情状态 + this.updatePostDetailState(id, { post }); + + // 更新列表中的帖子 + this.updatePostInLists(id, post); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_unfavorited', + payload: { postId: id, post }, + timestamp: Date.now(), + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '取消收藏失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'unfavorite', postId: id, error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + // ==================== 搜索操作 ==================== + + /** + * 搜索帖子 + * @param params 搜索参数 + * @param key 列表键 + */ + async searchPosts(params: SearchPostsParams, key: string = 'search'): Promise { + // 更新加载状态 + this.updatePostsState(key, { isLoading: true, error: null }); + + try { + const result = await postRepository.searchPosts(params); + + // 更新状态 + this.updatePostsState(key, { + posts: result.posts, + hasMore: result.hasMore, + cursor: result.nextCursor || null, + isLoading: false, + lastLoadTime: Date.now(), + }); + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '搜索帖子失败'; + this.updatePostsState(key, { + isLoading: false, + error: errorMessage, + }); + + throw error; + } + } + + // ==================== 用户帖子操作 ==================== + + /** + * 获取用户发布的帖子 + * @param userId 用户ID + * @param params 查询参数 + * @param key 列表键 + */ + async fetchPostsByUser( + userId: string, + params?: GetPostsParams, + key?: string + ): Promise { + const listKey = key || `user_${userId}`; + + // 更新加载状态 + this.updatePostsState(listKey, { isLoading: true, error: null }); + + try { + const result = await postRepository.getPostsByUser(userId, params); + + // 更新状态 + this.updatePostsState(listKey, { + posts: result.posts, + hasMore: result.hasMore, + cursor: result.nextCursor || null, + isLoading: false, + lastLoadTime: Date.now(), + }); + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '获取用户帖子失败'; + this.updatePostsState(listKey, { + isLoading: false, + error: errorMessage, + }); + + throw error; + } + } + + // ==================== 辅助方法 ==================== + + /** + * 更新所有列表中的帖子 + * @param postId 帖子ID + * @param updatedPost 更新后的帖子 + */ + private updatePostInLists(postId: string, updatedPost: Post): void { + this.postsState.forEach((state, key) => { + const index = state.posts.findIndex((post) => post.id === postId); + if (index !== -1) { + const newPosts = [...state.posts]; + newPosts[index] = updatedPost; + this.updatePostsState(key, { posts: newPosts }); + } + }); + } + + /** + * 清除指定列表的状态 + * @param key 列表键 + */ + clearPostsState(key: string = 'default'): void { + this.postsState.delete(key); + this.paginationManager.reset(key); + } + + /** + * 清除帖子详情状态 + * @param postId 帖子ID + */ + clearPostDetailState(postId: string): void { + this.postDetailState.delete(postId); + } + + /** + * 清除所有状态 + */ + clearAllStates(): void { + this.postsState.clear(); + this.postDetailState.clear(); + this.paginationManager.clear(); + } + + /** + * 获取当前用户ID + */ + getCurrentUserId(): string | null { + return this.currentUserId; + } + + /** + * 设置当前用户ID + */ + setCurrentUserId(userId: string | null): void { + this.currentUserId = userId; + } + + // ==================== 分页状态管理集成 ==================== + + /** + * 获取分页状态 + * @param key 列表键 + */ + getPaginationState(key: string): PaginationState { + return this.paginationManager.getState(key); + } + + /** + * 重置分页状态 + * @param key 列表键 + */ + resetPagination(key: string): void { + this.paginationManager.reset(key); + } + + /** + * 检查是否正在加载 + * @param key 列表键 + */ + isLoading(key: string = 'default'): boolean { + return this.getPostsState(key).isLoading; + } + + /** + * 检查是否有错误 + * @param key 列表键 + */ + hasError(key: string = 'default'): boolean { + return this.getPostsState(key).error !== null; + } + + /** + * 获取错误信息 + * @param key 列表键 + */ + getError(key: string = 'default'): string | null { + return this.getPostsState(key).error; + } +} + +// ==================== 导出 ==================== + +// 导出单例实例 +export const processPostUseCase = ProcessPostUseCase.getInstance(); +export default processPostUseCase; \ No newline at end of file diff --git a/src/data/datasources/LocalDataSource.ts b/src/data/datasources/LocalDataSource.ts index 1ed7b93..7126779 100644 --- a/src/data/datasources/LocalDataSource.ts +++ b/src/data/datasources/LocalDataSource.ts @@ -132,6 +132,12 @@ export class LocalDataSource implements ILocalDataSource { updatedAt TEXT NOT NULL, PRIMARY KEY (groupId, userId) )`, + // 帖子缓存表 + `CREATE TABLE IF NOT EXISTS posts_cache ( + id TEXT PRIMARY KEY NOT NULL, + data TEXT NOT NULL, + updatedAt TEXT NOT NULL + )`, ]; for (const sql of tables) { diff --git a/src/data/repositories/PostRepository.ts b/src/data/repositories/PostRepository.ts new file mode 100644 index 0000000..5d26cb0 --- /dev/null +++ b/src/data/repositories/PostRepository.ts @@ -0,0 +1,550 @@ +/** + * PostRepository - 帖子仓库实现 + * 实现IPostRepository接口,封装帖子相关的数据访问逻辑 + */ + +import { ApiDataSource, apiDataSource } from '../datasources/ApiDataSource'; +import { LocalDataSource, localDataSource } from '../datasources/LocalDataSource'; +import { PostMapper } from '../mappers/PostMapper'; +import type { Post, PostImage, PostStatus } from '../../core/entities/Post'; +import { createPost } from '../../core/entities/Post'; +import type { + IPostRepository, + GetPostsParams, + CreatePostData, + UpdatePostData, + PostsResult, + SearchPostsParams, +} from './interfaces/IPostRepository'; + +// ==================== 类型定义 ==================== + +/** + * API帖子响应类型 + */ +interface PostApiResponse { + id: number; + user_id: number; + title: string; + content: string; + images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>; + likes_count: number; + comments_count: number; + shares_count: number; + views_count: number; + favorites_count: number; + is_liked: boolean; + is_favorited: boolean; + is_pinned: boolean; + status: string; + community_id?: string; + tags?: string[]; + created_at: string; + updated_at: string; + author?: { + id: number; + username: string; + nickname?: string; + avatar?: string; + }; +} + +/** + * API帖子列表响应类型 + */ +interface PostsListApiResponse { + posts: PostApiResponse[]; + has_more: boolean; + next_cursor?: string; + total?: number; +} + +/** + * 缓存的帖子数据 + */ +interface CachedPost { + id: string; + data: string; + updatedAt: string; +} + +// ==================== PostRepository 实现 ==================== + +export class PostRepository implements IPostRepository { + private api: ApiDataSource; + private localDb: LocalDataSource; + private memoryCache: Map = new Map(); + private readonly CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存过期时间 + + constructor(api: ApiDataSource, localDb: LocalDataSource) { + this.api = api; + this.localDb = localDb; + } + + // ==================== 私有辅助方法 ==================== + + /** + * 将API响应转换为领域实体 + * 同时设置 camelCase 和 snake_case 字段以保持向后兼容 + */ + private mapToPost(response: PostApiResponse): Post { + const model = PostMapper.fromApiResponse(response as any); + return { + // camelCase 字段(新标准) + id: model.id, + authorId: model.authorId, + author: model.author ? { + id: model.author.id, + username: model.author.username, + nickname: model.author.nickname, + avatar: model.author.avatar, + } : undefined, + title: model.title, + content: model.content, + images: (model.images || []).map(url => ({ + url, + width: undefined, + height: undefined, + })) as PostImage[], + likesCount: model.likeCount, + commentsCount: model.commentCount, + sharesCount: model.shareCount, + viewsCount: model.viewCount, + favoritesCount: model.favoriteCount, + isLiked: model.isLiked, + isFavorited: model.isFavorited, + isPinned: model.isTop, + status: model.status, + communityId: model.communityId, + tags: model.tags || [], + createdAt: model.createdAt.toISOString(), + updatedAt: model.updatedAt.toISOString(), + + // snake_case 字段(向后兼容) + likes_count: model.likeCount, + comments_count: model.commentCount, + favorites_count: model.favoriteCount, + shares_count: model.shareCount, + views_count: model.viewCount, + is_liked: model.isLiked, + is_favorited: model.isFavorited, + is_pinned: model.isTop, + user_id: model.authorId, + created_at: model.createdAt.toISOString(), + updated_at: model.updatedAt.toISOString(), + community_id: model.communityId, + }; + } + + /** + * 从内存缓存获取帖子 + */ + private getFromMemoryCache(id: string): Post | null { + const cached = this.memoryCache.get(id); + if (!cached) return null; + + // 检查缓存是否过期 + if (Date.now() - cached.timestamp > this.CACHE_TTL) { + this.memoryCache.delete(id); + return null; + } + + return cached.post; + } + + /** + * 保存帖子到内存缓存 + */ + private saveToMemoryCache(post: Post): void { + this.memoryCache.set(post.id, { + post, + timestamp: Date.now(), + }); + } + + /** + * 从本地数据库获取缓存的帖子 + */ + private async getFromLocalCache(id: string): Promise { + try { + await this.localDb.initialize(); + const result = await this.localDb.getFirst( + 'SELECT * FROM posts_cache WHERE id = ?', + [id] + ); + + if (!result) return null; + + const post = JSON.parse(result.data) as Post; + return post; + } catch (error) { + console.error('[PostRepository] 获取本地缓存失败:', error); + return null; + } + } + + /** + * 保存帖子到本地缓存 + */ + private async saveToLocalCache(post: Post): Promise { + try { + await this.localDb.initialize(); + await this.localDb.run( + `INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`, + [post.id, JSON.stringify(post), new Date().toISOString()] + ); + } catch (error) { + console.error('[PostRepository] 保存本地缓存失败:', error); + } + } + + /** + * 清除帖子的本地缓存 + */ + private async clearLocalCache(id: string): Promise { + try { + await this.localDb.initialize(); + await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]); + } catch (error) { + console.error('[PostRepository] 清除本地缓存失败:', error); + } + } + + /** + * 处理错误 + */ + private handleError(error: unknown, operation: string): never { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(`[PostRepository] ${operation} 失败:`, error); + throw new Error(`帖子${operation}失败: ${message}`); + } + + // ==================== 帖子查询 ==================== + + /** + * 获取帖子列表 + */ + async getPosts(params?: GetPostsParams): Promise { + try { + const queryParams: Record = {}; + + if (params?.page) queryParams.page = params.page; + if (params?.pageSize) queryParams.page_size = params.pageSize; + if (params?.cursor) queryParams.cursor = params.cursor; + if (params?.post_type) queryParams.tab = params.post_type; + if (params?.communityId) queryParams.community_id = params.communityId; + if (params?.authorId) queryParams.author_id = params.authorId; + if (params?.tags && params.tags.length > 0) queryParams.tags = params.tags.join(','); + if (params?.status) queryParams.status = params.status; + if (params?.sortBy) queryParams.sort_by = params.sortBy; + if (params?.sortOrder) queryParams.sort_order = params.sortOrder; + if (params?.pinnedOnly) queryParams.pinned_only = true; + + const response = await this.api.get('/posts', queryParams); + + const posts = response.posts.map(p => this.mapToPost(p)); + + // 缓存帖子 + posts.forEach(post => { + this.saveToMemoryCache(post); + this.saveToLocalCache(post); + }); + + return { + posts, + hasMore: response.has_more, + nextCursor: response.next_cursor, + total: response.total, + }; + } catch (error) { + this.handleError(error, '获取帖子列表'); + } + } + + /** + * 获取单个帖子详情 + */ + async getPostById(id: string): Promise { + try { + // 1. 先查内存缓存 + const memoryCached = this.getFromMemoryCache(id); + if (memoryCached) { + return memoryCached; + } + + // 2. 查本地数据库缓存 + const localCached = await this.getFromLocalCache(id); + if (localCached) { + this.saveToMemoryCache(localCached); + return localCached; + } + + // 3. 从API获取 + const response = await this.api.get(`/posts/${id}`); + const post = this.mapToPost(response); + + // 更新缓存 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + // 如果API请求失败,尝试返回本地缓存 + const localCached = await this.getFromLocalCache(id); + if (localCached) { + console.warn('[PostRepository] API获取失败,使用本地缓存:', error); + return localCached; + } + this.handleError(error, '获取帖子详情'); + } + } + + /** + * 获取用户发布的帖子列表 + */ + async getPostsByUser(userId: string, params?: GetPostsParams): Promise { + try { + const queryParams: Record = { author_id: userId }; + + if (params?.page) queryParams.page = params.page; + if (params?.pageSize) queryParams.page_size = params.pageSize; + if (params?.cursor) queryParams.cursor = params.cursor; + if (params?.status) queryParams.status = params.status; + if (params?.sortBy) queryParams.sort_by = params.sortBy; + if (params?.sortOrder) queryParams.sort_order = params.sortOrder; + + const response = await this.api.get(`/users/${userId}/posts`, queryParams); + + const posts = response.posts.map(p => this.mapToPost(p)); + + // 缓存帖子 + posts.forEach(post => { + this.saveToMemoryCache(post); + this.saveToLocalCache(post); + }); + + return { + posts, + hasMore: response.has_more, + nextCursor: response.next_cursor, + total: response.total, + }; + } catch (error) { + this.handleError(error, '获取用户帖子列表'); + } + } + + /** + * 搜索帖子 + */ + async searchPosts(params: SearchPostsParams): Promise { + try { + const queryParams: Record = { + keyword: params.keyword, + }; + + if (params.page) queryParams.page = params.page; + if (params.pageSize) queryParams.page_size = params.pageSize; + if (params.scope) queryParams.scope = params.scope; + if (params.communityId) queryParams.community_id = params.communityId; + + const response = await this.api.get('/posts/search', queryParams); + + const posts = response.posts.map(p => this.mapToPost(p)); + + // 缓存帖子 + posts.forEach(post => { + this.saveToMemoryCache(post); + this.saveToLocalCache(post); + }); + + return { + posts, + hasMore: response.has_more, + nextCursor: response.next_cursor, + total: response.total, + }; + } catch (error) { + this.handleError(error, '搜索帖子'); + } + } + + // ==================== 帖子操作 ==================== + + /** + * 创建帖子 + */ + async createPost(data: CreatePostData): Promise { + try { + const requestData = PostMapper.createPostRequest( + data.title, + data.content, + data.images?.map(img => img.url), + data.communityId + ); + + if (data.tags && data.tags.length > 0) { + requestData.tags = data.tags; + } + + if (data.status) { + requestData.status = data.status; + } + + const response = await this.api.post('/posts', requestData); + const post = this.mapToPost(response); + + // 缓存新创建的帖子 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + this.handleError(error, '创建帖子'); + } + } + + /** + * 更新帖子 + */ + async updatePost(id: string, data: UpdatePostData): Promise { + try { + const requestData: Record = {}; + + if (data.title !== undefined) requestData.title = data.title; + if (data.content !== undefined) requestData.content = data.content; + if (data.images !== undefined) requestData.images = data.images.map(img => img.url); + if (data.tags !== undefined) requestData.tags = data.tags; + if (data.status !== undefined) requestData.status = data.status; + if (data.isPinned !== undefined) requestData.is_pinned = data.isPinned; + + const response = await this.api.put(`/posts/${id}`, requestData); + const post = this.mapToPost(response); + + // 更新缓存 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + this.handleError(error, '更新帖子'); + } + } + + /** + * 删除帖子 + */ + async deletePost(id: string): Promise { + try { + await this.api.delete(`/posts/${id}`); + + // 清除缓存 + this.memoryCache.delete(id); + await this.clearLocalCache(id); + } catch (error) { + this.handleError(error, '删除帖子'); + } + } + + // ==================== 互动操作 ==================== + + /** + * 点赞帖子 + */ + async likePost(id: string): Promise { + try { + const response = await this.api.post(`/posts/${id}/like`); + const post = this.mapToPost(response); + + // 更新缓存 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + this.handleError(error, '点赞帖子'); + } + } + + /** + * 取消点赞 + */ + async unlikePost(id: string): Promise { + try { + const response = await this.api.delete(`/posts/${id}/like`); + const post = this.mapToPost(response); + + // 更新缓存 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + this.handleError(error, '取消点赞'); + } + } + + /** + * 收藏帖子 + */ + async favoritePost(id: string): Promise { + try { + const response = await this.api.post(`/posts/${id}/favorite`); + const post = this.mapToPost(response); + + // 更新缓存 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + this.handleError(error, '收藏帖子'); + } + } + + /** + * 取消收藏 + */ + async unfavoritePost(id: string): Promise { + try { + const response = await this.api.delete(`/posts/${id}/favorite`); + const post = this.mapToPost(response); + + // 更新缓存 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + this.handleError(error, '取消收藏'); + } + } + + // ==================== 缓存管理 ==================== + + /** + * 清除所有缓存 + */ + async clearAllCache(): Promise { + this.memoryCache.clear(); + try { + await this.localDb.initialize(); + await this.localDb.run('DELETE FROM posts_cache'); + } catch (error) { + console.error('[PostRepository] 清除所有缓存失败:', error); + } + } + + /** + * 使指定帖子的缓存失效 + */ + async invalidateCache(id: string): Promise { + this.memoryCache.delete(id); + await this.clearLocalCache(id); + } +} + +// ==================== 单例导出 ==================== + +export const postRepository = new PostRepository(apiDataSource, localDataSource); +export default postRepository; diff --git a/src/data/repositories/interfaces/IPostRepository.ts b/src/data/repositories/interfaces/IPostRepository.ts new file mode 100644 index 0000000..c1dc72f --- /dev/null +++ b/src/data/repositories/interfaces/IPostRepository.ts @@ -0,0 +1,190 @@ +/** + * 帖子 Repository 接口 + * 定义帖子数据访问的抽象 + */ + +import type { Post, PostImage, PostStatus } from '../../../core/entities/Post'; + +// ==================== 请求/响应类型 ==================== + +/** + * 获取帖子列表参数 + */ +export interface GetPostsParams { + /** 分页 - 页码(从1开始) */ + page?: number; + /** 分页 - 每页数量 */ + pageSize?: number; + /** 游标 - 用于无限滚动加载 */ + cursor?: string; + /** 帖子类型筛选(可选):recommend, follow, hot, latest */ + post_type?: 'recommend' | 'follow' | 'hot' | 'latest'; + /** 社区ID过滤 */ + communityId?: string; + /** 作者ID过滤 */ + authorId?: string; + /** 标签过滤 */ + tags?: string[]; + /** 状态过滤 */ + status?: PostStatus; + /** 排序字段 */ + sortBy?: 'createdAt' | 'likesCount' | 'commentsCount' | 'viewsCount'; + /** 排序方向 */ + sortOrder?: 'asc' | 'desc'; + /** 是否只获取置顶帖子 */ + pinnedOnly?: boolean; +} + +/** + * 创建帖子数据 + */ +export interface CreatePostData { + /** 帖子标题 */ + title: string; + /** 帖子内容 */ + content: string; + /** 图片列表 */ + images?: PostImage[]; + /** 所属社区ID */ + communityId?: string; + /** 标签列表 */ + tags?: string[]; + /** 帖子状态 */ + status?: PostStatus; +} + +/** + * 更新帖子数据 + */ +export interface UpdatePostData { + /** 帖子标题 */ + title?: string; + /** 帖子内容 */ + content?: string; + /** 图片列表 */ + images?: PostImage[]; + /** 标签列表 */ + tags?: string[]; + /** 帖子状态 */ + status?: PostStatus; + /** 是否置顶 */ + isPinned?: boolean; +} + +/** + * 帖子列表结果 + */ +export interface PostsResult { + /** 帖子列表 */ + posts: Post[]; + /** 是否有更多数据 */ + hasMore: boolean; + /** 下一页游标 */ + nextCursor?: string; + /** 总数(如果支持) */ + total?: number; +} + +/** + * 搜索帖子参数 + */ +export interface SearchPostsParams { + /** 搜索关键词 */ + keyword: string; + /** 分页 - 页码 */ + page?: number; + /** 分页 - 每页数量 */ + pageSize?: number; + /** 搜索范围:标题、内容、或全部 */ + scope?: 'title' | 'content' | 'all'; + /** 社区ID过滤 */ + communityId?: string; +} + +// ==================== Repository 接口 ==================== + +export interface IPostRepository { + // ==================== 帖子查询 ==================== + + /** + * 获取帖子列表 + * @param params 查询参数 + * @returns 帖子列表结果 + */ + getPosts(params?: GetPostsParams): Promise; + + /** + * 获取单个帖子详情 + * @param id 帖子ID + * @returns 帖子实体,不存在则返回null + */ + getPostById(id: string): Promise; + + /** + * 获取用户发布的帖子列表 + * @param userId 用户ID + * @param params 查询参数 + * @returns 帖子列表结果 + */ + getPostsByUser(userId: string, params?: GetPostsParams): Promise; + + /** + * 搜索帖子 + * @param params 搜索参数 + * @returns 帖子列表结果 + */ + searchPosts(params: SearchPostsParams): Promise; + + // ==================== 帖子操作 ==================== + + /** + * 创建帖子 + * @param data 创建帖子数据 + * @returns 创建的帖子实体 + */ + createPost(data: CreatePostData): Promise; + + /** + * 更新帖子 + * @param id 帖子ID + * @param data 更新数据 + * @returns 更新后的帖子实体 + */ + updatePost(id: string, data: UpdatePostData): Promise; + + /** + * 删除帖子 + * @param id 帖子ID + */ + deletePost(id: string): Promise; + + // ==================== 互动操作 ==================== + + /** + * 点赞帖子 + * @param id 帖子ID + * @returns 更新后的帖子实体 + */ + likePost(id: string): Promise; + + /** + * 取消点赞 + * @param id 帖子ID + * @returns 更新后的帖子实体 + */ + unlikePost(id: string): Promise; + + /** + * 收藏帖子 + * @param id 帖子ID + * @returns 更新后的帖子实体 + */ + favoritePost(id: string): Promise; + + /** + * 取消收藏 + * @param id 帖子ID + * @returns 更新后的帖子实体 + */ + unfavoritePost(id: string): Promise; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index d18b9d8..0b1fd9b 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -95,3 +95,19 @@ export type { UseConnectionStateResult } from './useConnectionState'; export { useMediaCache } from './useMediaCache'; export type { UseMediaCacheReturn } from './useMediaCache'; + +// ==================== 差异更新 Hooks ==================== +export { useDifferentialMessages } from './useDifferentialMessages'; + +export type { + UseDifferentialMessagesOptions, + UseDifferentialMessagesResult, +} from './useDifferentialMessages'; + +export { useDifferentialPosts } from './useDifferentialPosts'; + +export type { + UseDifferentialPostsOptions, + UseDifferentialPostsResult, + DiffUpdatesInfo, +} from './useDifferentialPosts'; diff --git a/src/hooks/useDifferentialPosts.ts b/src/hooks/useDifferentialPosts.ts new file mode 100644 index 0000000..c441c9d --- /dev/null +++ b/src/hooks/useDifferentialPosts.ts @@ -0,0 +1,635 @@ +/** + * 帖子差异更新 Hook + * 接收原始帖子列表,返回优化后的帖子列表,自动处理批量更新 + * 集成 ProcessPostUseCase 进行状态管理 + */ + +import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; +import { + PostIdentifier, + PostUpdate, + PostUpdateType, + PostDiffConfig, + PostDiffResult, +} from '../infrastructure/diff/postTypes'; +import { + PostDiffCalculator, + createPostDiffCalculator, +} from '../infrastructure/diff/PostDiffCalculator'; +import { + PostUpdateBatcher, + PostBatcherOptions, + createPostUpdateBatcher, +} from '../infrastructure/diff/PostUpdateBatcher'; +import { processPostUseCase } from '../core/usecases/ProcessPostUseCase'; +import type { PostUseCaseEvent, PostsState } from '../core/usecases/ProcessPostUseCase'; +import type { Post } from '../core/entities/Post'; + +// ==================== 类型定义 ==================== + +/** + * 差异更新 Hook 配置选项 + */ +export interface UseDifferentialPostsOptions { + /** 差异计算配置 */ + diffConfig?: PostDiffConfig; + /** 批量处理配置 */ + batcherOptions?: PostBatcherOptions; + /** 是否启用差异计算,默认 true */ + enableDiff?: boolean; + /** 是否启用批量处理,默认 true */ + enableBatching?: boolean; + /** 最大帖子数量限制,超出时触发重置 */ + maxPostCount?: number; + /** 变化比例阈值(0-1),超过此值触发重置 */ + changeRatioThreshold?: number; + /** 列表键(用于区分不同的帖子列表) */ + listKey?: string; + /** 是否自动订阅 UseCase 状态变化,默认 true */ + autoSubscribe?: boolean; +} + +/** + * 差异更新信息 + */ +export interface DiffUpdatesInfo { + /** 新增数量 */ + addedCount: number; + /** 更新数量 */ + updatedCount: number; + /** 删除数量 */ + deletedCount: number; + /** 上次更新时间 */ + lastUpdateTime: number; +} + +/** + * 差异更新 Hook 返回值 + */ +export interface UseDifferentialPostsResult { + /** 优化后的帖子列表 */ + posts: T[]; + /** 是否正在加载 */ + loading: boolean; + /** 是否正在刷新 */ + refreshing: boolean; + /** 错误信息 */ + error: string | null; + /** 是否有更多数据 */ + hasMore: boolean; + /** 待处理更新数量 */ + pendingUpdateCount: number; + /** 是否正在处理更新 */ + isProcessing: boolean; + /** 刷新方法 */ + refresh: () => Promise; + /** 加载更多方法 */ + loadMore: () => Promise; + /** 手动刷新批量处理队列 */ + flush: () => void; + /** 重置方法 */ + reset: () => void; + /** 强制更新(跳过批量处理) */ + forceUpdate: (posts: T[]) => void; + /** 获取差异统计信息 */ + getDiffStats: () => { + totalBatches: number; + totalUpdates: number; + averageBatchSize: number; + }; + /** 差异更新信息 */ + diffUpdates: DiffUpdatesInfo; +} + +// ==================== Hook 实现 ==================== + +/** + * 帖子差异更新 Hook + * @param initialPosts 初始帖子列表(可选) + * @param options 配置选项 + * @returns 优化后的帖子列表和相关方法 + */ +export function useDifferentialPosts( + initialPosts: T[] = [], + options: UseDifferentialPostsOptions = {} +): UseDifferentialPostsResult { + const { + diffConfig, + batcherOptions, + enableDiff = true, + enableBatching = true, + maxPostCount = 10000, + changeRatioThreshold = 0.5, + listKey = 'default', + autoSubscribe = true, + } = options; + + // ==================== 状态 ==================== + + const [posts, setPosts] = useState(initialPosts); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(true); + const [isProcessing, setIsProcessing] = useState(false); + const [pendingUpdateCount, setPendingUpdateCount] = useState(0); + const [diffUpdates, setDiffUpdates] = useState({ + addedCount: 0, + updatedCount: 0, + deletedCount: 0, + lastUpdateTime: 0, + }); + + // ==================== Refs ==================== + + const calculatorRef = useRef | null>(null); + const batcherRef = useRef(null); + const previousPostsRef = useRef(initialPosts); + const unsubscribeRef = useRef<(() => void) | null>(null); + + // ==================== 初始化 ==================== + + // 初始化差异计算器 + useEffect(() => { + if (enableDiff) { + calculatorRef.current = createPostDiffCalculator(diffConfig); + } + return () => { + calculatorRef.current = null; + }; + }, [enableDiff, diffConfig]); + + // 初始化批量处理器 + useEffect(() => { + if (enableBatching) { + batcherRef.current = createPostUpdateBatcher(batcherOptions); + + // 订阅批量更新 + const unsubscribe = batcherRef.current.subscribe((updates) => { + handleBatchUpdates(updates); + }); + + return () => { + unsubscribe(); + batcherRef.current?.destroy(); + batcherRef.current = null; + }; + } + }, [enableBatching, batcherOptions]); + + // ==================== 批量更新处理 ==================== + + /** + * 处理批量更新 + */ + const handleBatchUpdates = useCallback((updates: PostUpdate[]) => { + setIsProcessing(true); + + try { + setPosts((currentPosts) => { + let newPosts = [...currentPosts]; + let addedCount = 0; + let updatedCount = 0; + let deletedCount = 0; + + for (const update of updates) { + switch (update.type) { + case PostUpdateType.ADD: { + const addUpdate = update as any; + const index = addUpdate.index ?? newPosts.length; + newPosts.splice(index, 0, addUpdate.post as T); + addedCount++; + break; + } + + case PostUpdateType.BATCH_ADD: { + const batchAddUpdate = update as any; + const postsToAdd = batchAddUpdate.posts || []; + const startIndex = batchAddUpdate.startIndex ?? newPosts.length; + newPosts.splice(startIndex, 0, ...postsToAdd as T[]); + addedCount += postsToAdd.length; + break; + } + + case PostUpdateType.UPDATE: { + const updatePost = update as any; + const index = newPosts.findIndex(p => p.id === updatePost.postId); + if (index !== -1) { + newPosts[index] = { ...newPosts[index], ...updatePost.updates }; + updatedCount++; + } + break; + } + + case PostUpdateType.BATCH_UPDATE: { + const batchUpdate = update as any; + const updatesList = batchUpdate.updates || []; + for (const { postId, changes } of updatesList) { + const index = newPosts.findIndex(p => p.id === postId); + if (index !== -1) { + newPosts[index] = { ...newPosts[index], ...changes }; + updatedCount++; + } + } + break; + } + + case PostUpdateType.DELETE: { + const deleteUpdate = update as any; + const prevLength = newPosts.length; + newPosts = newPosts.filter(p => p.id !== deleteUpdate.postId); + if (newPosts.length < prevLength) { + deletedCount++; + } + break; + } + + case PostUpdateType.BATCH_DELETE: { + const batchDelete = update as any; + const idsToDelete = new Set(batchDelete.postIds || []); + const prevLength = newPosts.length; + newPosts = newPosts.filter(p => !idsToDelete.has(p.id)); + deletedCount += prevLength - newPosts.length; + break; + } + + case PostUpdateType.MOVE: { + const moveUpdate = update as any; + const fromIndex = newPosts.findIndex(p => p.id === moveUpdate.postId); + if (fromIndex !== -1) { + const [movedPost] = newPosts.splice(fromIndex, 1); + newPosts.splice(moveUpdate.toIndex, 0, movedPost); + } + break; + } + + case PostUpdateType.RESET: { + const resetUpdate = update as any; + newPosts = resetUpdate.posts || []; + break; + } + } + } + + // 更新差异统计 + if (addedCount > 0 || updatedCount > 0 || deletedCount > 0) { + setDiffUpdates(prev => ({ + addedCount: prev.addedCount + addedCount, + updatedCount: prev.updatedCount + updatedCount, + deletedCount: prev.deletedCount + deletedCount, + lastUpdateTime: Date.now(), + })); + } + + return newPosts; + }); + } finally { + setIsProcessing(false); + setPendingUpdateCount(batcherRef.current?.getPendingCount() ?? 0); + } + }, []); + + // ==================== 差异计算 ==================== + + /** + * 计算并应用差异 + */ + const calculateAndApplyDiff = useCallback((newPosts: T[]) => { + const previousPosts = previousPostsRef.current; + + // 如果帖子数量变化太大,直接重置 + const previousCount = previousPosts.length; + const currentCount = newPosts.length; + const changeRatio = previousCount === 0 ? 1 : Math.abs(currentCount - previousCount) / previousCount; + + if (changeRatio > changeRatioThreshold) { + setPosts(newPosts); + previousPostsRef.current = newPosts; + return; + } + + // 使用差异计算 + if (enableDiff && calculatorRef.current) { + const diff = calculatorRef.current.calculateDiff(previousPosts, newPosts); + + if (diff.shouldReset) { + setPosts(newPosts); + } else { + // 将差异转换为更新操作 + const updates: PostUpdate[] = []; + + // 处理新增 + if (diff.added.length > 0) { + if (diff.added.length === 1) { + updates.push({ + type: PostUpdateType.ADD, + post: diff.added[0], + timestamp: Date.now(), + }); + } else { + updates.push({ + type: PostUpdateType.BATCH_ADD, + posts: diff.added, + timestamp: Date.now(), + }); + } + } + + // 处理更新 + if (diff.updated.length > 0) { + if (diff.updated.length === 1) { + updates.push({ + type: PostUpdateType.UPDATE, + postId: diff.updated[0].post.id, + updates: diff.updated[0].changes, + timestamp: Date.now(), + }); + } else { + updates.push({ + type: PostUpdateType.BATCH_UPDATE, + updates: diff.updated.map(u => ({ + postId: u.post.id, + changes: u.changes, + })), + timestamp: Date.now(), + }); + } + } + + // 处理删除 + if (diff.deleted.length > 0) { + if (diff.deleted.length === 1) { + updates.push({ + type: PostUpdateType.DELETE, + postId: diff.deleted[0].post.id, + timestamp: Date.now(), + }); + } else { + updates.push({ + type: PostUpdateType.BATCH_DELETE, + postIds: diff.deleted.map(d => d.post.id), + timestamp: Date.now(), + }); + } + } + + // 处理移动 + if (diff.moved.length > 0) { + for (const move of diff.moved) { + updates.push({ + type: PostUpdateType.MOVE, + postId: move.post.id, + toIndex: move.toIndex, + timestamp: Date.now(), + }); + } + } + + // 应用更新 + if (updates.length > 0) { + if (enableBatching && batcherRef.current) { + batcherRef.current.addUpdates(updates); + } else { + handleBatchUpdates(updates); + } + } + } + } else { + // 不使用差异计算,直接设置 + setPosts(newPosts); + } + + // 更新帖子数量限制检查 + if (newPosts.length > maxPostCount) { + console.warn(`[useDifferentialPosts] Post count (${newPosts.length}) exceeds limit (${maxPostCount})`); + } + + previousPostsRef.current = newPosts; + }, [enableDiff, enableBatching, maxPostCount, changeRatioThreshold, handleBatchUpdates]); + + // ==================== UseCase 订阅 ==================== + + /** + * 处理 UseCase 事件 + */ + const handleUseCaseEvent = useCallback((event: PostUseCaseEvent) => { + switch (event.type) { + case 'state_changed': { + const { state } = event.payload as { key: string; state: PostsState }; + if (state) { + setLoading(state.isLoading); + setRefreshing(state.isRefreshing); + setError(state.error); + setHasMore(state.hasMore); + + // 如果帖子列表有变化,进行差异计算 + if (state.posts && state.posts.length > 0) { + calculateAndApplyDiff(state.posts as unknown as T[]); + } + } + break; + } + + case 'posts_loaded': { + const { posts: loadedPosts } = event.payload; + if (loadedPosts) { + calculateAndApplyDiff(loadedPosts as T[]); + } + break; + } + + case 'post_created': { + const { post } = event.payload; + if (post) { + // 新帖子添加到列表开头 + setPosts(prev => [post as T, ...prev]); + setDiffUpdates(prev => ({ + ...prev, + addedCount: prev.addedCount + 1, + lastUpdateTime: Date.now(), + })); + } + break; + } + + case 'post_updated': { + const { post } = event.payload; + if (post) { + setPosts(prev => prev.map(p => p.id === post.id ? { ...p, ...post } as T : p)); + setDiffUpdates(prev => ({ + ...prev, + updatedCount: prev.updatedCount + 1, + lastUpdateTime: Date.now(), + })); + } + break; + } + + case 'post_deleted': { + const { postId } = event.payload; + if (postId) { + setPosts(prev => prev.filter(p => p.id !== postId)); + setDiffUpdates(prev => ({ + ...prev, + deletedCount: prev.deletedCount + 1, + lastUpdateTime: Date.now(), + })); + } + break; + } + + case 'loading_changed': { + const { isLoading } = event.payload; + setLoading(isLoading); + break; + } + + case 'error_occurred': { + const { error: errorMsg } = event.payload; + setError(errorMsg); + break; + } + } + }, [calculateAndApplyDiff]); + + // 订阅 UseCase 状态变化 + useEffect(() => { + if (autoSubscribe) { + unsubscribeRef.current = processPostUseCase.subscribe(handleUseCaseEvent); + + // 初始化时获取当前状态 + const currentState = processPostUseCase.getPostsState(listKey); + if (currentState.posts.length > 0) { + setPosts(currentState.posts as unknown as T[]); + previousPostsRef.current = currentState.posts as unknown as T[]; + } + setLoading(currentState.isLoading); + setRefreshing(currentState.isRefreshing); + setError(currentState.error); + setHasMore(currentState.hasMore); + + return () => { + if (unsubscribeRef.current) { + unsubscribeRef.current(); + unsubscribeRef.current = null; + } + }; + } + }, [autoSubscribe, listKey, handleUseCaseEvent]); + + // ==================== 操作方法 ==================== + + /** + * 刷新帖子列表 + */ + const refresh = useCallback(async () => { + setRefreshing(true); + setError(null); + try { + await processPostUseCase.refreshPosts(listKey); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : '刷新失败'; + setError(errorMsg); + } finally { + setRefreshing(false); + } + }, [listKey]); + + /** + * 加载更多帖子 + */ + const loadMore = useCallback(async () => { + if (hasMore && !loading) { + setLoading(true); + setError(null); + try { + await processPostUseCase.loadMorePosts(listKey); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : '加载更多失败'; + setError(errorMsg); + } finally { + setLoading(false); + } + } + }, [listKey, hasMore, loading]); + + /** + * 手动刷新批量处理队列 + */ + const flush = useCallback(() => { + batcherRef.current?.flush(); + }, []); + + /** + * 重置方法 + */ + const reset = useCallback(() => { + calculatorRef.current?.reset(); + batcherRef.current?.clearPending(); + setPosts([]); + setLoading(false); + setRefreshing(false); + setError(null); + setHasMore(true); + previousPostsRef.current = []; + setDiffUpdates({ + addedCount: 0, + updatedCount: 0, + deletedCount: 0, + lastUpdateTime: 0, + }); + }, []); + + /** + * 强制更新方法 + */ + const forceUpdate = useCallback((newPosts: T[]) => { + setPosts(newPosts); + previousPostsRef.current = newPosts; + }, []); + + /** + * 获取统计信息 + */ + const getDiffStats = useCallback(() => { + const batcherStats = batcherRef.current?.getStats(); + return { + totalBatches: batcherStats?.totalBatches ?? 0, + totalUpdates: batcherStats?.totalUpdates ?? 0, + averageBatchSize: batcherStats?.averageBatchSize ?? 0, + }; + }, []); + + // ==================== 定期更新待处理数量 ==================== + + useEffect(() => { + if (!enableBatching || !batcherRef.current) return; + + const interval = setInterval(() => { + setPendingUpdateCount(batcherRef.current?.getPendingCount() ?? 0); + }, 50); + + return () => clearInterval(interval); + }, [enableBatching]); + + // ==================== 返回值 ==================== + + return { + posts, + loading, + refreshing, + error, + hasMore, + pendingUpdateCount, + isProcessing, + refresh, + loadMore, + flush, + reset, + forceUpdate, + getDiffStats, + diffUpdates, + }; +} + +export default useDifferentialPosts; diff --git a/src/infrastructure/diff/PostDiffCalculator.ts b/src/infrastructure/diff/PostDiffCalculator.ts new file mode 100644 index 0000000..cc6f8bd --- /dev/null +++ b/src/infrastructure/diff/PostDiffCalculator.ts @@ -0,0 +1,430 @@ +/** + * 帖子差异计算器 + * 计算两个帖子列表之间的差异,支持增量更新 + */ + +import { + PostIdentifier, + PostDiffResult, + PostDiffConfig, + DEFAULT_POST_DIFF_CONFIG, + PostComparator, + PostSorter, + PostDiffStats, +} from './postTypes'; + +/** + * 帖子差异计算器类 + * 用于高效计算帖子列表的变化 + */ +export class PostDiffCalculator { + private config: Required; + private previousPosts: Map = new Map(); + private previousOrder: string[] = []; + private stats: PostDiffStats = { + totalBatches: 0, + totalUpdates: 0, + averageBatchSize: 0, + lastBatchTime: 0, + addedCount: 0, + updatedCount: 0, + deletedCount: 0, + }; + + constructor(config: PostDiffConfig = {}) { + this.config = { ...DEFAULT_POST_DIFF_CONFIG, ...config }; + } + + /** + * 计算两个帖子列表的差异 + * @param oldList 旧帖子列表 + * @param newList 新帖子列表 + * @returns 差异结果 + */ + calculateDiff(oldList: T[], newList: T[]): PostDiffResult { + const result: PostDiffResult = { + added: [], + updated: [], + deleted: [], + moved: [], + unchanged: [], + shouldReset: false, + }; + + // 如果旧列表为空,直接返回新列表作为新增 + if (oldList.length === 0) { + result.added = [...newList]; + this.updateCache(newList); + this.updateStats(0, newList.length, 0); + return result; + } + + // 如果新列表为空,表示全部删除 + if (newList.length === 0) { + result.deleted = oldList.map((post, index) => ({ post, index })); + this.clearCache(); + this.updateStats(0, 0, oldList.length); + return result; + } + + // 检查是否需要重置(变化太大) + const changeRatio = this.calculateChangeRatio(oldList, newList); + if (changeRatio > this.config.resetThreshold) { + result.shouldReset = true; + result.added = [...newList]; + this.updateCache(newList); + this.updateStats(0, newList.length, 0); + return result; + } + + // 构建旧列表的索引映射 + const oldIndexMap = new Map(); + oldList.forEach((post, index) => { + oldIndexMap.set(post.id, index); + }); + + // 构建新列表的索引映射 + const newIndexMap = new Map(); + newList.forEach((post, index) => { + newIndexMap.set(post.id, index); + }); + + // 检测新增、更新和未变更的帖子 + const processedIds = new Set(); + let addedCount = 0; + let updatedCount = 0; + + for (let newIndex = 0; newIndex < newList.length; newIndex++) { + const newPost = newList[newIndex]; + const oldIndex = oldIndexMap.get(newPost.id); + + if (oldIndex === undefined) { + // 新增帖子 + result.added.push(newPost); + addedCount++; + } else { + const oldPost = oldList[oldIndex]; + processedIds.add(newPost.id); + + // 检测是否有更新 + const changes = this.detectChanges(oldPost, newPost); + if (changes && Object.keys(changes).length > 0) { + result.updated.push({ + post: newPost, + index: oldIndex, + changes, + }); + updatedCount++; + } else { + result.unchanged.push(newPost); + } + + // 检测是否移动 + if (oldIndex !== newIndex) { + result.moved.push({ + post: newPost, + fromIndex: oldIndex, + toIndex: newIndex, + }); + } + } + } + + // 检测删除的帖子 + let deletedCount = 0; + for (let oldIndex = 0; oldIndex < oldList.length; oldIndex++) { + const oldPost = oldList[oldIndex]; + if (!processedIds.has(oldPost.id)) { + result.deleted.push({ post: oldPost, index: oldIndex }); + deletedCount++; + } + } + + this.updateCache(newList); + this.updateStats(addedCount, updatedCount, deletedCount); + return result; + } + + /** + * 计算增量差异(基于缓存的上次状态) + * @param newList 新帖子列表 + * @returns 差异结果 + */ + calculateIncrementalDiff(newList: T[]): PostDiffResult { + const oldList = Array.from(this.previousPosts.values()); + // 按照之前的顺序排序 + oldList.sort((a, b) => { + const indexA = this.previousOrder.indexOf(a.id); + const indexB = this.previousOrder.indexOf(b.id); + return indexA - indexB; + }); + return this.calculateDiff(oldList, newList); + } + + /** + * 计算单个帖子的变化 + * @param oldPost 旧帖子 + * @param newPost 新帖子 + * @returns 变化的字段 + */ + detectChanges(oldPost: T, newPost: T): Partial | null { + const changes: Partial = {}; + let hasChanges = false; + + // 如果配置了追踪特定字段,只检测这些字段 + const fieldsToCheck = this.config.detectPartialUpdates + ? this.config.trackedFields + : (Object.keys(newPost) as Array); + + for (const key of fieldsToCheck) { + if (key === 'id') continue; // ID 不变 + + const oldValue = (oldPost as any)[key]; + const newValue = (newPost as any)[key]; + + // 深度比较 + if (this.hasValueChanged(oldValue, newValue)) { + (changes as any)[key] = newValue; + hasChanges = true; + } + } + + return hasChanges ? changes : null; + } + + /** + * 检测值是否变化 + * @param oldValue 旧值 + * @param newValue 新值 + * @returns 是否变化 + */ + private hasValueChanged(oldValue: any, newValue: any): boolean { + // 简单类型直接比较 + if (typeof oldValue !== 'object' || oldValue === null || newValue === null) { + return oldValue !== newValue; + } + + // 数组比较 + if (Array.isArray(oldValue) && Array.isArray(newValue)) { + if (oldValue.length !== newValue.length) return true; + return JSON.stringify(oldValue) !== JSON.stringify(newValue); + } + + // 对象比较 + return JSON.stringify(oldValue) !== JSON.stringify(newValue); + } + + /** + * 计算变化比例 + * @param oldList 旧列表 + * @param newList 新列表 + * @returns 变化比例(0-1) + */ + private calculateChangeRatio(oldList: T[], newList: T[]): number { + if (oldList.length === 0 && newList.length === 0) return 0; + if (oldList.length === 0 || newList.length === 0) return 1; + + const oldIds = new Set(oldList.map(p => p.id)); + const newIds = new Set(newList.map(p => p.id)); + + let commonCount = 0; + oldIds.forEach(id => { + if (newIds.has(id)) { + commonCount++; + } + }); + + const maxLength = Math.max(oldList.length, newList.length); + return 1 - commonCount / maxLength; + } + + /** + * 更新缓存 + * @param posts 帖子列表 + */ + private updateCache(posts: T[]): void { + this.previousPosts.clear(); + this.previousOrder = []; + + for (const post of posts) { + this.previousPosts.set(post.id, post); + this.previousOrder.push(post.id); + } + } + + /** + * 清除缓存 + */ + private clearCache(): void { + this.previousPosts.clear(); + this.previousOrder = []; + } + + /** + * 更新统计信息 + */ + private updateStats(addedCount: number, updatedCount: number, deletedCount: number): void { + this.stats.totalBatches++; + const totalChanges = addedCount + updatedCount + deletedCount; + this.stats.totalUpdates += totalChanges; + this.stats.averageBatchSize = this.stats.totalUpdates / this.stats.totalBatches; + this.stats.lastBatchTime = Date.now(); + this.stats.addedCount += addedCount; + this.stats.updatedCount += updatedCount; + this.stats.deletedCount += deletedCount; + } + + /** + * 设置配置 + * @param config 配置 + */ + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + /** + * 获取当前配置 + * @returns 当前配置 + */ + getConfig(): Required { + return { ...this.config }; + } + + /** + * 重置计算器状态 + */ + reset(): void { + this.clearCache(); + this.stats = { + totalBatches: 0, + totalUpdates: 0, + averageBatchSize: 0, + lastBatchTime: 0, + addedCount: 0, + updatedCount: 0, + deletedCount: 0, + }; + } + + /** + * 获取缓存的帖子数量 + * @returns 缓存数量 + */ + getCachedCount(): number { + return this.previousPosts.size; + } + + /** + * 检查帖子是否在缓存中 + * @param postId 帖子 ID + * @returns 是否存在 + */ + hasPost(postId: string): boolean { + return this.previousPosts.has(postId); + } + + /** + * 获取缓存中的帖子 + * @param postId 帖子 ID + * @returns 帖子或 undefined + */ + getCachedPost(postId: string): T | undefined { + return this.previousPosts.get(postId); + } + + /** + * 获取统计信息 + * @returns 统计信息 + */ + getStats(): PostDiffStats { + return { ...this.stats }; + } + + /** + * 批量检测帖子变化 + * @param posts 要检测的帖子列表 + * @returns 变化的帖子列表 + */ + batchDetectChanges(posts: T[]): Array<{ post: T; changes: Partial }> { + const results: Array<{ post: T; changes: Partial }> = []; + + for (const post of posts) { + const cachedPost = this.previousPosts.get(post.id); + if (cachedPost) { + const changes = this.detectChanges(cachedPost, post); + if (changes && Object.keys(changes).length > 0) { + results.push({ post, changes }); + } + } + } + + return results; + } + + /** + * 比较两个帖子列表是否相同 + * @param list1 列表1 + * @param list2 列表2 + * @returns 是否相同 + */ + areEqual(list1: T[], list2: T[]): boolean { + if (list1.length !== list2.length) return false; + + for (let i = 0; i < list1.length; i++) { + if (list1[i].id !== list2[i].id) return false; + } + + return true; + } + + /** + * 获取两个帖子列表的交集ID + * @param list1 列表1 + * @param list2 列表2 + * @returns 交集ID集合 + */ + getIntersection(list1: T[], list2: T[]): Set { + const ids1 = new Set(list1.map(p => p.id)); + const ids2 = new Set(list2.map(p => p.id)); + const intersection = new Set(); + + ids1.forEach(id => { + if (ids2.has(id)) { + intersection.add(id); + } + }); + + return intersection; + } + + /** + * 获取两个帖子列表的差集ID + * @param list1 列表1 + * @param list2 列表2 + * @returns 差集ID集合(在list1中但不在list2中) + */ + getDifference(list1: T[], list2: T[]): Set { + const ids1 = new Set(list1.map(p => p.id)); + const ids2 = new Set(list2.map(p => p.id)); + const difference = new Set(); + + ids1.forEach(id => { + if (!ids2.has(id)) { + difference.add(id); + } + }); + + return difference; + } +} + +/** + * 创建帖子差异计算器实例的工厂函数 + * @param config 配置 + * @returns PostDiffCalculator 实例 + */ +export function createPostDiffCalculator( + config?: PostDiffConfig +): PostDiffCalculator { + return new PostDiffCalculator(config); +} diff --git a/src/infrastructure/diff/PostUpdateBatcher.ts b/src/infrastructure/diff/PostUpdateBatcher.ts new file mode 100644 index 0000000..dafa49b --- /dev/null +++ b/src/infrastructure/diff/PostUpdateBatcher.ts @@ -0,0 +1,505 @@ +/** + * 帖子更新批量处理器 + * 收集更新操作并按批处理,减少 UI 更新次数 + */ + +import { + PostUpdate, + PostUpdateType, + PostBatchUpdateListener, + PostDiffConfig, + DEFAULT_POST_DIFF_CONFIG, + PostIdentifier, + PostUpdateBatch, +} from './postTypes'; + +/** + * 批量处理器配置选项 + */ +export interface PostBatcherOptions { + /** 批量处理间隔(毫秒),默认 16ms(约 60fps) */ + batchInterval?: number; + /** 最大批量大小,默认 50 */ + maxBatchSize?: number; + /** 是否启用去重,默认 true */ + enableDeduplication?: boolean; + /** 是否合并相同帖子的多次更新,默认 true */ + enableMerge?: boolean; + /** 最大等待时间(毫秒),超过此时间强制刷新,默认 100ms */ + maxWaitTime?: number; + /** 是否自动启动批量处理,默认 true */ + autoStart?: boolean; +} + +/** + * 默认批量处理器配置 + */ +export const DEFAULT_POST_BATCHER_OPTIONS: Required = { + batchInterval: 16, + maxBatchSize: 50, + enableDeduplication: true, + enableMerge: true, + maxWaitTime: 100, + autoStart: true, +}; + +/** + * 帖子更新批量处理器类 + */ +export class PostUpdateBatcher { + /** 待处理的更新队列 */ + private pendingUpdates: Map = new Map(); + /** 批量处理定时器 */ + private batchTimer: ReturnType | null = null; + /** 最大等待时间定时器 */ + private maxWaitTimer: ReturnType | null = null; + /** 监听器集合 */ + private listeners: Set = new Set(); + /** 是否正在批处理中 */ + private isProcessing: boolean = false; + /** 配置选项 */ + private options: Required; + /** 处理器是否已启动 */ + private isStarted: boolean = false; + /** 批量处理统计信息 */ + private stats = { + totalBatches: 0, + totalUpdates: 0, + averageBatchSize: 0, + lastBatchTime: 0, + }; + /** 批次 ID 计数器 */ + private batchIdCounter: number = 0; + + constructor(options: PostBatcherOptions = {}) { + this.options = { ...DEFAULT_POST_BATCHER_OPTIONS, ...options }; + if (this.options.autoStart) { + this.start(); + } + } + + /** + * 启动批量处理器 + */ + start(): void { + if (this.isStarted) return; + this.isStarted = true; + this.scheduleBatch(); + } + + /** + * 停止批量处理器 + */ + stop(): void { + this.isStarted = false; + this.clearTimers(); + } + + /** + * 添加更新操作到批量队列 + * @param update 帖子更新操作 + */ + addUpdate(update: PostUpdate): void { + const key = this.generateUpdateKey(update); + + // 去重:如果已有相同帖子 ID 的待处理更新,进行合并 + if (this.options.enableDeduplication && this.pendingUpdates.has(key)) { + const existing = this.pendingUpdates.get(key)!; + + if (this.options.enableMerge) { + const merged = this.mergeUpdates(existing, update); + if (merged) { + this.pendingUpdates.set(key, merged); + return; + } + } + } + + this.pendingUpdates.set(key, update); + + // 如果达到最大批量大小,立即刷新 + if (this.pendingUpdates.size >= this.options.maxBatchSize) { + this.flush(); + } + } + + /** + * 添加多个更新操作 + * @param updates 更新操作数组 + */ + addUpdates(updates: PostUpdate[]): void { + for (const update of updates) { + this.addUpdate(update); + } + } + + /** + * 立即刷新所有待处理更新 + * @returns 处理的更新数组 + */ + flush(): PostUpdate[] { + if (this.pendingUpdates.size === 0 || this.isProcessing) { + return []; + } + + this.isProcessing = true; + this.clearTimers(); + + const updates = Array.from(this.pendingUpdates.values()); + this.pendingUpdates.clear(); + + // 更新统计信息 + this.stats.totalBatches++; + this.stats.totalUpdates += updates.length; + this.stats.averageBatchSize = this.stats.totalUpdates / this.stats.totalBatches; + this.stats.lastBatchTime = Date.now(); + + // 通知监听器 + this.notifyListeners(updates); + + this.isProcessing = false; + + // 重新调度定时器 + this.scheduleBatch(); + + return updates; + } + + /** + * 刷新并返回批次对象 + * @returns 批次对象或 null + */ + flushAsBatch(): PostUpdateBatch | null { + const updates = this.flush(); + if (updates.length === 0) return null; + + return { + batchId: `batch_${++this.batchIdCounter}_${Date.now()}`, + updates, + createdAt: Date.now(), + processed: false, + }; + } + + /** + * 订阅批量更新事件 + * @param listener 监听器函数 + * @returns 取消订阅函数 + */ + subscribe(listener: PostBatchUpdateListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * 取消订阅 + * @param listener 监听器函数 + */ + unsubscribe(listener: PostBatchUpdateListener): void { + this.listeners.delete(listener); + } + + /** + * 获取待处理更新数量 + * @returns 待处理数量 + */ + getPendingCount(): number { + return this.pendingUpdates.size; + } + + /** + * 获取统计信息 + * @returns 统计信息 + */ + getStats() { + return { ...this.stats }; + } + + /** + * 检查是否有待处理更新 + * @returns 是否有待处理更新 + */ + hasPendingUpdates(): boolean { + return this.pendingUpdates.size > 0; + } + + /** + * 清空所有待处理更新 + */ + clearPending(): void { + this.pendingUpdates.clear(); + this.clearTimers(); + } + + /** + * 销毁批量处理器 + */ + destroy(): void { + this.stop(); + this.clearPending(); + this.listeners.clear(); + } + + /** + * 生成更新键值 + * @param update 更新操作 + * @returns 键值 + */ + private generateUpdateKey(update: PostUpdate): string { + // 根据更新类型生成唯一键 + const updateType = (update as PostUpdate).type; + switch (updateType) { + case PostUpdateType.ADD: + case PostUpdateType.BATCH_ADD: + return `${updateType}_${(update as any).timestamp}`; + case PostUpdateType.UPDATE: + case PostUpdateType.BATCH_UPDATE: + return `${updateType}_${this.extractPostId(update)}`; + case PostUpdateType.DELETE: + case PostUpdateType.BATCH_DELETE: + return `${updateType}_${this.extractPostId(update)}`; + case PostUpdateType.MOVE: + return `${updateType}_${(update as any).postId}`; + case PostUpdateType.RESET: + return `${updateType}_${(update as any).timestamp}`; + default: + // 兜底处理未知类型 + return `unknown_${Date.now()}_${Math.random()}`; + } + } + + /** + * 提取帖子 ID + * @param update 更新操作 + * @returns 帖子 ID 或空字符串 + */ + private extractPostId(update: PostUpdate): string { + const payload = (update as any).payload; + if (payload) { + if (payload.postId) return payload.postId; + if (payload.postIds) return payload.postIds.join(','); + } + // 直接从更新对象提取 + if ((update as any).postId) return (update as any).postId; + if ((update as any).postIds) return (update as any).postIds.join(','); + return ''; + } + + /** + * 合并更新操作 + * @param existing 已有更新 + * @param incoming 新更新 + * @returns 合并后的更新或 null(如果无法合并) + */ + private mergeUpdates(existing: PostUpdate, incoming: PostUpdate): PostUpdate | null { + // 只有同类型的更新才能合并 + if (existing.type !== incoming.type) { + return null; + } + + switch (existing.type) { + case PostUpdateType.UPDATE: + // 合并更新字段 + return { + ...existing, + payload: { + ...(existing as any).payload, + updates: { + ...(existing as any).payload?.updates, + ...(incoming as any).payload?.updates, + }, + }, + timestamp: incoming.timestamp, + }; + + case PostUpdateType.BATCH_UPDATE: + // 合并批量更新 + const existingUpdates = (existing as any).payload?.updates || []; + const incomingUpdates = (incoming as any).payload?.updates || []; + const updatesMap = new Map(); + + for (const update of [...existingUpdates, ...incomingUpdates]) { + const existing = updatesMap.get(update.postId); + if (existing) { + existing.changes = { ...existing.changes, ...update.changes }; + } else { + updatesMap.set(update.postId, { ...update }); + } + } + + return { + ...existing, + payload: { + ...(existing as any).payload, + updates: Array.from(updatesMap.values()), + }, + timestamp: incoming.timestamp, + }; + + case PostUpdateType.BATCH_DELETE: + // 合并删除 ID 列表 + const existingIds = new Set((existing as any).payload?.postIds || []); + const incomingIds = (incoming as any).payload?.postIds || []; + for (const id of incomingIds) { + existingIds.add(id); + } + return { + ...existing, + payload: { + ...(existing as any).payload, + postIds: Array.from(existingIds), + }, + timestamp: incoming.timestamp, + }; + + default: + return null; + } + } + + /** + * 调度批量处理 + */ + private scheduleBatch(): void { + if (!this.isStarted) return; + + this.clearTimers(); + + // 设置最大等待时间定时器 + this.maxWaitTimer = setTimeout(() => { + this.flush(); + }, this.options.maxWaitTime); + + // 设置批量处理定时器 + this.batchTimer = setTimeout(() => { + this.flush(); + }, this.options.batchInterval); + } + + /** + * 清除所有定时器 + */ + private clearTimers(): void { + if (this.batchTimer) { + clearTimeout(this.batchTimer); + this.batchTimer = null; + } + if (this.maxWaitTimer) { + clearTimeout(this.maxWaitTimer); + this.maxWaitTimer = null; + } + } + + /** + * 通知监听器 + * @param updates 更新数组 + */ + private notifyListeners(updates: PostUpdate[]): void { + if (this.listeners.size === 0) return; + + // 转换为数组以支持迭代 + const listenersArray = Array.from(this.listeners); + for (const listener of listenersArray) { + try { + listener(updates); + } catch (error) { + console.error('Error in post batch update listener:', error); + } + } + } + + // ==================== 便捷方法 ==================== + + /** + * 添加帖子新增操作 + */ + addPost(post: PostIdentifier, index?: number): void { + this.addUpdate({ + type: PostUpdateType.ADD, + post, + index, + timestamp: Date.now(), + }); + } + + /** + * 添加帖子更新操作 + */ + updatePost(postId: string, updates: Partial): void { + this.addUpdate({ + type: PostUpdateType.UPDATE, + postId, + updates, + timestamp: Date.now(), + }); + } + + /** + * 添加帖子删除操作 + */ + deletePost(postId: string): void { + this.addUpdate({ + type: PostUpdateType.DELETE, + postId, + timestamp: Date.now(), + }); + } + + /** + * 批量添加帖子 + */ + batchAddPosts(posts: PostIdentifier[], startIndex?: number): void { + this.addUpdate({ + type: PostUpdateType.BATCH_ADD, + posts, + startIndex, + timestamp: Date.now(), + }); + } + + /** + * 批量更新帖子 + */ + batchUpdatePosts(updates: Array<{ postId: string; changes: Partial }>): void { + this.addUpdate({ + type: PostUpdateType.BATCH_UPDATE, + updates, + timestamp: Date.now(), + }); + } + + /** + * 批量删除帖子 + */ + batchDeletePosts(postIds: string[]): void { + this.addUpdate({ + type: PostUpdateType.BATCH_DELETE, + postIds, + timestamp: Date.now(), + }); + } + + /** + * 重置帖子列表 + */ + resetPosts(posts: PostIdentifier[]): void { + this.addUpdate({ + type: PostUpdateType.RESET, + posts, + timestamp: Date.now(), + }); + } +} + +/** + * 创建帖子批量处理器的工厂函数 + * @param options 配置选项 + * @returns PostUpdateBatcher 实例 + */ +export function createPostUpdateBatcher( + options?: PostBatcherOptions +): PostUpdateBatcher { + return new PostUpdateBatcher(options); +} diff --git a/src/infrastructure/diff/index.ts b/src/infrastructure/diff/index.ts index dfa746a..5b4959d 100644 --- a/src/infrastructure/diff/index.ts +++ b/src/infrastructure/diff/index.ts @@ -1,8 +1,10 @@ /** * 差异更新模块 - * 提供消息列表的增量更新功能,减少 UI 重渲染次数 + * 提供消息列表和帖子列表的增量更新功能,减少 UI 重渲染次数 */ +// ==================== Message 相关 ==================== + // 类型定义 export { MessageUpdateType, @@ -38,3 +40,45 @@ export { BatcherOptions, DEFAULT_BATCHER_OPTIONS, } from './MessageUpdateBatcher'; + +// ==================== Post 相关 ==================== + +// 类型定义 +export { + PostChangeType, + PostUpdateType, + PostIdentifier, + PostChange, + PostDiffResult, + BasePostUpdate, + AddPostUpdate, + UpdatePostUpdate, + DeletePostUpdate, + MovePostUpdate, + BatchAddPostUpdate, + BatchUpdatePostUpdate, + BatchDeletePostUpdate, + ResetPostUpdate, + PostUpdate, + PostUpdateBatch, + PostDiffConfig, + PostBatchUpdateListener, + PostComparator, + PostSorter, + PostDiffStats, + DEFAULT_POST_DIFF_CONFIG, +} from './postTypes'; + +// 差异计算器 +export { + PostDiffCalculator, + createPostDiffCalculator, +} from './PostDiffCalculator'; + +// 批量处理器 +export { + PostUpdateBatcher, + createPostUpdateBatcher, + PostBatcherOptions, + DEFAULT_POST_BATCHER_OPTIONS, +} from './PostUpdateBatcher'; diff --git a/src/infrastructure/diff/postTypes.ts b/src/infrastructure/diff/postTypes.ts new file mode 100644 index 0000000..0968b83 --- /dev/null +++ b/src/infrastructure/diff/postTypes.ts @@ -0,0 +1,325 @@ +/** + * Post差异更新类型定义 + * 用于帖子列表的增量更新,减少 UI 重渲染次数 + */ + +import { Post } from '../../core/entities/Post'; + +// ==================== 变化类型枚举 ==================== + +/** + * 帖子变化类型枚举 + */ +export enum PostChangeType { + /** 新增帖子 */ + ADDED = 'added', + /** 更新帖子 */ + UPDATED = 'updated', + /** 删除帖子 */ + DELETED = 'deleted', +} + +/** + * 帖子更新类型枚举 + */ +export enum PostUpdateType { + /** 添加单条帖子 */ + ADD = 'ADD', + /** 更新单条帖子 */ + UPDATE = 'UPDATE', + /** 删除单条帖子 */ + DELETE = 'DELETE', + /** 移动帖子位置 */ + MOVE = 'MOVE', + /** 批量添加 */ + BATCH_ADD = 'BATCH_ADD', + /** 批量更新 */ + BATCH_UPDATE = 'BATCH_UPDATE', + /** 批量删除 */ + BATCH_DELETE = 'BATCH_DELETE', + /** 重置整个列表 */ + RESET = 'RESET', +} + +// ==================== 基础接口 ==================== + +/** + * 帖子唯一标识接口 + */ +export interface PostIdentifier { + /** 帖子唯一 ID */ + id: string; + /** 帖子创建时间(可选,用于排序) */ + createdAt?: string; + /** 更新时间(可选,用于检测变化) */ + updatedAt?: string; +} + +/** + * 单个帖子变化记录 + */ +export interface PostChange { + /** 变化类型 */ + type: PostChangeType; + /** 帖子数据 */ + post: T; + /** 变化前的索引位置(用于删除和移动) */ + oldIndex?: number; + /** 变化后的索引位置(用于新增和移动) */ + newIndex?: number; + /** 变化的字段(用于更新) */ + changes?: Partial; +} + +// ==================== 差异结果接口 ==================== + +/** + * Post差异计算结果接口 + */ +export interface PostDiffResult { + /** 新增的帖子 */ + added: T[]; + /** 更新的帖子(包含变化详情) */ + updated: Array<{ + post: T; + index: number; + changes: Partial; + }>; + /** 删除的帖子 */ + deleted: Array<{ + post: T; + index: number; + }>; + /** 移动的帖子 */ + moved: Array<{ + post: T; + fromIndex: number; + toIndex: number; + }>; + /** 未变更的帖子 */ + unchanged: T[]; + /** 是否需要重置(变化比例过大时) */ + shouldReset: boolean; +} + +// ==================== 更新操作接口 ==================== + +/** + * 基础帖子更新操作接口 + */ +export interface BasePostUpdate { + /** 更新类型 */ + type: PostUpdateType; + /** 更新时间戳 */ + timestamp: number; + /** 社区 ID(可选) */ + communityId?: string; + /** 载荷数据(可选,用于合并更新) */ + payload?: Record; +} + +/** + * 添加单条帖子更新 + */ +export interface AddPostUpdate extends BasePostUpdate { + type: PostUpdateType.ADD; + /** 帖子数据 */ + post: PostIdentifier; + /** 插入位置索引(可选,默认追加到末尾) */ + index?: number; +} + +/** + * 更新单条帖子更新 + */ +export interface UpdatePostUpdate extends BasePostUpdate { + type: PostUpdateType.UPDATE; + /** 帖子 ID */ + postId: string; + /** 更新的字段 */ + updates: Partial; +} + +/** + * 删除单条帖子更新 + */ +export interface DeletePostUpdate extends BasePostUpdate { + type: PostUpdateType.DELETE; + /** 帖子 ID */ + postId: string; +} + +/** + * 移动帖子更新 + */ +export interface MovePostUpdate extends BasePostUpdate { + type: PostUpdateType.MOVE; + /** 帖子 ID */ + postId: string; + /** 目标位置索引 */ + toIndex: number; +} + +/** + * 批量添加帖子更新 + */ +export interface BatchAddPostUpdate extends BasePostUpdate { + type: PostUpdateType.BATCH_ADD; + /** 帖子列表 */ + posts: PostIdentifier[]; + /** 插入位置索引(可选,默认追加到末尾) */ + startIndex?: number; +} + +/** + * 批量更新帖子更新 + */ +export interface BatchUpdatePostUpdate extends BasePostUpdate { + type: PostUpdateType.BATCH_UPDATE; + /** 批量更新项 */ + updates: Array<{ + postId: string; + changes: Partial; + }>; +} + +/** + * 批量删除帖子更新 + */ +export interface BatchDeletePostUpdate extends BasePostUpdate { + type: PostUpdateType.BATCH_DELETE; + /** 要删除的帖子 ID 列表 */ + postIds: string[]; +} + +/** + * 重置帖子列表更新 + */ +export interface ResetPostUpdate extends BasePostUpdate { + type: PostUpdateType.RESET; + /** 新的帖子列表 */ + posts: PostIdentifier[]; +} + +/** + * 帖子更新联合类型 + */ +export type PostUpdate = + | AddPostUpdate + | UpdatePostUpdate + | DeletePostUpdate + | MovePostUpdate + | BatchAddPostUpdate + | BatchUpdatePostUpdate + | BatchDeletePostUpdate + | ResetPostUpdate; + +// ==================== 批量更新接口 ==================== + +/** + * Post批量更新类型 + */ +export interface PostUpdateBatch { + /** 批次 ID */ + batchId: string; + /** 更新列表 */ + updates: PostUpdate[]; + /** 创建时间戳 */ + createdAt: number; + /** 是否已处理 */ + processed: boolean; +} + +// ==================== 配置接口 ==================== + +/** + * Post差异配置接口 + */ +export interface PostDiffConfig { + /** 批量处理间隔(毫秒),默认 16ms(60fps) */ + batchInterval?: number; + /** 最大批量大小,默认 50 */ + maxBatchSize?: number; + /** 是否启用去重,默认 true */ + enableDeduplication?: boolean; + /** 是否合并相同帖子的多次更新,默认 true */ + enableMerge?: boolean; + /** 帖子唯一标识字段,默认 'id' */ + idField?: string; + /** 排序字段,默认 'createdAt' */ + sortField?: string; + /** 变化比例阈值,超过此值触发重置,默认 0.5 */ + resetThreshold?: number; + /** 是否检测部分字段更新 */ + detectPartialUpdates?: boolean; + /** 需要检测变化的字段列表 */ + trackedFields?: (keyof Post)[]; +} + +/** + * 默认Post差异配置常量 + */ +export const DEFAULT_POST_DIFF_CONFIG: Required = { + batchInterval: 16, + maxBatchSize: 50, + enableDeduplication: true, + enableMerge: true, + idField: 'id', + sortField: 'createdAt', + resetThreshold: 0.5, + detectPartialUpdates: true, + trackedFields: [ + 'likesCount', + 'commentsCount', + 'sharesCount', + 'viewsCount', + 'favoritesCount', + 'isLiked', + 'isFavorited', + 'isPinned', + 'status', + 'title', + 'content', + 'images', + 'tags', + ], +}; + +// ==================== 监听器类型 ==================== + +/** + * 批量更新监听器类型 + */ +export type PostBatchUpdateListener = (updates: PostUpdate[]) => void; + +/** + * Post比较函数类型 + */ +export type PostComparator = (a: T, b: T) => boolean; + +/** + * Post排序函数类型 + */ +export type PostSorter = (a: T, b: T) => number; + +// ==================== 统计接口 ==================== + +/** + * Post差异统计接口 + */ +export interface PostDiffStats { + /** 总批次数 */ + totalBatches: number; + /** 总更新数 */ + totalUpdates: number; + /** 平均批量大小 */ + averageBatchSize: number; + /** 上次批次时间 */ + lastBatchTime: number; + /** 新增帖子数 */ + addedCount: number; + /** 更新帖子数 */ + updatedCount: number; + /** 删除帖子数 */ + deletedCount: number; +} \ No newline at end of file diff --git a/src/presentation/hooks/responsive/useBreakpointCheck.ts b/src/presentation/hooks/responsive/useBreakpointCheck.ts deleted file mode 100644 index 14f0da6..0000000 --- a/src/presentation/hooks/responsive/useBreakpointCheck.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * useBreakpointCheck Hook - * 断点检查 - 提供断点范围检查功能 - * @deprecated 请直接使用 useBreakpoint.ts 中的 hooks - */ - -export { useBreakpointGTE, useBreakpointLT, useBreakpointBetween } from './useBreakpoint'; diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 66f37d9..68e3ac6 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -32,8 +32,8 @@ import { PostCard, TabBar, SearchBar } from '../../components/business'; import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common'; import { HomeStackParamList, RootStackParamList } from '../../navigation/types'; import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive'; -import { useCursorPagination } from '../../hooks/useCursorPagination'; -import { CursorPaginationRequest } from '../../types/dto'; +import { useDifferentialPosts } from '../../hooks/useDifferentialPosts'; +import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase'; import { SearchScreen } from './SearchScreen'; import { CreatePostScreen } from '../create/CreatePostScreen'; import { navigationService } from '../../infrastructure/navigation/navigationService'; @@ -55,7 +55,7 @@ type PostType = 'recommend' | 'follow' | 'hot' | 'latest'; export const HomeScreen: React.FC = () => { const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore(); + const { posts: storePosts } = useUserStore(); const currentUser = useCurrentUser(); // 使用响应式 hook @@ -89,6 +89,15 @@ export const HomeScreen: React.FC = () => { const postIdsRef = useRef>(new Set()); const lastSwipeAtRef = useRef(0); + // 构建一个以 postId 为 key 的 map,用于快速查找 + const postsMap = useMemo(() => { + const map = new Map(); + storePosts.forEach(post => { + map.set(post.id, post); + }); + return map; + }, [storePosts]); + // 获取当前 tab 对应的帖子类型 const getPostType = useCallback((): PostType => { switch (activeIndex) { @@ -100,42 +109,70 @@ export const HomeScreen: React.FC = () => { } }, [activeIndex]); - // 使用游标分页获取帖子列表 + // 使用差异更新 Hook 获取帖子列表 + const listKey = useMemo(() => `home_${getPostType()}`, [getPostType]); + const { - items: posts, - isLoading, - isRefreshing, + posts, + loading: isLoading, + refreshing: isRefreshing, hasMore, error, - loadMore, - refresh, - } = useCursorPagination( - async ({ cursor, pageSize, extraParams }) => { - const params: CursorPaginationRequest = { - cursor, - page_size: pageSize, - post_type: extraParams?.post_type, - }; - const response = await postService.getPostsCursor(params); - return response; - }, - { pageSize: DEFAULT_PAGE_SIZE }, - { post_type: getPostType() } + reset, + } = useDifferentialPosts( + [], + { + listKey, + enableDiff: true, + enableBatching: true, + autoSubscribe: true, + } ); - // Tab 切换时刷新数据 + // 加载更多方法 + const loadMore = useCallback(async () => { + if (hasMore && !isLoading) { + try { + await processPostUseCase.loadMorePosts(listKey); + } catch (err) { + console.error('加载更多失败:', err); + } + } + }, [listKey, hasMore, isLoading]); + + // 刷新方法 - 先获取正确类型的帖子,再刷新 + const refresh = useCallback(async () => { + try { + // 先获取正确类型的帖子 + await processPostUseCase.fetchPosts( + { page: 1, pageSize: DEFAULT_PAGE_SIZE, post_type: getPostType() }, + listKey + ); + } catch (err) { + console.error('刷新失败:', err); + } + }, [listKey, getPostType]); + + // Tab 切换时刷新数据并重置列表 useEffect(() => { + reset(); refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeIndex]); - // 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新) - useEffect(() => { - if (posts.length === 0) return; - - // 更新 postIdsRef - const currentPostIds = new Set(posts.map(p => p.id)); - postIdsRef.current = currentPostIds; - }, [posts, storePosts]); + // 将获取到的原始帖子与 store 中的状态合并 + // 这样 UI 始终显示的是 store 中的最新状态(包括点赞、收藏等) + const displayPosts = useMemo(() => { + return posts.map(post => { + const storePost = postsMap.get(post.id); + if (storePost) { + // 如果 store 中有这个帖子,使用 store 的最新状态 + return storePost; + } + // 如果 store 中没有这个帖子,使用原始数据 + return post; + }); + }, [posts, postsMap]); // 根据屏幕尺寸确定网格列数 const gridColumns = useMemo(() => { @@ -242,20 +279,28 @@ export const HomeScreen: React.FC = () => { }; // 点赞帖子 - const handleLike = (post: Post) => { - if (post.is_liked) { - unlikePost(post.id); - } else { - likePost(post.id); + const handleLike = async (post: Post) => { + try { + if (post.is_liked) { + await processPostUseCase.unlikePost(post.id); + } else { + await processPostUseCase.likePost(post.id); + } + } catch (error) { + console.error('点赞操作失败:', error); } }; // 收藏帖子 - const handleBookmark = (post: Post) => { - if (post.is_favorited) { - unfavoritePost(post.id); - } else { - favoritePost(post.id); + const handleBookmark = async (post: Post) => { + try { + if (post.is_favorited) { + await processPostUseCase.unfavoritePost(post.id); + } else { + await processPostUseCase.favoritePost(post.id); + } + } catch (error) { + console.error('收藏操作失败:', error); } }; @@ -272,7 +317,7 @@ export const HomeScreen: React.FC = () => { Alert.alert('已复制', '帖子链接已复制到剪贴板'); }; - // 删除帖子 - 由于 posts 来自 Hook,需要刷新列表 + // 删除帖子 - 删除后刷新列表 const handleDeletePost = async (postId: string) => { try { const success = await postService.deletePost(postId); @@ -377,7 +422,7 @@ export const HomeScreen: React.FC = () => { const columnHeights: number[] = Array(gridColumns).fill(0); // 防御性检查:确保 posts 存在且是数组 - if (!posts || !Array.isArray(posts) || posts.length === 0) { + if (!displayPosts || !Array.isArray(displayPosts) || displayPosts.length === 0) { return columns; } @@ -385,7 +430,7 @@ export const HomeScreen: React.FC = () => { const totalGap = (gridColumns - 1) * responsiveGap; const columnWidth = (width - responsivePadding * 2 - totalGap) / gridColumns; - posts.forEach((post) => { + displayPosts.forEach((post) => { const postHeight = estimatePostHeight(post, columnWidth); // 找到当前高度最小的列 @@ -395,7 +440,7 @@ export const HomeScreen: React.FC = () => { }); return columns; - }, [posts, gridColumns, width, responsiveGap, responsivePadding]); + }, [displayPosts, gridColumns, width, responsiveGap, responsivePadding]); // 渲染单列帖子 const renderWaterfallColumn = (column: Post[], columnIndex: number) => ( @@ -461,7 +506,7 @@ export const HomeScreen: React.FC = () => { gap={{ xs: 8, sm: 12, md: 16, lg: 20, xl: 24 }} containerStyle={{ paddingHorizontal: responsivePadding, paddingBottom: 80 }} > - {posts.map(post => { + {displayPosts.map(post => { const authorId = post.author?.id || ''; const isPostAuthor = currentUser?.id === authorId; return ( @@ -507,7 +552,7 @@ export const HomeScreen: React.FC = () => { // 移动端和宽屏都使用单列 FlatList,宽屏下居中显示 return ( item.id} contentContainerStyle={[ diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index b04f000..6b9b10d 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -32,6 +32,7 @@ import { Post, Comment, VoteResultDTO } from '../../types'; import { useUserStore } from '../../stores'; import { useCurrentUser } from '../../stores/authStore'; import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services'; +import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase'; import { useCursorPagination } from '../../hooks/useCursorPagination'; import { CommentItem, VoteCard } from '../../components/business'; import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout } from '../../components/common'; @@ -62,15 +63,6 @@ export const PostDetailScreen: React.FC = () => { const responsivePadding = useResponsiveSpacing({ xs: 12, sm: 14, md: 16, lg: 20, xl: 24, '2xl': 28, '3xl': 32, '4xl': 40 }); const responsiveGap = useResponsiveSpacing({ xs: 8, sm: 10, md: 12, lg: 16, xl: 20, '2xl': 24, '3xl': 28, '4xl': 32 }); - const { - likePost, - unlikePost, - favoritePost, - unfavoritePost, - likeComment, - unlikeComment - } = useUserStore(); - const currentUser = useCurrentUser(); const [post, setPost] = useState(null); @@ -144,14 +136,15 @@ export const PostDetailScreen: React.FC = () => { } try { - // 从API获取帖子详情 - const postData = await postService.getPost(postId); + // 使用 ProcessPostUseCase 获取帖子详情 + const postData = await processPostUseCase.fetchPostById(postId); if (postData) { - setPost(postData); + // 类型转换:将 core/entities/Post 转换为 PostDTO 以保持兼容性 + setPost(postData as unknown as Post); // 初始化关注状态 if (postData.author) { - setIsFollowing(postData.author.is_following || false); - setIsFollowingMe(postData.author.is_following_me || false); + setIsFollowing((postData.author as any).is_following || false); + setIsFollowingMe((postData.author as any).is_following_me || false); } // 只在首次加载时记录浏览量 if (recordView && !hasRecordedView.current) { @@ -163,7 +156,7 @@ export const PostDetailScreen: React.FC = () => { } // 如果是投票帖子,立即加载投票数据 - if (postData.is_vote) { + if ((postData as any).is_vote) { setIsVoteLoading(true); try { const voteData = await voteService.getVoteResult(postId); @@ -184,8 +177,8 @@ export const PostDetailScreen: React.FC = () => { setPost(updatedPost); // 初始化关注状态 if (updatedPost.author) { - setIsFollowing(updatedPost.author.is_following || false); - setIsFollowingMe(updatedPost.author.is_following_me || false); + setIsFollowing((updatedPost.author as any).is_following || false); + setIsFollowingMe((updatedPost.author as any).is_following_me || false); } } } @@ -361,10 +354,11 @@ export const PostDetailScreen: React.FC = () => { try { if (oldIsLiked) { - await unlikePost(post.id); + await processPostUseCase.unlikePost(post.id); } else { - await likePost(post.id); + await processPostUseCase.likePost(post.id); } + // UseCase 会更新 store,本地状态已经是乐观更新的,无需再次更新 } catch (error) { console.error('点赞操作失败:', error); // 失败时回滚状态 @@ -374,7 +368,7 @@ export const PostDetailScreen: React.FC = () => { likes_count: oldLikesCount } : null); } - }, [post, likePost, unlikePost]); + }, [post]); // 收藏帖子 const handleFavorite = useCallback(async () => { @@ -393,10 +387,11 @@ export const PostDetailScreen: React.FC = () => { try { if (oldIsFavorited) { - await unfavoritePost(post.id); + await processPostUseCase.unfavoritePost(post.id); } else { - await favoritePost(post.id); + await processPostUseCase.favoritePost(post.id); } + // UseCase 会更新 store,本地状态已经是乐观更新的,无需再次更新 } catch (error) { console.error('收藏操作失败:', error); // 失败时回滚状态 @@ -406,7 +401,7 @@ export const PostDetailScreen: React.FC = () => { favorites_count: oldFavoritesCount } : null); } - }, [post, favoritePost, unfavoritePost]); + }, [post]); // 分享帖子 const handleShare = useCallback(async () => { @@ -527,17 +522,13 @@ export const PostDetailScreen: React.FC = () => { onPress: async () => { setIsDeleting(true); try { - const success = await postService.deletePost(post.id); - if (success) { - Alert.alert('删除成功', '帖子已删除', [ - { - text: '确定', - onPress: () => navigation.goBack(), - }, - ]); - } else { - Alert.alert('删除失败', '删除帖子时发生错误,请稍后重试'); - } + await processPostUseCase.deletePost(post.id); + Alert.alert('删除成功', '帖子已删除', [ + { + text: '确定', + onPress: () => navigation.goBack(), + }, + ]); } catch (error) { console.error('删除帖子失败:', error); Alert.alert('删除失败', '删除帖子时发生错误,请稍后重试'); @@ -817,9 +808,9 @@ export const PostDetailScreen: React.FC = () => { try { if (oldIsLiked) { - await unlikeComment(comment.id); + await commentService.unlikeComment(comment.id); } else { - await likeComment(comment.id); + await commentService.likeComment(comment.id); } } catch (error) { console.error('评论点赞操作失败:', error); diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index 84def36..f63b0de 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -265,7 +265,7 @@ export const MessageListScreen: React.FC = () => { }, [isFocused]); // 【新架构】使用focus刷新hook,从ChatScreen返回时自动刷新未读数 - useMessageListRefresh(isFocused); + useMessageListRefresh(); // 同步未读数到userStore(用于TabBar角标显示) useEffect(() => { diff --git a/src/screens/message/NotificationsScreen.tsx b/src/screens/message/NotificationsScreen.tsx index d8ef14b..c239f5f 100644 --- a/src/screens/message/NotificationsScreen.tsx +++ b/src/screens/message/NotificationsScreen.tsx @@ -29,7 +29,8 @@ import { SystemMessageItem } from '../../components/business'; import { Text, EmptyState, ResponsiveContainer } from '../../components/common'; import { useCursorPagination } from '../../hooks/useCursorPagination'; import { RootStackParamList } from '../../navigation/types'; -import { useMessageManagerSystemUnreadCount, useUserStore } from '../../stores'; +import { useMessageManagerSystemUnreadCount } from '../../stores'; +import { messageManager } from '../../stores/messageManager'; const MESSAGE_TYPES = [ { key: 'all', title: '全部' }, @@ -46,7 +47,6 @@ const GROUP_SYSTEM_TYPES = new Set(['group_invite', 'group_join_apply', 'group_j export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack }) => { const isFocused = useIsFocused(); - const fetchMessageUnreadCount = useUserStore(state => state.fetchMessageUnreadCount); const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount(); const navigation = useNavigation>(); @@ -158,11 +158,11 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack setUnreadCount(0); setSystemUnreadCount(0); // 同步更新全局 TabBar 红点 - fetchMessageUnreadCount(); + messageManager.fetchUnreadCount(); } catch (error) { console.error('一键已读失败:', error); } - }, [refresh, fetchMessageUnreadCount, setSystemUnreadCount]); + }, [refresh, setSystemUnreadCount]); // 页面加载和获得焦点时刷新,并自动标记所有消息为已读 // 使用 ref 防止重复执行,避免无限循环 @@ -281,7 +281,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack } // 更新本地未读数以及全局 TabBar 红点 fetchUnreadCount(); - fetchMessageUnreadCount(); + messageManager.fetchUnreadCount(); // 根据消息类型处理导航 const { system_type, extra_data } = message; diff --git a/src/screens/message/components/ChatScreen/MessageBubble.tsx b/src/screens/message/components/ChatScreen/MessageBubble.tsx index bd1ada6..945460d 100644 --- a/src/screens/message/components/ChatScreen/MessageBubble.tsx +++ b/src/screens/message/components/ChatScreen/MessageBubble.tsx @@ -9,8 +9,6 @@ import { View, TouchableOpacity, Image, - findNodeHandle, - UIManager, GestureResponderEvent, StyleSheet, Dimensions, @@ -219,22 +217,17 @@ export const MessageBubble: React.FC = ({ // 处理长按并获取位置 const handleLongPress = () => { if (bubbleRef.current) { - const node = findNodeHandle(bubbleRef.current); - if (node) { - UIManager.measureInWindow(node, (x, y, width, height) => { - const position: MenuPosition = { - x, - y, - width, - height, - pressX: pressPositionRef.current.x, - pressY: pressPositionRef.current.y, - }; - onLongPress(message, position); - }); - } else { - onLongPress(message); - } + bubbleRef.current.measureInWindow((x, y, width, height) => { + const position: MenuPosition = { + x, + y, + width, + height, + pressX: pressPositionRef.current.x, + pressY: pressPositionRef.current.y, + }; + onLongPress(message, position); + }); } else { onLongPress(message); } diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts index 191e43a..e414537 100644 --- a/src/screens/message/components/ChatScreen/types.ts +++ b/src/screens/message/components/ChatScreen/types.ts @@ -73,8 +73,6 @@ export interface MessageBubbleProps { otherUser: { id?: string; nickname?: string; avatar?: string | null } | null; isGroupChat: boolean; groupMembers: GroupMemberResponse[]; - /** @deprecated 发送者信息现在直接从 message.sender 获取,不再使用 senderCache */ - senderCache?: Map; otherUserLastReadSeq: number; selectedMessageId: string | null; // 消息映射,用于查找被回复的消息 diff --git a/src/screens/profile/ProfileScreen.tsx b/src/screens/profile/ProfileScreen.tsx index 165ebac..28bdc3a 100644 --- a/src/screens/profile/ProfileScreen.tsx +++ b/src/screens/profile/ProfileScreen.tsx @@ -49,7 +49,7 @@ export const ProfileScreen: React.FC = () => { // 使用 any 类型来访问根导航 const rootNavigation = useNavigation(); const { currentUser, updateUser, fetchCurrentUser } = useAuthStore(); - const { followUser, unfollowUser, likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore(); + const { followUser, unfollowUser, posts: storePosts } = useUserStore(); // 响应式布局 const { isDesktop, isTablet, width } = useResponsive(); @@ -275,9 +275,9 @@ export const ProfileScreen: React.FC = () => { post={post} onPress={() => handlePostPress(post.id)} onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}} - onLike={() => post.is_liked ? unlikePost(post.id) : likePost(post.id)} + onLike={() => post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id)} onComment={() => handlePostPress(post.id, true)} - onBookmark={() => post.is_favorited ? unfavoritePost(post.id) : favoritePost(post.id)} + onBookmark={() => post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id)} onShare={() => {}} onDelete={() => handleDeletePost(post.id)} isPostAuthor={isPostAuthor} @@ -315,9 +315,9 @@ export const ProfileScreen: React.FC = () => { post={post} onPress={() => handlePostPress(post.id)} onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}} - onLike={() => post.is_liked ? unlikePost(post.id) : likePost(post.id)} + onLike={() => post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id)} onComment={() => handlePostPress(post.id, true)} - onBookmark={() => post.is_favorited ? unfavoritePost(post.id) : favoritePost(post.id)} + onBookmark={() => post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id)} onShare={() => {}} onDelete={() => handleDeletePost(post.id)} isPostAuthor={isPostAuthor} @@ -330,7 +330,7 @@ export const ProfileScreen: React.FC = () => { } return null; - }, [loading, activeTab, posts, favorites, currentUser?.id, handlePostPress, handleUserPress, handleDeletePost, unlikePost, likePost, unfavoritePost, favoritePost]); + }, [loading, activeTab, posts, favorites, currentUser?.id, handlePostPress, handleUserPress, handleDeletePost]); // 渲染用户信息头部 - 不随 tab 变化,使用 useMemo 缓存 const renderUserHeader = useMemo(() => ( diff --git a/src/screens/profile/UserScreen.tsx b/src/screens/profile/UserScreen.tsx index 8febb3e..5383caf 100644 --- a/src/screens/profile/UserScreen.tsx +++ b/src/screens/profile/UserScreen.tsx @@ -41,7 +41,7 @@ export const UserScreen: React.FC = () => { // 使用 any 类型来访问根导航 const rootNavigation = useNavigation>(); - const { followUser, unfollowUser, likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore(); + const { followUser, unfollowUser, posts: storePosts } = useUserStore(); const currentUser = useCurrentUser(); // 响应式布局 @@ -319,9 +319,9 @@ export const UserScreen: React.FC = () => { post={post} onPress={() => handlePostPress(post.id)} onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}} - onLike={() => post.is_liked ? unlikePost(post.id) : likePost(post.id)} + onLike={() => post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id)} onComment={() => handlePostPress(post.id, true)} - onBookmark={() => post.is_favorited ? unfavoritePost(post.id) : favoritePost(post.id)} + onBookmark={() => post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id)} onShare={() => {}} onDelete={() => handleDeletePost(post.id)} isPostAuthor={isPostAuthor} @@ -359,9 +359,9 @@ export const UserScreen: React.FC = () => { post={post} onPress={() => handlePostPress(post.id)} onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}} - onLike={() => post.is_liked ? unlikePost(post.id) : likePost(post.id)} + onLike={() => post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id)} onComment={() => handlePostPress(post.id, true)} - onBookmark={() => post.is_favorited ? unfavoritePost(post.id) : favoritePost(post.id)} + onBookmark={() => post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id)} onShare={() => {}} onDelete={() => handleDeletePost(post.id)} isPostAuthor={isPostAuthor} diff --git a/src/services/groupService.ts b/src/services/groupService.ts index 330193d..4ae3f3d 100644 --- a/src/services/groupService.ts +++ b/src/services/groupService.ts @@ -403,17 +403,6 @@ class GroupService { } } - // ==================== 兼容旧API的方法(将逐步废弃) ==================== - - /** - * @deprecated 使用 setGroupName 代替 - */ - async updateGroup(id: number, data: { name: string }): Promise { - await this.setGroupName(id, data.name); - return this.getGroup(id); - } -} - // 导出群组服务实例 export const groupService = new GroupService(); diff --git a/src/services/messageService.ts b/src/services/messageService.ts index 4c15f1d..844e878 100644 --- a/src/services/messageService.ts +++ b/src/services/messageService.ts @@ -671,32 +671,6 @@ class MessageService { }; } } - - // ==================== 兼容旧API的方法(将逐步废弃) ==================== - - /** - * @deprecated 使用 getConversations 代替 - */ - async getConversation(conversationId: string): Promise { - try { - return await this.getConversationById(conversationId); - } catch (error) { - console.error('获取会话详情失败:', error); - return null; - } - } - - /** - * @deprecated 使用 createConversation(userId: string) 代替 - */ - async createConversationByString(userId: string): Promise { - try { - return await this.createConversation(userId); - } catch (error) { - console.error('创建会话失败:', error); - return null; - } - } } // 导出消息服务实例 diff --git a/src/services/postService.ts b/src/services/postService.ts index 4f81844..c3675c4 100644 --- a/src/services/postService.ts +++ b/src/services/postService.ts @@ -1,11 +1,16 @@ /** * 帖子服务 * 处理帖子的增删改查、点赞、收藏等功能 + * + * @deprecated 此服务已弃用,请使用 ProcessPostUseCase 代替 + * 互动操作(点赞、收藏)已委托给 processPostUseCase */ import { api, PaginatedData } from './api'; import { Post, CreatePostInput } from '../types'; import { CursorPaginationRequest, CursorPaginationResponse } from '../types/dto'; +import { processPostUseCase } from '../core/usecases/ProcessPostUseCase'; +import type { Post as PostEntity } from '../core/entities/Post'; // 帖子列表响应 interface PostListResponse { @@ -129,14 +134,12 @@ class PostService { } // 点赞帖子 + // @deprecated 请使用 processPostUseCase.likePost() 代替 async likePost(postId: string): Promise { try { - const response = await api.post(`/posts/${postId}/like`); - if (response.code === 0) { - return response.data; - } - console.warn('[postService] likePost failed, code:', response.code); - return null; + const post = await processPostUseCase.likePost(postId); + // 转换为旧版 Post 类型以保持向后兼容 + return post as unknown as Post; } catch (error) { console.error('[postService] likePost error:', error); return null; @@ -144,14 +147,11 @@ class PostService { } // 取消点赞帖子 + // @deprecated 请使用 processPostUseCase.unlikePost() 代替 async unlikePost(postId: string): Promise { try { - const response = await api.delete(`/posts/${postId}/like`); - if (response.code === 0) { - return response.data; - } - console.warn('[postService] unlikePost failed, code:', response.code); - return null; + const post = await processPostUseCase.unlikePost(postId); + return post as unknown as Post; } catch (error) { console.error('[postService] unlikePost error:', error); return null; @@ -159,14 +159,11 @@ class PostService { } // 收藏帖子 + // @deprecated 请使用 processPostUseCase.favoritePost() 代替 async favoritePost(postId: string): Promise { try { - const response = await api.post(`/posts/${postId}/favorite`); - if (response.code === 0) { - return response.data; - } - console.warn('[postService] favoritePost failed, code:', response.code); - return null; + const post = await processPostUseCase.favoritePost(postId); + return post as unknown as Post; } catch (error) { console.error('[postService] favoritePost error:', error); return null; @@ -174,14 +171,11 @@ class PostService { } // 取消收藏帖子 + // @deprecated 请使用 processPostUseCase.unfavoritePost() 代替 async unfavoritePost(postId: string): Promise { try { - const response = await api.delete(`/posts/${postId}/favorite`); - if (response.code === 0) { - return response.data; - } - console.warn('[postService] unfavoritePost failed, code:', response.code); - return null; + const post = await processPostUseCase.unfavoritePost(postId); + return post as unknown as Post; } catch (error) { console.error('[postService] unfavoritePost error:', error); return null; diff --git a/src/stores/messageManagerHooks.ts b/src/stores/messageManagerHooks.ts index e9aba4c..ccf36cd 100644 --- a/src/stores/messageManagerHooks.ts +++ b/src/stores/messageManagerHooks.ts @@ -502,10 +502,9 @@ interface UseMessageListRefreshReturn { * 从 ChatScreen 返回时,MessageManager 内存状态已经是最新的(markAsRead 乐观更新), * 无需再请求服务器(避免时序竞争覆盖已读状态)。 * 仅在用户主动下拉刷新时才触发服务器请求。 - * @deprecated isFocused 参数保留仅为向后兼容,不再触发焦点刷新 + * 注意:isFocused 参数已移除,不再触发焦点刷新 */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export function useMessageListRefresh(_isFocused?: boolean): UseMessageListRefreshReturn { +export function useMessageListRefresh(): UseMessageListRefreshReturn { const [isRefreshing, setIsRefreshing] = useState(false); const lastRefreshTimeRef = useRef(0); diff --git a/src/stores/userStore.ts b/src/stores/userStore.ts index 1b06334..d5e9131 100644 --- a/src/stores/userStore.ts +++ b/src/stores/userStore.ts @@ -10,12 +10,10 @@ import { PaginatedData } from '../services/api'; import { authService, postService, - commentService, notificationService, } from '../services'; import { userManager } from './userManager'; import { messageManager } from './messageManager'; -import { createOptimisticUpdaterFactory } from '../utils/optimisticUpdate'; interface UserState { // 状态 @@ -42,17 +40,10 @@ interface UserState { markAllNotificationsAsRead: () => Promise; fetchNotificationBadge: () => Promise; setMessageUnreadCount: (count: number) => void; - fetchMessageUnreadCount: () => Promise; addSearchHistory: (keyword: string) => void; clearSearchHistory: () => void; - // 互动操作 - likePost: (postId: string) => Promise; - unlikePost: (postId: string) => Promise; - favoritePost: (postId: string) => Promise; - unfavoritePost: (postId: string) => Promise; - likeComment: (commentId: string) => Promise; - unlikeComment: (commentId: string) => Promise; + // 关注用户操作(authService 不管理状态,所以保留在 store) followUser: (userId: string) => Promise; unfollowUser: (userId: string) => Promise; @@ -61,9 +52,6 @@ interface UserState { } export const useUserStore = create((set, get) => { - // 创建乐观更新器 - const optimisticUpdater = createOptimisticUpdaterFactory(set, get); - return { // 初始状态 users: [], @@ -252,19 +240,6 @@ export const useUserStore = create((set, get) => { set({ messageUnreadCount: count }); }, - // 从后端拉取最新消息未读数 - // @deprecated 请使用 messageManager.fetchUnreadCount() 代替 - // 此方法保留用于向后兼容 - fetchMessageUnreadCount: async () => { - try { - await messageManager.fetchUnreadCount(); - const unread = messageManager.getUnreadCount(); - set({ messageUnreadCount: unread.total + unread.system }); - } catch (error) { - console.error('获取消息未读数失败:', error); - } - }, - // 添加搜索历史 addSearchHistory: (keyword: string) => { set(state => { @@ -278,153 +253,7 @@ export const useUserStore = create((set, get) => { set({ searchHistory: [] }); }, - // 点赞帖子 - 使用乐观更新工具 - likePost: async (postId: string) => { - const originalPosts = get().posts; - - // 乐观更新 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p - ) - })); - - try { - const updatedPost = await postService.likePost(postId); - if (updatedPost) { - // 使用服务器返回的数据更新 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? updatedPost : p - ) - })); - } - } catch (error) { - console.error('点赞帖子失败:', error); - // 回滚 - set({ posts: originalPosts }); - } - }, - - // 取消点赞 - 使用乐观更新工具 - unlikePost: async (postId: string) => { - const originalPosts = get().posts; - - // 乐观更新 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p - ) - })); - - try { - const updatedPost = await postService.unlikePost(postId); - if (updatedPost) { - // 使用服务器返回的数据更新 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? updatedPost : p - ) - })); - } - } catch (error) { - console.error('取消点赞帖子失败:', error); - // 回滚 - set({ posts: originalPosts }); - } - }, - - // 收藏帖子 - 使用乐观更新工具 - favoritePost: async (postId: string) => { - const originalPosts = get().posts; - - // 乐观更新 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p - ) - })); - - try { - const updatedPost = await postService.favoritePost(postId); - if (updatedPost) { - // 使用服务器返回的数据更新 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? updatedPost : p - ) - })); - } - } catch (error) { - console.error('收藏帖子失败:', error); - // 回滚 - set({ posts: originalPosts }); - } - }, - - // 取消收藏 - 使用乐观更新工具 - unfavoritePost: async (postId: string) => { - const originalPosts = get().posts; - - // 乐观更新 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p - ) - })); - - try { - const updatedPost = await postService.unfavoritePost(postId); - if (updatedPost) { - // 使用服务器返回的数据更新 - set(state => ({ - posts: state.posts.map(p => - p.id === postId ? updatedPost : p - ) - })); - } - } catch (error) { - console.error('取消收藏帖子失败:', error); - // 回滚 - set({ posts: originalPosts }); - } - }, - - // 点赞评论 - 使用乐观更新工具 - likeComment: async (commentId: string) => { - await optimisticUpdater({ - key: 'posts', - optimisticUpdate: (posts) => - posts.map(p => ({ - ...p, - top_comment: p.top_comment && p.top_comment.id === commentId - ? { ...p.top_comment, is_liked: true, likes_count: p.top_comment.likes_count + 1 } - : p.top_comment - })), - rollbackState: (original) => original, - apiCall: () => commentService.likeComment(commentId), - errorMessage: '点赞评论失败', - }); - }, - - // 取消点赞评论 - 使用乐观更新工具 - unlikeComment: async (commentId: string) => { - await optimisticUpdater({ - key: 'posts', - optimisticUpdate: (posts) => - posts.map(p => ({ - ...p, - top_comment: p.top_comment && p.top_comment.id === commentId - ? { ...p.top_comment, is_liked: false, likes_count: Math.max(0, p.top_comment.likes_count - 1) } - : p.top_comment - })), - rollbackState: (original) => original, - apiCall: () => commentService.unlikeComment(commentId), - errorMessage: '取消点赞评论失败', - }); - }, - - // 关注用户 - 使用乐观更新工具 + // 关注用户 - authService 不管理状态,所以由 store 管理 followUser: async (userId: string) => { const originalUsers = get().users; const originalUserCache = get().userCache; @@ -462,7 +291,7 @@ export const useUserStore = create((set, get) => { } }, - // 取消关注 - 使用乐观更新工具 + // 取消关注 - authService 不管理状态,所以由 store 管理 unfollowUser: async (userId: string) => { const originalUsers = get().users; const originalUserCache = get().userCache; diff --git a/src/types/dto.ts b/src/types/dto.ts index ab43a3c..111b7eb 100644 --- a/src/types/dto.ts +++ b/src/types/dto.ts @@ -326,37 +326,6 @@ export interface MessageSyncResponse { has_more: boolean; } -// ==================== Legacy Message DTOs (Deprecated) ==================== - -/** - * @deprecated 使用 MessageResponse 代替 - */ -export interface MessageDTO { - id: string; - conversation_id: string; - sender_id: string; - content: string; - message_type: string; - is_read: boolean; - created_at: string; - sender: UserDTO; -} - -/** - * @deprecated 使用 ConversationResponse 代替 - */ -export interface ConversationDTO { - id: string; - user1_id: string; - user2_id: string; - last_message: MessageDTO | null; - last_message_at: string; - unread_count: number; - participant: UserDTO; - created_at: string; - updated_at: string; -} - // ==================== Auth DTOs ==================== export interface LoginDTO { diff --git a/src/types/index.ts b/src/types/index.ts index d1ee970..08c41cf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -189,34 +189,6 @@ export type Conversation = ConversationResponse; */ export type Message = MessageResponse; -// ============================================ -// Legacy Message Types (Deprecated) -// ============================================ - -/** - * @deprecated 使用 ConversationResponse 代替 - */ -export interface LegacyConversation { - id: string; - participant: User; - lastMessage: LegacyMessage; - unreadCount: number; - updatedAt: string; -} - -/** - * @deprecated 使用 MessageResponse 代替 - */ -export interface LegacyMessage { - id: string; - conversationId: string; - senderId: string; - content: string; - type: 'text' | 'image' | 'system'; - isRead: boolean; - createdAt: string; -} - // ============================================ // API Response Types // ============================================ From 69e21471febfbbb0e9124e7ad58a54876d58adf8 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Sat, 21 Mar 2026 21:13:34 +0800 Subject: [PATCH 11/36] fix(groupService): add missing closing brace --- src/services/groupService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/services/groupService.ts b/src/services/groupService.ts index 4ae3f3d..f936644 100644 --- a/src/services/groupService.ts +++ b/src/services/groupService.ts @@ -402,6 +402,7 @@ class GroupService { }; } } +} // 导出群组服务实例 export const groupService = new GroupService(); From cee120a6f862b6d69235403f8e000b669fdfe235 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Mon, 23 Mar 2026 00:16:10 +0800 Subject: [PATCH 12/36] refactor(PostRepository): update PostsListApiResponse structure and handle response mapping - Change 'posts' to 'list' in PostsListApiResponse for consistency with API response. - Introduce optional fields: 'page', 'page_size', and 'total_pages' for enhanced pagination support. - Update response handling in PostRepository methods to accommodate new structure and ensure default values for 'hasMore'. --- src/data/repositories/PostRepository.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/data/repositories/PostRepository.ts b/src/data/repositories/PostRepository.ts index 5d26cb0..c013194 100644 --- a/src/data/repositories/PostRepository.ts +++ b/src/data/repositories/PostRepository.ts @@ -53,10 +53,13 @@ interface PostApiResponse { * API帖子列表响应类型 */ interface PostsListApiResponse { - posts: PostApiResponse[]; - has_more: boolean; + list: PostApiResponse[]; + has_more?: boolean; next_cursor?: string; total?: number; + page?: number; + page_size?: number; + total_pages?: number; } /** @@ -242,7 +245,7 @@ export class PostRepository implements IPostRepository { const response = await this.api.get('/posts', queryParams); - const posts = response.posts.map(p => this.mapToPost(p)); + const posts = (response.list || []).map(p => this.mapToPost(p)); // 缓存帖子 posts.forEach(post => { @@ -252,7 +255,7 @@ export class PostRepository implements IPostRepository { return { posts, - hasMore: response.has_more, + hasMore: response.has_more ?? false, nextCursor: response.next_cursor, total: response.total, }; @@ -315,7 +318,7 @@ export class PostRepository implements IPostRepository { const response = await this.api.get(`/users/${userId}/posts`, queryParams); - const posts = response.posts.map(p => this.mapToPost(p)); + const posts = (response.list || []).map(p => this.mapToPost(p)); // 缓存帖子 posts.forEach(post => { @@ -325,7 +328,7 @@ export class PostRepository implements IPostRepository { return { posts, - hasMore: response.has_more, + hasMore: response.has_more ?? false, nextCursor: response.next_cursor, total: response.total, }; @@ -350,7 +353,7 @@ export class PostRepository implements IPostRepository { const response = await this.api.get('/posts/search', queryParams); - const posts = response.posts.map(p => this.mapToPost(p)); + const posts = (response.list || []).map(p => this.mapToPost(p)); // 缓存帖子 posts.forEach(post => { @@ -360,7 +363,7 @@ export class PostRepository implements IPostRepository { return { posts, - hasMore: response.has_more, + hasMore: response.has_more ?? false, nextCursor: response.next_cursor, total: response.total, }; From 88510aa6aed697637fccd80ec99f3ce281db2328 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Mon, 23 Mar 2026 00:34:26 +0800 Subject: [PATCH 13/36] feat(api): implement token auto-refresh mechanism and enhance token management - Add support for proactive token refresh when nearing expiration to improve user experience. - Introduce a refresh lock to prevent concurrent token refresh requests. - Update token handling in API client to ensure valid tokens are used for requests. - Modify authService to handle refresh token requests with improved error handling and response management. --- src/services/api.ts | 108 ++++++++++++++++++++++++++++++++++-- src/services/authService.ts | 14 ++++- 2 files changed, 114 insertions(+), 8 deletions(-) diff --git a/src/services/api.ts b/src/services/api.ts index 96f28e1..98e5fff 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -2,6 +2,7 @@ * API 客户端 * 使用 fetch API 进行 HTTP 请求 * 支持请求/响应拦截器 + * 支持主动刷新 token(快过期时自动刷新) */ import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -24,6 +25,9 @@ const SSE_URL = `${BASE_URL.replace(/\/+$/, '')}/realtime/sse`; const TOKEN_KEY = 'auth_token'; const REFRESH_TOKEN_KEY = 'refresh_token'; +// Token 提前刷新阈值(秒):提前 5 分钟刷新 +const TOKEN_REFRESH_THRESHOLD_SECONDS = 5 * 60; + // API 响应格式 export interface ApiResponse { code: number; @@ -53,10 +57,21 @@ export class ApiError extends Error { } } +// JWT Payload 结构 +interface JwtPayload { + user_id: string; + username: string; + exp?: number; + iat?: number; + iss?: string; +} + // API 客户端类 class ApiClient { private baseUrl: string; private navigation: any = null; + // 刷新锁:防止并发刷新 + private refreshLock: Promise | null = null; constructor(baseUrl: string) { this.baseUrl = baseUrl; @@ -67,6 +82,33 @@ class ApiClient { this.navigation = navigation; } + // 解析 JWT token 获取 payload + private parseJwt(token: string): JwtPayload | null { + try { + const parts = token.split('.'); + if (parts.length !== 3) { + return null; + } + // React Native/Expo 环境使用 atob + const payload = JSON.parse(atob(parts[1])); + return payload as JwtPayload; + } catch { + return null; + } + } + + // 检查 token 是否快过期(返回 true 表示需要刷新) + private isTokenExpiringSoon(token: string): boolean { + const payload = this.parseJwt(token); + if (!payload || !payload.exp) { + return false; + } + const now = Math.floor(Date.now() / 1000); + const remaining = payload.exp - now; + // 剩余时间小于阈值时需要刷新 + return remaining < TOKEN_REFRESH_THRESHOLD_SECONDS && remaining > 0; + } + // 获取 token async getToken(): Promise { try { @@ -137,10 +179,33 @@ class ApiClient { 'Content-Type': 'application/json', }; - // 添加认证 token + // 添加认证 token(主动刷新快过期的 token) const token = await this.getToken(); if (token) { - headers['Authorization'] = `Bearer ${token}`; + // 检查 token 是否快过期,如果是则主动刷新 + if (this.isTokenExpiringSoon(token)) { + const refreshed = await this.refreshToken(); + if (!refreshed) { + // 刷新失败,清除 token 并跳转登录 + await this.clearToken(); + if (this.navigation) { + this.navigation.dispatch( + CommonActions.reset({ + index: 0, + routes: [{ name: 'Login' }], + }) + ); + } + throw new ApiError(401, '登录已过期,请重新登录'); + } + // 刷新成功,使用新 token + const newToken = await this.getToken(); + if (newToken) { + headers['Authorization'] = `Bearer ${newToken}`; + } + } else { + headers['Authorization'] = `Bearer ${token}`; + } } // 构建请求配置 @@ -230,8 +295,27 @@ class ApiClient { } } - // 刷新 token + // 刷新 token(带锁,防止并发刷新) private async refreshToken(): Promise { + // 如果已有刷新操作正在进行,等待其完成 + if (this.refreshLock) { + return this.refreshLock; + } + + // 创建刷新锁 + this.refreshLock = this.doRefreshToken(); + + try { + const result = await this.refreshLock; + return result; + } finally { + // 释放刷新锁 + this.refreshLock = null; + } + } + + // 实际执行刷新 token + private async doRefreshToken(): Promise { try { const refreshToken = await this.getRefreshToken(); if (!refreshToken) { @@ -242,8 +326,10 @@ class ApiClient { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${refreshToken}`, }, + body: JSON.stringify({ + refresh_token: refreshToken, + }), }); if (!response.ok) { @@ -309,10 +395,22 @@ class ApiClient { }); } + // 获取 token 并检查是否需要刷新 const token = await this.getToken(); const headers: HeadersInit = {}; if (token) { - headers['Authorization'] = `Bearer ${token}`; + // 检查 token 是否快过期 + if (this.isTokenExpiringSoon(token)) { + const refreshed = await this.refreshToken(); + if (refreshed) { + const newToken = await this.getToken(); + if (newToken) { + headers['Authorization'] = `Bearer ${newToken}`; + } + } + } else { + headers['Authorization'] = `Bearer ${token}`; + } } const response = await fetch(`${this.baseUrl}${path}`, { diff --git a/src/services/authService.ts b/src/services/authService.ts index c32f953..0311b8b 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -237,7 +237,14 @@ class AuthService { // 刷新Token async refreshToken(): Promise { try { - const response = await api.post('/auth/refresh'); + const refreshToken = await api.getRefreshToken(); + if (!refreshToken) { + return null; + } + + const response = await api.post('/auth/refresh', { + refresh_token: refreshToken, + }); if (!response.data) { return null; @@ -245,8 +252,9 @@ class AuthService { if (response.data.token) { await api.setToken(response.data.token); } - if (response.data.refreshToken) { - await api.setRefreshToken(response.data.refreshToken); + const newRefreshToken = response.data.refresh_token || response.data.refreshToken; + if (newRefreshToken) { + await api.setRefreshToken(newRefreshToken); } return response.data; From 26ae28847034fcedff920f9bfb8b46708b4e59dd Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Mon, 23 Mar 2026 03:58:26 +0800 Subject: [PATCH 14/36] refactor: standardize pagination response structure and enhance cursor handling - Update various services and hooks to replace 'items' with 'list' for consistency in pagination responses. - Modify PostRepository, commentService, groupService, messageService, and postService to align with the new response structure. - Enhance cursor pagination logic in hooks and components to ensure proper handling of pagination states. - Refactor HomeScreen, PostDetailScreen, SearchScreen, and other components to utilize the updated pagination structure. --- .gitea/workflows/build.yml | 4 +- src/core/usecases/ProcessPostUseCase.ts | 72 +++++-- src/data/repositories/PostRepository.ts | 16 +- src/hooks/useCursorPagination.ts | 22 +-- src/hooks/useDifferentialPosts.ts | 209 +++++--------------- src/infrastructure/pagination/types.ts | 6 +- src/screens/home/HomeScreen.tsx | 34 +++- src/screens/home/PostDetailScreen.tsx | 10 +- src/screens/home/SearchScreen.tsx | 4 +- src/screens/message/GroupMembersScreen.tsx | 2 +- src/screens/message/JoinGroupScreen.tsx | 2 +- src/screens/message/MessageListScreen.tsx | 10 +- src/screens/message/NotificationsScreen.tsx | 2 +- src/services/commentService.ts | 30 +-- src/services/groupService.ts | 6 +- src/services/messageService.ts | 62 +++--- src/services/notificationService.ts | 2 +- src/services/postService.ts | 126 ++++++------ src/types/dto.ts | 2 +- 19 files changed, 312 insertions(+), 309 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 33ba30e..54648a5 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -314,8 +314,8 @@ jobs: labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 provenance: false - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache + cache-to: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache,mode=max - name: Show image tags run: | diff --git a/src/core/usecases/ProcessPostUseCase.ts b/src/core/usecases/ProcessPostUseCase.ts index 1a6d775..c87bffa 100644 --- a/src/core/usecases/ProcessPostUseCase.ts +++ b/src/core/usecases/ProcessPostUseCase.ts @@ -64,10 +64,14 @@ export interface PostsState { hasMore: boolean; /** 错误信息 */ error: string | null; - /** 当前游标 */ + /** 当前游标(cursor 模式) */ cursor: string | null; + /** 当前页码(page 模式) */ + currentPage: number; /** 最后加载时间 */ lastLoadTime: number | null; + /** 上次请求的参数(用于加载更多和刷新) */ + lastParams?: GetPostsParams; } /** @@ -198,6 +202,7 @@ class ProcessPostUseCase { hasMore: true, error: null, cursor: null, + currentPage: 1, lastLoadTime: null, }; this.postsState.set(key, state); @@ -257,29 +262,32 @@ class ProcessPostUseCase { * @param key 列表键(用于区分不同的列表) */ async fetchPosts(params?: GetPostsParams, key: string = 'default'): Promise { - const state = this.getPostsState(key); - // 更新加载状态 this.updatePostsState(key, { isLoading: true, error: null }); try { - // 合并分页参数 + // 默认传递空字符串 cursor 来启动 cursor 模式 + // 注意:cursor 要放在 ...params 后面,这样只有在 params 没有传 cursor 时才使用默认值 const queryParams: GetPostsParams = { - page: params?.page || 1, pageSize: params?.pageSize || DEFAULT_PAGE_SIZE, - cursor: params?.cursor || state.cursor || undefined, ...params, + cursor: params?.cursor !== undefined ? params.cursor : '', // 默认使用空字符串启动 cursor 模式 }; const result = await postRepository.getPosts(queryParams); - // 更新状态 + // 判断是否为 cursor 模式(有 next_cursor 返回) + const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null; + + // 更新状态(保存请求参数以便加载更多时使用) this.updatePostsState(key, { posts: result.posts, hasMore: result.hasMore, - cursor: result.nextCursor || null, + cursor: isCursorMode ? result.nextCursor : null, // cursor 模式保存 cursor,否则设为 null 表示 page 模式 + currentPage: isCursorMode ? undefined : 1, // page 模式从第 1 页开始 isLoading: false, lastLoadTime: Date.now(), + lastParams: params, // 保存请求参数 }); // 通知订阅者 @@ -312,20 +320,29 @@ class ProcessPostUseCase { * @param key 列表键 */ async refreshPosts(key: string = 'default'): Promise { + const state = this.getPostsState(key); + // 更新刷新状态 this.updatePostsState(key, { isRefreshing: true, error: null }); try { + // 使用保存的请求参数(如 post_type)进行刷新 + // 默认传递空字符串 cursor 来启动 cursor 模式 const result = await postRepository.getPosts({ - page: 1, pageSize: DEFAULT_PAGE_SIZE, + ...state.lastParams, + cursor: state.lastParams?.cursor !== undefined ? state.lastParams.cursor : '', // 默认使用空字符串启动 cursor 模式 }); + // 判断是否为 cursor 模式(有 next_cursor 返回) + const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null; + // 更新状态 this.updatePostsState(key, { posts: result.posts, hasMore: result.hasMore, - cursor: result.nextCursor || null, + cursor: isCursorMode ? result.nextCursor : null, + currentPage: isCursorMode ? undefined : 1, isRefreshing: false, lastLoadTime: Date.now(), }); @@ -372,21 +389,45 @@ class ProcessPostUseCase { this.updatePostsState(key, { isLoading: true, error: null }); try { - const queryParams: GetPostsParams = { - pageSize: DEFAULT_PAGE_SIZE, - cursor: state.cursor || undefined, - }; + // 判断使用哪种分页模式:cursor 不为 null 表示使用 cursor 模式 + const useCursorMode = state.cursor !== null && state.cursor !== undefined; + + // 必须先展开 lastParams,再由本次分页字段覆盖。 + // lastParams 来自首次 fetch(通常含 page:1),若写在展开之前会把 nextPage/cursor 覆盖掉,导致永远请求第 1 页、无法加载第 3 页及以后。 + const base = { ...(state.lastParams ?? {}) } as GetPostsParams; + let queryParams: GetPostsParams; + + if (useCursorMode) { + delete base.page; + queryParams = { + ...base, + pageSize: DEFAULT_PAGE_SIZE, + cursor: state.cursor!, + }; + } else { + const nextPage = (state.currentPage || 1) + 1; + delete base.cursor; + queryParams = { + ...base, + pageSize: DEFAULT_PAGE_SIZE, + page: nextPage, + }; + } const result = await postRepository.getPosts(queryParams); // 合并新数据 const newPosts = [...state.posts, ...result.posts]; + // 判断响应是否为 cursor 模式(有 next_cursor 返回) + const responseIsCursorMode = result.nextCursor !== undefined && result.nextCursor !== null; + // 更新状态 this.updatePostsState(key, { posts: newPosts, hasMore: result.hasMore, - cursor: result.nextCursor || null, + cursor: responseIsCursorMode ? result.nextCursor : null, + currentPage: useCursorMode ? state.currentPage : ((state.currentPage || 1) + 1), isLoading: false, lastLoadTime: Date.now(), }); @@ -398,6 +439,7 @@ class ProcessPostUseCase { }; } catch (error) { const errorMessage = error instanceof Error ? error.message : '加载更多帖子失败'; + console.error('[ProcessPostUseCase] Load more error:', errorMessage); this.updatePostsState(key, { isLoading: false, error: errorMessage, diff --git a/src/data/repositories/PostRepository.ts b/src/data/repositories/PostRepository.ts index c013194..4841a11 100644 --- a/src/data/repositories/PostRepository.ts +++ b/src/data/repositories/PostRepository.ts @@ -231,9 +231,12 @@ export class PostRepository implements IPostRepository { try { const queryParams: Record = {}; - if (params?.page) queryParams.page = params.page; + if (params?.page !== undefined) queryParams.page = params.page; if (params?.pageSize) queryParams.page_size = params.pageSize; - if (params?.cursor) queryParams.cursor = params.cursor; + // 支持 cursor 参数:空字符串也传递,用于启动 cursor 模式 + if (params?.cursor !== undefined && params?.cursor !== null) { + queryParams.cursor = params.cursor; + } if (params?.post_type) queryParams.tab = params.post_type; if (params?.communityId) queryParams.community_id = params.communityId; if (params?.authorId) queryParams.author_id = params.authorId; @@ -253,9 +256,16 @@ export class PostRepository implements IPostRepository { this.saveToLocalCache(post); }); + // 计算 hasMore:优先使用 has_more,否则通过 page/total_pages 计算 + let hasMore = response.has_more; + if (hasMore === undefined && response.page !== undefined && response.total_pages !== undefined) { + hasMore = response.page < response.total_pages; + } + hasMore = hasMore ?? false; + return { posts, - hasMore: response.has_more ?? false, + hasMore, nextCursor: response.next_cursor, total: response.total, }; diff --git a/src/hooks/useCursorPagination.ts b/src/hooks/useCursorPagination.ts index 9ba0a56..1fa6d3c 100644 --- a/src/hooks/useCursorPagination.ts +++ b/src/hooks/useCursorPagination.ts @@ -29,7 +29,7 @@ export function useCursorPagination( const effectivePageSize = Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE); const [state, setState] = useState>({ - items: [], + list: [], nextCursor: null, prevCursor: null, hasMore: true, // 初始设为 true,以便首次加载可以执行 @@ -86,7 +86,7 @@ export function useCursorPagination( setState(prev => ({ ...prev, - items: [...prev.items, ...response.items], + list: [...prev.list, ...response.list], nextCursor: response.next_cursor, prevCursor: response.prev_cursor, hasMore: response.has_more && response.next_cursor !== null, @@ -134,7 +134,7 @@ export function useCursorPagination( setState(prev => ({ ...prev, // 向前加载时,新数据放在前面 - items: [...response.items, ...prev.items], + list: [...response.list, ...prev.list], nextCursor: response.next_cursor, prevCursor: response.prev_cursor, hasMore: response.has_more, @@ -169,7 +169,7 @@ export function useCursorPagination( if (cancelledRef.current) return; setState({ - items: response.items, + list: response.list, nextCursor: response.next_cursor, prevCursor: response.prev_cursor, hasMore: response.has_more && response.next_cursor !== null, @@ -196,7 +196,7 @@ export function useCursorPagination( isFirstLoadRef.current = true; isLoadingRef.current = false; setState({ - items: [], + list: [], nextCursor: null, prevCursor: null, hasMore: true, // 重置后也设为 true,以便可以重新加载 @@ -208,16 +208,16 @@ export function useCursorPagination( }, []); // 设置数据(用于外部数据注入) - const setItems = useCallback( + const setList = useCallback( ( - items: T[], + list: T[], nextCursor: string | null, prevCursor: string | null, hasMore: boolean ) => { setState(prev => ({ ...prev, - items, + list, nextCursor, prevCursor, hasMore, @@ -230,7 +230,7 @@ export function useCursorPagination( // 自动加载第一页 useEffect(() => { // 使用 ref 防止重复加载,不依赖 loadMore 函数引用 - // 只检查 autoLoad 和 autoLoadRef,不再依赖 state.items.length + // 只检查 autoLoad 和 autoLoadRef,不再依赖 state.list.length if (!autoLoad || autoLoadRef.current) return; autoLoadRef.current = true; @@ -261,7 +261,7 @@ export function useCursorPagination( setState(prev => ({ ...prev, - items: response.items, + list: response.list, nextCursor: response.next_cursor, prevCursor: response.prev_cursor, hasMore: response.has_more && response.next_cursor !== null, @@ -296,7 +296,7 @@ export function useCursorPagination( loadPrevious, refresh, reset, - setItems, + setList, }; } diff --git a/src/hooks/useDifferentialPosts.ts b/src/hooks/useDifferentialPosts.ts index c441c9d..d10150b 100644 --- a/src/hooks/useDifferentialPosts.ts +++ b/src/hooks/useDifferentialPosts.ts @@ -4,13 +4,12 @@ * 集成 ProcessPostUseCase 进行状态管理 */ -import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; +import { useState, useRef, useCallback, useEffect } from 'react'; import { PostIdentifier, PostUpdate, PostUpdateType, PostDiffConfig, - PostDiffResult, } from '../infrastructure/diff/postTypes'; import { PostDiffCalculator, @@ -119,7 +118,6 @@ export function useDifferentialPosts( enableDiff = true, enableBatching = true, maxPostCount = 10000, - changeRatioThreshold = 0.5, listKey = 'default', autoSubscribe = true, } = options; @@ -147,37 +145,7 @@ export function useDifferentialPosts( const previousPostsRef = useRef(initialPosts); const unsubscribeRef = useRef<(() => void) | null>(null); - // ==================== 初始化 ==================== - - // 初始化差异计算器 - useEffect(() => { - if (enableDiff) { - calculatorRef.current = createPostDiffCalculator(diffConfig); - } - return () => { - calculatorRef.current = null; - }; - }, [enableDiff, diffConfig]); - - // 初始化批量处理器 - useEffect(() => { - if (enableBatching) { - batcherRef.current = createPostUpdateBatcher(batcherOptions); - - // 订阅批量更新 - const unsubscribe = batcherRef.current.subscribe((updates) => { - handleBatchUpdates(updates); - }); - - return () => { - unsubscribe(); - batcherRef.current?.destroy(); - batcherRef.current = null; - }; - } - }, [enableBatching, batcherOptions]); - - // ==================== 批量更新处理 ==================== + // ==================== 批量更新处理(须在订阅 batcher 的 useEffect 之前定义)==================== /** * 处理批量更新 @@ -289,123 +257,34 @@ export function useDifferentialPosts( } }, []); - // ==================== 差异计算 ==================== + // ==================== 初始化 ==================== - /** - * 计算并应用差异 - */ - const calculateAndApplyDiff = useCallback((newPosts: T[]) => { - const previousPosts = previousPostsRef.current; - - // 如果帖子数量变化太大,直接重置 - const previousCount = previousPosts.length; - const currentCount = newPosts.length; - const changeRatio = previousCount === 0 ? 1 : Math.abs(currentCount - previousCount) / previousCount; - - if (changeRatio > changeRatioThreshold) { - setPosts(newPosts); - previousPostsRef.current = newPosts; - return; + // 初始化差异计算器 + useEffect(() => { + if (enableDiff) { + calculatorRef.current = createPostDiffCalculator(diffConfig); } + return () => { + calculatorRef.current = null; + }; + }, [enableDiff, diffConfig]); - // 使用差异计算 - if (enableDiff && calculatorRef.current) { - const diff = calculatorRef.current.calculateDiff(previousPosts, newPosts); + // 初始化批量处理器 + useEffect(() => { + if (!enableBatching) return; - if (diff.shouldReset) { - setPosts(newPosts); - } else { - // 将差异转换为更新操作 - const updates: PostUpdate[] = []; + batcherRef.current = createPostUpdateBatcher(batcherOptions); - // 处理新增 - if (diff.added.length > 0) { - if (diff.added.length === 1) { - updates.push({ - type: PostUpdateType.ADD, - post: diff.added[0], - timestamp: Date.now(), - }); - } else { - updates.push({ - type: PostUpdateType.BATCH_ADD, - posts: diff.added, - timestamp: Date.now(), - }); - } - } + const unsubscribe = batcherRef.current.subscribe((updates) => { + handleBatchUpdates(updates); + }); - // 处理更新 - if (diff.updated.length > 0) { - if (diff.updated.length === 1) { - updates.push({ - type: PostUpdateType.UPDATE, - postId: diff.updated[0].post.id, - updates: diff.updated[0].changes, - timestamp: Date.now(), - }); - } else { - updates.push({ - type: PostUpdateType.BATCH_UPDATE, - updates: diff.updated.map(u => ({ - postId: u.post.id, - changes: u.changes, - })), - timestamp: Date.now(), - }); - } - } - - // 处理删除 - if (diff.deleted.length > 0) { - if (diff.deleted.length === 1) { - updates.push({ - type: PostUpdateType.DELETE, - postId: diff.deleted[0].post.id, - timestamp: Date.now(), - }); - } else { - updates.push({ - type: PostUpdateType.BATCH_DELETE, - postIds: diff.deleted.map(d => d.post.id), - timestamp: Date.now(), - }); - } - } - - // 处理移动 - if (diff.moved.length > 0) { - for (const move of diff.moved) { - updates.push({ - type: PostUpdateType.MOVE, - postId: move.post.id, - toIndex: move.toIndex, - timestamp: Date.now(), - }); - } - } - - // 应用更新 - if (updates.length > 0) { - if (enableBatching && batcherRef.current) { - batcherRef.current.addUpdates(updates); - } else { - handleBatchUpdates(updates); - } - } - } - } else { - // 不使用差异计算,直接设置 - setPosts(newPosts); - } - - // 更新帖子数量限制检查 - if (newPosts.length > maxPostCount) { - console.warn(`[useDifferentialPosts] Post count (${newPosts.length}) exceeds limit (${maxPostCount})`); - } - - previousPostsRef.current = newPosts; - }, [enableDiff, enableBatching, maxPostCount, changeRatioThreshold, handleBatchUpdates]); + return () => { + unsubscribe(); + batcherRef.current?.destroy(); + batcherRef.current = null; + }; + }, [enableBatching, batcherOptions, handleBatchUpdates]); // ==================== UseCase 订阅 ==================== @@ -415,26 +294,39 @@ export function useDifferentialPosts( const handleUseCaseEvent = useCallback((event: PostUseCaseEvent) => { switch (event.type) { case 'state_changed': { - const { state } = event.payload as { key: string; state: PostsState }; + const { key: eventKey, state } = event.payload as { key: string; state: PostsState }; + // 全局订阅:只应用当前列表 key,避免其他 Tab/列表的 state 覆盖本列表(如 createPost 会更新所有 key) + if (eventKey !== listKey) { + break; + } if (state) { setLoading(state.isLoading); setRefreshing(state.isRefreshing); setError(state.error); setHasMore(state.hasMore); - - // 如果帖子列表有变化,进行差异计算 + + // ProcessPostUseCase 的 state.posts 已是合并后的完整列表。 + // 不再走差异计算 + PostUpdateBatcher:批量应用晚于 previousPostsRef 更新时, + // BATCH_ADD 会在过短的 currentPosts 上 splice,导致连续游标加载「请求成功但列表不增长」。 + batcherRef.current?.clearPending(); + calculatorRef.current?.reset(); if (state.posts && state.posts.length > 0) { - calculateAndApplyDiff(state.posts as unknown as T[]); + const next = state.posts as unknown as T[]; + if (next.length > maxPostCount) { + console.warn(`[useDifferentialPosts] Post count (${next.length}) exceeds limit (${maxPostCount})`); + } + setPosts(next); + previousPostsRef.current = next; + } else { + setPosts([]); + previousPostsRef.current = []; } } break; } case 'posts_loaded': { - const { posts: loadedPosts } = event.payload; - if (loadedPosts) { - calculateAndApplyDiff(loadedPosts as T[]); - } + // fetchPosts 在 updatePostsState 时已发出 state_changed(含合并后 posts),此处不再应用,避免与其它 key 竞态或重复计算 break; } @@ -485,12 +377,17 @@ export function useDifferentialPosts( } case 'error_occurred': { - const { error: errorMsg } = event.payload; - setError(errorMsg); + const payload = event.payload as { key?: string; error?: string }; + if (payload.key !== undefined && payload.key !== listKey) { + break; + } + if (payload.error !== undefined) { + setError(payload.error); + } break; } } - }, [calculateAndApplyDiff]); + }, [listKey, maxPostCount]); // 订阅 UseCase 状态变化 useEffect(() => { diff --git a/src/infrastructure/pagination/types.ts b/src/infrastructure/pagination/types.ts index f07466a..f2e7798 100644 --- a/src/infrastructure/pagination/types.ts +++ b/src/infrastructure/pagination/types.ts @@ -205,7 +205,7 @@ export interface CursorPaginationConfig { */ export interface CursorPaginationState { /** 数据项列表 */ - items: T[]; + list: T[]; /** 下一页游标 */ nextCursor: string | null; /** 上一页游标 */ @@ -235,7 +235,7 @@ export interface CursorPaginationActions { /** 重置状态 */ reset: () => void; /** 设置数据(用于外部数据注入) */ - setItems: (items: T[], nextCursor: string | null, prevCursor: string | null, hasMore: boolean) => void; + setList: (list: T[], nextCursor: string | null, prevCursor: string | null, hasMore: boolean) => void; } /** @@ -253,7 +253,7 @@ export type CursorFetchFunction = (params: { /** 额外参数 */ extraParams?: P; }) => Promise<{ - items: T[]; + list: T[]; next_cursor: string | null; prev_cursor: string | null; has_more: boolean; diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 68e3ac6..bf672c0 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -88,6 +88,10 @@ export const HomeScreen: React.FC = () => { // 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态 const postIdsRef = useRef>(new Set()); const lastSwipeAtRef = useRef(0); + + // 网格模式滚动位置检测 + const gridScrollYRef = useRef(0); + const isLoadingMoreRef = useRef(false); // 构建一个以 postId 为 key 的 map,用于快速查找 const postsMap = useMemo(() => { @@ -131,14 +135,30 @@ export const HomeScreen: React.FC = () => { // 加载更多方法 const loadMore = useCallback(async () => { - if (hasMore && !isLoading) { + if (hasMore && !isLoading && !isLoadingMoreRef.current) { + isLoadingMoreRef.current = true; try { await processPostUseCase.loadMorePosts(listKey); } catch (err) { console.error('加载更多失败:', err); + } finally { + isLoadingMoreRef.current = false; } } }, [listKey, hasMore, isLoading]); + + // 网格模式滚动处理 - 检测是否滚动到底部 + const handleGridScroll = useCallback((event: any) => { + const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent; + const scrollY = contentOffset.y; + const visibleHeight = layoutMeasurement.height; + const contentHeight = contentSize.height; + + // 当滚动到距离底部 200px 时触发加载更多 + if (scrollY + visibleHeight >= contentHeight - 200) { + loadMore(); + } + }, [loadMore]); // 刷新方法 - 先获取正确类型的帖子,再刷新 const refresh = useCallback(async () => { @@ -485,6 +505,7 @@ export const HomeScreen: React.FC = () => { ]} showsVerticalScrollIndicator={false} scrollEventThrottle={100} + onScroll={handleGridScroll} refreshControl={ { } > {distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))} + {/* 加载更多指示器 */} + {isLoading && displayPosts.length > 0 && ( + + + + )}
); } @@ -767,4 +794,9 @@ const styles = StyleSheet.create({ height: 72, borderRadius: 36, }, + loadingMore: { + paddingVertical: 20, + alignItems: 'center', + justifyContent: 'center', + }, }); diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index 6b9b10d..ad5e071 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -72,7 +72,7 @@ export const PostDetailScreen: React.FC = () => { // 使用游标分页 Hook 管理评论列表 const { - items: paginatedComments, + list: paginatedComments, isLoading: isCommentsLoading, isRefreshing: isCommentsRefreshing, hasMore: hasMoreComments, @@ -206,13 +206,13 @@ export const PostDetailScreen: React.FC = () => { // 如果是从评论按钮跳转过来的,加载完成后滚动到评论区 useEffect(() => { - if (shouldScrollToComments && !loading && comments.length > 0) { + if (shouldScrollToComments && !loading && (comments?.length ?? 0) > 0) { // 延迟确保列表已完成渲染和布局 setTimeout(() => { flatListRef.current?.scrollToIndex({ index: 0, animated: true, viewPosition: 0 }); }, 500); } - }, [shouldScrollToComments, loading, comments.length]); + }, [shouldScrollToComments, loading, comments?.length]); // 动态设置导航头部 useEffect(() => { @@ -1413,7 +1413,7 @@ export const PostDetailScreen: React.FC = () => { 加载更多评论
- ) : comments.length > 0 ? ( + ) : (comments?.length ?? 0) > 0 ? ( 没有更多评论了 @@ -1546,7 +1546,7 @@ export const PostDetailScreen: React.FC = () => { 加载更多评论
- ) : comments.length > 0 ? ( + ) : (comments?.length ?? 0) > 0 ? ( 没有更多评论了 diff --git a/src/screens/home/SearchScreen.tsx b/src/screens/home/SearchScreen.tsx index 0e728a4..a00ad87 100644 --- a/src/screens/home/SearchScreen.tsx +++ b/src/screens/home/SearchScreen.tsx @@ -83,7 +83,7 @@ export const SearchScreen: React.FC = ({ onBack, navigation: // 使用游标分页进行帖子搜索 const { - items: searchResults, + list: searchResults, isLoading, isRefreshing, hasMore, @@ -93,7 +93,7 @@ export const SearchScreen: React.FC = ({ onBack, navigation: } = useCursorPagination( async ({ cursor, pageSize, extraParams }) => { if (!extraParams?.query) { - return { items: [], next_cursor: null, prev_cursor: null, has_more: false }; + return { list: [], next_cursor: null, prev_cursor: null, has_more: false }; } const response = await postService.searchPostsCursor(extraParams.query, { cursor, diff --git a/src/screens/message/GroupMembersScreen.tsx b/src/screens/message/GroupMembersScreen.tsx index ffb281b..6f7b189 100644 --- a/src/screens/message/GroupMembersScreen.tsx +++ b/src/screens/message/GroupMembersScreen.tsx @@ -72,7 +72,7 @@ const GroupMembersScreen: React.FC = () => { // 使用游标分页 Hook 管理成员列表 const { - items: members, + list: members, isLoading, isRefreshing, hasMore, diff --git a/src/screens/message/JoinGroupScreen.tsx b/src/screens/message/JoinGroupScreen.tsx index e6df582..36125a9 100644 --- a/src/screens/message/JoinGroupScreen.tsx +++ b/src/screens/message/JoinGroupScreen.tsx @@ -36,7 +36,7 @@ const JoinGroupScreen: React.FC = () => { // 使用游标分页 Hook 管理群组列表 const { - items: groups, + list: groups, isLoading, isRefreshing, hasMore, diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index f63b0de..efc2e2b 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -164,7 +164,7 @@ export const MessageListScreen: React.FC = () => { // 【游标分页】使用 useCursorPagination hook 获取会话列表 const { - items: conversations, + list: conversationList, isLoading, isRefreshing, hasMore, @@ -453,7 +453,7 @@ export const MessageListScreen: React.FC = () => { if (activeSearchTab === 'chat') { const results: SearchResultItem[] = []; - for (const conv of conversations) { + for (const conv of conversationList) { await messageManager.fetchMessages(String(conv.id)); const messages = messageManager.getMessages(String(conv.id)); const matchedMessages = messages.filter(msg => { @@ -484,7 +484,7 @@ export const MessageListScreen: React.FC = () => { } finally { setIsSearching(false); } - }, [conversations, activeSearchTab]); + }, [conversationList, activeSearchTab]); // 搜索文本变化时触发搜索 useEffect(() => { @@ -533,7 +533,7 @@ export const MessageListScreen: React.FC = () => { // 合并列表数据 const listData: ConversationResponse[] = [ createSystemMessageItem(), - ...conversations, + ...conversationList, ]; // 总未读数 @@ -923,7 +923,7 @@ export const MessageListScreen: React.FC = () => {
- {isLoading && conversations.length === 0 ? ( + {isLoading && conversationList.length === 0 ? ( diff --git a/src/screens/message/NotificationsScreen.tsx b/src/screens/message/NotificationsScreen.tsx index c239f5f..a17161c 100644 --- a/src/screens/message/NotificationsScreen.tsx +++ b/src/screens/message/NotificationsScreen.tsx @@ -88,7 +88,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack ); const { - items: messages, + list: messages, isLoading, isRefreshing, hasMore, diff --git a/src/services/commentService.ts b/src/services/commentService.ts index 71989f2..4049ec5 100644 --- a/src/services/commentService.ts +++ b/src/services/commentService.ts @@ -255,15 +255,18 @@ class CommentService { params: CursorPaginationRequest = {} ): Promise> { try { - const response = await api.get>( - `/comments/post/${postId}/cursor`, - { params } - ); - return response.data; + const response = await api.get(`/comments/post/${postId}/cursor`, { params }); + const raw = response.data; + return { + list: Array.isArray(raw?.list) ? raw.list : [], + next_cursor: raw?.next_cursor ?? null, + prev_cursor: raw?.prev_cursor ?? null, + has_more: raw?.has_more ?? false, + }; } catch (error) { console.error('获取帖子评论列表失败:', error); return { - items: [], + list: [], next_cursor: null, prev_cursor: null, has_more: false, @@ -282,15 +285,18 @@ class CommentService { params: CursorPaginationRequest = {} ): Promise> { try { - const response = await api.get>( - `/comments/${commentId}/replies/cursor`, - { params } - ); - return response.data; + const response = await api.get(`/comments/${commentId}/replies/cursor`, { params }); + const raw = response.data; + return { + list: Array.isArray(raw?.list) ? raw.list : [], + next_cursor: raw?.next_cursor ?? null, + prev_cursor: raw?.prev_cursor ?? null, + has_more: raw?.has_more ?? false, + }; } catch (error) { console.error('获取评论回复列表失败:', error); return { - items: [], + list: [], next_cursor: null, prev_cursor: null, has_more: false, diff --git a/src/services/groupService.ts b/src/services/groupService.ts index f936644..f6ca233 100644 --- a/src/services/groupService.ts +++ b/src/services/groupService.ts @@ -341,7 +341,7 @@ class GroupService { } catch (error) { console.error('获取群组列表失败:', error); return { - items: [], + list: [], next_cursor: null, prev_cursor: null, has_more: false, @@ -368,7 +368,7 @@ class GroupService { } catch (error) { console.error('获取群组成员列表失败:', error); return { - items: [], + list: [], next_cursor: null, prev_cursor: null, has_more: false, @@ -395,7 +395,7 @@ class GroupService { } catch (error) { console.error('获取群公告列表失败:', error); return { - items: [], + list: [], next_cursor: null, prev_cursor: null, has_more: false, diff --git a/src/services/messageService.ts b/src/services/messageService.ts index 844e878..1bcab13 100644 --- a/src/services/messageService.ts +++ b/src/services/messageService.ts @@ -573,11 +573,17 @@ class MessageService { '/conversations/cursor', { params } ); - return response.data; + const raw = response.data; + return { + list: Array.isArray(raw?.list) ? raw.list : [], + next_cursor: raw?.next_cursor ?? null, + prev_cursor: raw?.prev_cursor ?? null, + has_more: raw?.has_more ?? false, + }; } catch (error) { console.error('获取会话列表失败:', error); return { - items: [], + list: [], next_cursor: null, prev_cursor: null, has_more: false, @@ -596,15 +602,21 @@ class MessageService { params: CursorPaginationRequest = {} ): Promise> { try { - const response = await api.get>( + const response = await api.get( `/conversations/${encodeURIComponent(conversationId)}/messages/cursor`, { params } ); - return response.data; + const raw = response.data; + return { + list: Array.isArray(raw?.list) ? raw.list : [], + next_cursor: raw?.next_cursor ?? null, + prev_cursor: raw?.prev_cursor ?? null, + has_more: raw?.has_more ?? false, + }; } catch (error) { console.error('获取消息列表失败:', error); return { - items: [], + list: [], next_cursor: null, prev_cursor: null, has_more: false, @@ -631,32 +643,36 @@ class MessageService { const data = response.data; - // 兼容两种 API 响应格式 + // 游标或带 page/total_pages 的分页(统一为 CursorPaginationResponse:list) if (data && typeof data === 'object') { - // 游标分页格式 - if (Array.isArray(data.items)) { + const list = Array.isArray(data.list) ? data.list : null; + if (list) { + const isPageShape = + typeof data.page === 'number' && + typeof data.total_pages === 'number' && + data.next_cursor === undefined && + data.prev_cursor === undefined; + if (isPageShape) { + const currentPage = data.page || 1; + const totalPages = data.total_pages || 1; + return { + list, + next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, + prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, + has_more: currentPage < totalPages, + }; + } return { - items: data.items, + list, next_cursor: data.next_cursor ?? null, prev_cursor: data.prev_cursor ?? null, has_more: data.has_more ?? false, }; } - // 传统分页格式 - 转换为游标格式 - if (Array.isArray(data.list)) { - const currentPage = data.page || 1; - const totalPages = data.total_pages || 1; - return { - items: data.list, - next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, - prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, - has_more: currentPage < totalPages, - }; - } } - + return { - items: [], + list: [], next_cursor: null, prev_cursor: null, has_more: false, @@ -664,7 +680,7 @@ class MessageService { } catch (error) { console.error('获取系统消息列表失败:', error); return { - items: [], + list: [], next_cursor: null, prev_cursor: null, has_more: false, diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index f808b70..b5de12e 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -172,7 +172,7 @@ class NotificationService { } catch (error) { console.error('获取通知列表失败:', error); return { - items: [], + list: [], next_cursor: null, prev_cursor: null, has_more: false, diff --git a/src/services/postService.ts b/src/services/postService.ts index c3675c4..9445c60 100644 --- a/src/services/postService.ts +++ b/src/services/postService.ts @@ -304,37 +304,33 @@ class PostService { const response = await api.get('/posts', requestParams); const data = response.data; - - // 兼容两种 API 响应格式: - // 1. 游标分页格式:{ items, next_cursor, prev_cursor, has_more } - // 2. 传统分页格式:{ list, total, page, page_size, total_pages } - if (data && typeof data === 'object') { - // 游标分页格式 - if (Array.isArray(data.items)) { - return { - items: data.items, - next_cursor: data.next_cursor ?? null, - prev_cursor: data.prev_cursor ?? null, - has_more: data.has_more ?? false, - }; - } - // 传统分页格式 - 转换为游标格式 - if (Array.isArray(data.list)) { - const currentPage = data.page || 1; - const totalPages = data.total_pages || 1; - return { - items: data.list, - // 使用页码作为游标 - next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, - prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, - has_more: currentPage < totalPages, - }; - } - } - - // 默认返回空数据 + + if (data && typeof data === 'object' && Array.isArray(data.list)) { + const isPageShape = + typeof data.page === 'number' && + typeof data.total_pages === 'number' && + data.next_cursor === undefined && + data.prev_cursor === undefined; + if (isPageShape) { + const currentPage = data.page || 1; + const totalPages = data.total_pages || 1; + return { + list: data.list, + next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, + prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, + has_more: currentPage < totalPages, + }; + } + return { + list: data.list, + next_cursor: data.next_cursor ?? null, + prev_cursor: data.prev_cursor ?? null, + has_more: data.has_more ?? false, + }; + } + return { - items: [], + list: [], next_cursor: null, prev_cursor: null, has_more: false, @@ -342,7 +338,7 @@ class PostService { } catch (error) { console.error('获取帖子列表失败:', error); return { - items: [], + list: [], next_cursor: null, prev_cursor: null, has_more: false, @@ -367,31 +363,33 @@ class PostService { }); const data = response.data; - - // 兼容两种 API 响应格式 - if (data && typeof data === 'object') { - if (Array.isArray(data.items)) { - return { - items: data.items, - next_cursor: data.next_cursor ?? null, - prev_cursor: data.prev_cursor ?? null, - has_more: data.has_more ?? false, - }; - } - if (Array.isArray(data.list)) { + + if (data && typeof data === 'object' && Array.isArray(data.list)) { + const isPageShape = + typeof data.page === 'number' && + typeof data.total_pages === 'number' && + data.next_cursor === undefined && + data.prev_cursor === undefined; + if (isPageShape) { const currentPage = data.page || 1; const totalPages = data.total_pages || 1; return { - items: data.list, + list: data.list, next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, has_more: currentPage < totalPages, }; } + return { + list: data.list, + next_cursor: data.next_cursor ?? null, + prev_cursor: data.prev_cursor ?? null, + has_more: data.has_more ?? false, + }; } - + return { - items: [], + list: [], next_cursor: null, prev_cursor: null, has_more: false, @@ -399,7 +397,7 @@ class PostService { } catch (error) { console.error('搜索帖子失败:', error); return { - items: [], + list: [], next_cursor: null, prev_cursor: null, has_more: false, @@ -424,31 +422,33 @@ class PostService { ); const data = response.data; - - // 兼容两种 API 响应格式 - if (data && typeof data === 'object') { - if (Array.isArray(data.items)) { - return { - items: data.items, - next_cursor: data.next_cursor ?? null, - prev_cursor: data.prev_cursor ?? null, - has_more: data.has_more ?? false, - }; - } - if (Array.isArray(data.list)) { + + if (data && typeof data === 'object' && Array.isArray(data.list)) { + const isPageShape = + typeof data.page === 'number' && + typeof data.total_pages === 'number' && + data.next_cursor === undefined && + data.prev_cursor === undefined; + if (isPageShape) { const currentPage = data.page || 1; const totalPages = data.total_pages || 1; return { - items: data.list, + list: data.list, next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, has_more: currentPage < totalPages, }; } + return { + list: data.list, + next_cursor: data.next_cursor ?? null, + prev_cursor: data.prev_cursor ?? null, + has_more: data.has_more ?? false, + }; } - + return { - items: [], + list: [], next_cursor: null, prev_cursor: null, has_more: false, @@ -456,7 +456,7 @@ class PostService { } catch (error) { console.error('获取用户帖子列表失败:', error); return { - items: [], + list: [], next_cursor: null, prev_cursor: null, has_more: false, diff --git a/src/types/dto.ts b/src/types/dto.ts index 111b7eb..b8cae24 100644 --- a/src/types/dto.ts +++ b/src/types/dto.ts @@ -489,7 +489,7 @@ export interface CursorPaginationRequest { */ export interface CursorPaginationResponse { /** 数据项列表 */ - items: T[]; + list: T[]; /** 下一页游标 */ next_cursor: string | null; /** 上一页游标 */ From d97df0b2d48c633f2158810ccd18e6bfad0a99d2 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Mon, 23 Mar 2026 13:40:48 +0800 Subject: [PATCH 15/36] refactor(MessageManager): enhance conversation list handling and integrate local source fallback - Update MessageManager to support both remote and local conversation list sources, improving data retrieval and offline capabilities. - Refactor hooks and components to utilize the new MessageManager structure, ensuring seamless integration of cursor pagination and local caching. - Introduce new types and methods for better management of conversation data and loading states. - Modify MessageListScreen to leverage the updated hooks for fetching conversations, enhancing user experience with improved loading and pagination handling. --- src/screens/message/MessageListScreen.tsx | 30 +-- src/stores/conversationListSources.ts | 133 ++++++++++ src/stores/index.ts | 17 +- src/stores/messageManager.ts | 291 +++++++++++++++++----- src/stores/messageManagerHooks.ts | 25 +- 5 files changed, 407 insertions(+), 89 deletions(-) create mode 100644 src/stores/conversationListSources.ts diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index efc2e2b..2694356 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -31,13 +31,19 @@ import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, fontSizes, shadows, borderRadius } from '../../theme'; import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments, extractTextFromSegmentsAsync, MessageSegment } from '../../types/dto'; -import { authService, messageService } from '../../services'; +import { authService } from '../../services'; import { useUserStore, useAuthStore } from '../../stores'; -// 【新架构】使用MessageManager hooks -import { messageManager, useMessageListRefresh, useCreateConversation, useUnreadCount, useMarkAsRead } from '../../stores'; +// 【新架构】使用MessageManager hooks(会话列表数据源自 MessageManager 游标同步) +import { + messageManager, + useMessageListRefresh, + useCreateConversation, + useUnreadCount, + useMarkAsRead, + useMessageManagerConversations, +} from '../../stores'; import { Avatar, Text, EmptyState, ResponsiveContainer } from '../../components/common'; import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive'; -import { useCursorPagination } from '../../hooks/useCursorPagination'; import { RootStackParamList, MessageStackParamList } from '../../navigation/types'; import { getUserCache } from '../../services/database'; // 导入 EmbeddedChat 组件用于桌面端双栏布局 @@ -162,24 +168,14 @@ export const MessageListScreen: React.FC = () => { const { isDesktop, isTablet, width } = useResponsive(); const isWideScreen = useBreakpointGTE('lg'); - // 【游标分页】使用 useCursorPagination hook 获取会话列表 + // 会话列表:MessageManager 统一游标拉取与合并,与已读/角标同源 const { - list: conversationList, + conversations: conversationList, isLoading, - isRefreshing, hasMore, loadMore, refresh, - error: paginationError, - } = useCursorPagination( - async ({ cursor, pageSize }) => { - return await messageService.getConversationsCursor({ - cursor, - page_size: pageSize, - }); - }, - { pageSize: 20 } - ); + } = useMessageManagerConversations(); // 使用 MessageManager 获取未读数和系统通知数 const { totalUnreadCount, systemUnreadCount } = useUnreadCount(); diff --git a/src/stores/conversationListSources.ts b/src/stores/conversationListSources.ts new file mode 100644 index 0000000..6cece1d --- /dev/null +++ b/src/stores/conversationListSources.ts @@ -0,0 +1,133 @@ +/** + * 会话列表数据源抽象:游标、常规页码、SQLite 等实现同一契约,MessageManager 只依赖接口。 + */ + +import { ConversationResponse } from '../types/dto'; +import { messageService } from '../services/messageService'; +import { getConversationListCache } from '../services/database'; + +export const CONVERSATION_LIST_PAGE_SIZE = 20; + +/** 单次拉取结果(一页或一批) */ +export interface ConversationListPage { + items: ConversationResponse[]; + /** 在当前源上是否还能再 loadNext 一页 */ + hasMore: boolean; +} + +/** + * 分页式会话列表源:restart 后开始新序列;首次 loadNext 为第一页,之后为后续页。 + */ +export interface IConversationListPagedSource { + restart(): void; + loadNext(): Promise; + /** 至少成功 loadNext 过一次且仍有下一页时为 true */ + readonly hasMore: boolean; +} + +/** 远端常规分页:GET /conversations?page=&page_size=(始终请求网络,避免走 getConversations 的本地短路) */ +export class NetworkOffsetConversationListPagedSource implements IConversationListPagedSource { + private nextPage = 1; + private hasMoreAfterLastLoad = false; + private loadedOnce = false; + private readonly pageSize: number; + + constructor(pageSize: number = CONVERSATION_LIST_PAGE_SIZE) { + this.pageSize = pageSize; + } + + restart(): void { + this.nextPage = 1; + this.hasMoreAfterLastLoad = false; + this.loadedOnce = false; + } + + get hasMore(): boolean { + return this.loadedOnce && this.hasMoreAfterLastLoad; + } + + async loadNext(): Promise { + const response = await messageService.getConversations(this.nextPage, this.pageSize, true); + const items = response.list || []; + const totalPages = Math.max(0, response.total_pages ?? 0); + const currentPage = response.page ?? this.nextPage; + this.hasMoreAfterLastLoad = totalPages > 0 && currentPage < totalPages; + this.nextPage = currentPage + 1; + this.loadedOnce = true; + return { items, hasMore: this.hasMoreAfterLastLoad }; + } +} + +/** 远端游标接口 */ +export class NetworkCursorConversationListPagedSource implements IConversationListPagedSource { + private nextCursor: string | null = null; + private hasMoreAfterLastLoad = false; + private loadedOnce = false; + private readonly pageSize: number; + + constructor(pageSize: number = CONVERSATION_LIST_PAGE_SIZE) { + this.pageSize = pageSize; + } + + restart(): void { + this.nextCursor = null; + this.hasMoreAfterLastLoad = false; + this.loadedOnce = false; + } + + get hasMore(): boolean { + return this.loadedOnce && this.hasMoreAfterLastLoad; + } + + async loadNext(): Promise { + const response = await messageService.getConversationsCursor( + this.nextCursor == null + ? { page_size: this.pageSize } + : { cursor: this.nextCursor, page_size: this.pageSize } + ); + const items = response.list || []; + this.nextCursor = response.next_cursor ?? null; + this.hasMoreAfterLastLoad = Boolean(response.has_more && response.next_cursor != null); + this.loadedOnce = true; + return { items, hasMore: this.hasMoreAfterLastLoad }; + } +} + +/** SQLite 会话列表缓存(单批,无后续页) */ +export class SqliteConversationListPagedSource implements IConversationListPagedSource { + private consumed = false; + + restart(): void { + this.consumed = false; + } + + get hasMore(): boolean { + return false; + } + + async loadNext(): Promise { + if (this.consumed) { + return { items: [], hasMore: false }; + } + this.consumed = true; + try { + const items = await getConversationListCache(); + return { items, hasMore: false }; + } catch (e) { + console.warn('[SqliteConversationListPagedSource] 读取会话列表缓存失败:', e); + return { items: [], hasMore: false }; + } + } +} + +/** 未注入 remote 时,按类型创建默认远端源 */ +export type RemoteConversationListSourceKind = 'cursor' | 'offset'; + +export function createRemoteConversationListSource( + kind: RemoteConversationListSourceKind, + pageSize: number = CONVERSATION_LIST_PAGE_SIZE +): IConversationListPagedSource { + return kind === 'offset' + ? new NetworkOffsetConversationListPagedSource(pageSize) + : new NetworkCursorConversationListPagedSource(pageSize); +} diff --git a/src/stores/index.ts b/src/stores/index.ts index aebb49a..927185a 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -14,7 +14,22 @@ export { // MessageManager 新架构导出(已替换 conversationStore) export { messageManager } from './messageManager'; -export type { MessageEvent, MessageEventType, MessageSubscriber } from './messageManager'; +export type { + MessageEvent, + MessageEventType, + MessageSubscriber, + MessageManagerConversationListDeps, +} from './messageManager'; +export { + CONVERSATION_LIST_PAGE_SIZE, + type ConversationListPage, + type IConversationListPagedSource, + type RemoteConversationListSourceKind, + NetworkCursorConversationListPagedSource, + NetworkOffsetConversationListPagedSource, + SqliteConversationListPagedSource, + createRemoteConversationListSource, +} from './conversationListSources'; export { postManager } from './postManager'; export { groupManager } from './groupManager'; export { userManager } from './userManager'; diff --git a/src/stores/messageManager.ts b/src/stores/messageManager.ts index 5db8812..a4c11f0 100644 --- a/src/stores/messageManager.ts +++ b/src/stores/messageManager.ts @@ -11,6 +11,8 @@ * - 统一处理SSE消息 * - 统一处理本地数据库读写 * - 提供订阅机制供React组件使用 + * + * UI 只应订阅 MessageManager / hooks:列表数据可能来自网络游标或 SQLite 缓存回退,对界面透明。 */ import { ConversationResponse, MessageResponse, MessageSegment, UserDTO } from '../types/dto'; @@ -41,14 +43,24 @@ import { saveUserCache, updateMessageStatus, deleteConversation as deleteConversationFromDb, + saveConversationsCache, + saveUsersCache, + saveGroupsCache, } from '../services/database'; import { api } from '../services/api'; import { useAuthStore } from './authStore'; +import { + type IConversationListPagedSource, + SqliteConversationListPagedSource, + createRemoteConversationListSource, + CONVERSATION_LIST_PAGE_SIZE, +} from './conversationListSources'; // ==================== 类型定义 ==================== export type MessageEventType = | 'conversations_updated' + | 'conversations_loading' | 'messages_updated' | 'unread_count_updated' | 'connection_changed' @@ -113,6 +125,19 @@ interface MessageManagerState { */ const READ_STATE_PROTECTION_DELAY = 5000; // 5秒 +/** 可注入会话列表源,便于测试或替换实现 */ +export interface MessageManagerConversationListDeps { + remoteConversationListSource?: IConversationListPagedSource; + localConversationListSource?: IConversationListPagedSource; + /** + * 未传 remoteConversationListSource 时:默认 `cursor`(/conversations/cursor), + * `offset` 为常规页码(/conversations?page=&page_size=)。 + */ + remoteListKind?: 'cursor' | 'offset'; + /** 与 remoteListKind 搭配,默认与 CONVERSATION_LIST_PAGE_SIZE 一致 */ + remoteListPageSize?: number; +} + /** * 已读状态记录接口 */ @@ -138,6 +163,14 @@ class MessageManager { private initializePromise: Promise | null = null; private activatingConversationTasks: Map> = new Map(); + /** 正在加载会话列表下一页 */ + private loadingMoreConversations = false; + + /** 远端会话列表(游标或页码,由注入/remoteListKind 决定) */ + private readonly remoteConversationListSource: IConversationListPagedSource; + /** 本地会话列表缓存(SQLite,单批) */ + private readonly localConversationListSource: IConversationListPagedSource; + /** * 正在进行中的已读 API 请求集合 * 用于防止 fetchConversations 的服务器数据覆盖乐观已读状态 @@ -151,7 +184,17 @@ class MessageManager { */ private readStateVersion: number = 0; - constructor() { + constructor(deps?: MessageManagerConversationListDeps) { + const pageSize = deps?.remoteListPageSize ?? CONVERSATION_LIST_PAGE_SIZE; + this.remoteConversationListSource = + deps?.remoteConversationListSource ?? + createRemoteConversationListSource( + deps?.remoteListKind === 'offset' ? 'offset' : 'cursor', + pageSize + ); + this.localConversationListSource = + deps?.localConversationListSource ?? new SqliteConversationListPagedSource(); + this.state = { conversations: new Map(), conversationList: [], @@ -259,6 +302,85 @@ class MessageManager { this.state.conversationList = list; } + /** + * 将游标接口返回的单条会话写入 Map(统一 string id、尊重 pendingReadMap 已读保护) + */ + private applyConversationFromFetch(conv: ConversationResponse): void { + const id = this.normalizeConversationId(conv.id); + const readRecord = this.pendingReadMap.get(id); + let next: ConversationResponse; + if (readRecord) { + const shouldPreserveLocalRead = (conv.unread_count || 0) > 0; + next = shouldPreserveLocalRead + ? { ...conv, id, unread_count: 0 } + : { ...conv, id }; + } else { + next = { ...conv, id }; + } + this.state.conversations.set(id, next); + } + + /** 与会话列表同步写入本地缓存(参与者 / 群 / 列表) */ + private persistConversationListCache(): void { + const list = Array.from(this.state.conversations.values()); + saveConversationsCache(list).catch(error => { + console.error('[MessageManager] 持久化会话列表失败:', error); + }); + saveUsersCache( + list.flatMap(conv => [ + ...(conv.participants || []), + ...(conv.last_message?.sender ? [conv.last_message.sender] : []), + ]) + ).catch(error => { + console.error('[MessageManager] 持久化会话相关用户失败:', error); + }); + saveGroupsCache( + list + .map(conv => conv.group) + .filter((group): group is NonNullable => Boolean(group)) + ).catch(error => { + console.error('[MessageManager] 持久化会话相关群组失败:', error); + }); + } + + private recomputeConversationTotalUnread(): void { + this.state.totalUnreadCount = Array.from(this.state.conversations.values()).reduce( + (sum, conv) => sum + (conv.unread_count || 0), + 0 + ); + } + + private emitConversationListAndUnreadUpdates(): void { + this.persistConversationListCache(); + this.notifySubscribers({ + type: 'conversations_updated', + payload: { conversations: this.state.conversationList }, + timestamp: Date.now(), + }); + this.notifySubscribers({ + type: 'unread_count_updated', + payload: { + totalUnreadCount: this.state.totalUnreadCount, + systemUnreadCount: this.state.systemUnreadCount, + }, + timestamp: Date.now(), + }); + } + + /** 通过本地分页源灌入内存(暖机 / 网络失败回退) */ + private async hydrateConversationsFromLocalSource(): Promise { + this.localConversationListSource.restart(); + const page = await this.localConversationListSource.loadNext(); + if (!page.items.length) { + return false; + } + this.state.conversations.clear(); + page.items.forEach(conv => this.applyConversationFromFetch(conv)); + this.updateConversationList(); + this.recomputeConversationTotalUnread(); + return true; + } + // ==================== 初始化与销毁 ==================== async initialize(): Promise { @@ -328,6 +450,9 @@ class MessageManager { this.state.isInitialized = false; this.initializePromise = null; this.activatingConversationTasks.clear(); + this.remoteConversationListSource.restart(); + this.localConversationListSource.restart(); + this.loadingMoreConversations = false; } // ==================== SSE 处理 ==================== @@ -996,49 +1121,42 @@ class MessageManager { // ==================== 数据获取方法 ==================== /** - * 获取会话列表 + * 获取会话列表(首屏/下拉刷新:优先网络游标;可回退 SQLite,对 UI 透明) */ async fetchConversations(forceRefresh = false): Promise { if (this.state.isLoadingConversations && !forceRefresh) { return; } + // 非强制刷新且内存为空:先用本地源暖机,便于离线/弱网先展示列表 + if (!forceRefresh && this.state.conversations.size === 0) { + const warmed = await this.hydrateConversationsFromLocalSource(); + if (warmed) { + this.emitConversationListAndUnreadUpdates(); + } + } + this.state.isLoadingConversations = true; + const emitLoadingToUi = this.state.conversationList.length === 0; + if (emitLoadingToUi) { + this.notifySubscribers({ + type: 'conversations_loading', + payload: { loading: true }, + timestamp: Date.now(), + }); + } try { - const response = await messageService.getConversations(1, 20, forceRefresh); - const conversations = response.list || []; + this.remoteConversationListSource.restart(); + const page = await this.remoteConversationListSource.loadNext(); - // 更新会话Map:精确保护 pendingReadMap 中的会话不被服务器旧数据覆盖 - // 场景:markAsRead API 已发出但尚未完成,服务器返回的 unread_count 仍为旧值 this.state.conversations.clear(); - conversations.forEach(conv => { - const readRecord = this.pendingReadMap.get(conv.id); - if (readRecord) { - // 此会话有进行中的已读请求或保护期内,使用智能合并逻辑 - // 如果服务器返回的 unread_count > 0,但本地有更晚的已读记录,则保留本地状态 - const shouldPreserveLocalRead = conv.unread_count > 0; - if (shouldPreserveLocalRead) { - this.state.conversations.set(conv.id, { ...conv, unread_count: 0 }); - } else { - this.state.conversations.set(conv.id, conv); - } - } else { - this.state.conversations.set(conv.id, conv); - } - }); + page.items.forEach(conv => this.applyConversationFromFetch(conv)); - // 更新排序后的列表 this.updateConversationList(); - // 计算总未读数(基于保护后的会话数据) - const totalUnread = Array.from(this.state.conversations.values()) - .reduce((sum, conv) => sum + (conv.unread_count || 0), 0); - this.state.totalUnreadCount = totalUnread; + this.recomputeConversationTotalUnread(); - // 修复本地缓存:refreshConversationsFromServer 已将服务器旧数据写入 SQLite, - // 对于 pendingReadMap 中的会话,需要重新把 unread_count=0 写回本地缓存, - // 避免下次冷启动时读到旧的未读数 if (this.pendingReadMap.size > 0) { for (const convId of this.pendingReadMap.keys()) { updateConversationCacheUnreadCount(convId, 0).catch(error => { @@ -1047,24 +1165,15 @@ class MessageManager { } } - - // 通知更新 - this.notifySubscribers({ - type: 'conversations_updated', - payload: { conversations: this.state.conversationList }, - timestamp: Date.now(), - }); - - this.notifySubscribers({ - type: 'unread_count_updated', - payload: { - totalUnreadCount: this.state.totalUnreadCount, - systemUnreadCount: this.state.systemUnreadCount, - }, - timestamp: Date.now(), - }); + this.emitConversationListAndUnreadUpdates(); } catch (error) { console.error('[MessageManager] 获取会话列表失败:', error); + if (this.state.conversationList.length === 0) { + const recovered = await this.hydrateConversationsFromLocalSource(); + if (recovered) { + this.emitConversationListAndUnreadUpdates(); + } + } this.notifySubscribers({ type: 'error', payload: { error, context: 'fetchConversations' }, @@ -1072,6 +1181,47 @@ class MessageManager { }); } finally { this.state.isLoadingConversations = false; + if (emitLoadingToUi) { + this.notifySubscribers({ + type: 'conversations_loading', + payload: { loading: false }, + timestamp: Date.now(), + }); + } + } + } + + /** + * 会话列表加载下一页(游标追加,不清空已有会话) + */ + async loadMoreConversations(): Promise { + if (!this.remoteConversationListSource.hasMore) { + return; + } + if (this.loadingMoreConversations || this.state.isLoadingConversations) { + return; + } + + this.loadingMoreConversations = true; + try { + const page = await this.remoteConversationListSource.loadNext(); + + page.items.forEach(conv => this.applyConversationFromFetch(conv)); + + this.updateConversationList(); + + this.recomputeConversationTotalUnread(); + + this.emitConversationListAndUnreadUpdates(); + } catch (error) { + console.error('[MessageManager] 加载更多会话失败:', error); + this.notifySubscribers({ + type: 'error', + payload: { error, context: 'loadMoreConversations' }, + timestamp: Date.now(), + }); + } finally { + this.loadingMoreConversations = false; } } @@ -1079,25 +1229,26 @@ class MessageManager { * 获取单个会话详情 */ async fetchConversationDetail(conversationId: string): Promise { + const normalizedId = this.normalizeConversationId(conversationId); try { - const detail = await messageService.getConversationById(conversationId); + const detail = await messageService.getConversationById(normalizedId); if (detail) { // 若此会话有进行中的已读请求或处于保护期内,保留 unread_count=0 - const readRecord = this.pendingReadMap.get(conversationId); + const readRecord = this.pendingReadMap.get(normalizedId); const isPending = !!readRecord; // 使用智能合并:如果服务器返回 unread_count > 0 但本地有更晚的已读记录,保留本地状态 const shouldPreserveLocalRead = isPending && (detail.unread_count || 0) > 0; const safeDetail = shouldPreserveLocalRead - ? { ...detail as ConversationResponse, unread_count: 0 } - : detail as ConversationResponse; - this.state.conversations.set(conversationId, safeDetail); + ? { ...(detail as ConversationResponse), id: normalizedId, unread_count: 0 } + : { ...(detail as ConversationResponse), id: normalizedId }; + this.state.conversations.set(normalizedId, safeDetail); this.updateConversationList(); // getConversationById 内部会调用 saveConversationCache 写入服务器旧数据, // 若有 pending 已读请求或处于保护期内,需要修复本地缓存 if (isPending) { - updateConversationCacheUnreadCount(conversationId, 0).catch(error => { - console.error('[MessageManager] 修复本地缓存未读数失败:', conversationId, error); + updateConversationCacheUnreadCount(normalizedId, 0).catch(error => { + console.error('[MessageManager] 修复本地缓存未读数失败:', normalizedId, error); }); } @@ -1554,15 +1705,16 @@ class MessageManager { * 3. fetchConversations 使用版本号判断数据新旧 */ async markAsRead(conversationId: string, seq: number): Promise { + const normalizedId = this.normalizeConversationId(conversationId); - const conversation = this.state.conversations.get(conversationId); + const conversation = this.state.conversations.get(normalizedId); if (!conversation) { - console.warn('[MessageManager] 会话不存在:', conversationId); + console.warn('[MessageManager] 会话不存在:', normalizedId); return; } const prevUnreadCount = conversation.unread_count || 0; - const existingRecord = this.pendingReadMap.get(conversationId); + const existingRecord = this.pendingReadMap.get(normalizedId); // 使用 seq 去重:同一会话若已上报到更大/相同 seq,则跳过重复上报 if (existingRecord && seq <= existingRecord.lastReadSeq) { @@ -1580,7 +1732,7 @@ class MessageManager { // 1. 标记此会话有进行中的已读请求(防止 fetchConversations 覆盖乐观状态) // 记录时间戳和版本号,用于后续智能合并 - this.pendingReadMap.set(conversationId, { + this.pendingReadMap.set(normalizedId, { timestamp: Date.now(), version: currentVersion, lastReadSeq: seq, @@ -1591,18 +1743,18 @@ class MessageManager { ...conversation, unread_count: 0, }; - this.state.conversations.set(conversationId, updatedConv); + this.state.conversations.set(normalizedId, updatedConv); this.state.totalUnreadCount = Math.max(0, this.state.totalUnreadCount - prevUnreadCount); this.updateConversationList(); // 3. 更新本地数据库 - markConversationAsRead(conversationId).catch(console.error); + markConversationAsRead(normalizedId).catch(console.error); // 4. 立即通知所有订阅者 this.notifySubscribers({ type: 'message_read', payload: { - conversationId, + conversationId: normalizedId, unreadCount: 0, totalUnreadCount: this.state.totalUnreadCount, }, @@ -1626,40 +1778,40 @@ class MessageManager { // 5. 调用 API,完成后设置延迟清除保护 try { - await messageService.markAsRead(conversationId, seq); + await messageService.markAsRead(normalizedId, seq); } catch (error) { console.error('[MessageManager] 标记已读API失败:', error); // 失败时回滚状态 - this.state.conversations.set(conversationId, conversation); + this.state.conversations.set(normalizedId, conversation); this.state.totalUnreadCount += prevUnreadCount; this.updateConversationList(); this.notifySubscribers({ type: 'message_read', payload: { - conversationId, + conversationId: normalizedId, unreadCount: prevUnreadCount, totalUnreadCount: this.state.totalUnreadCount, }, timestamp: Date.now(), }); // API 失败时立即清除保护,允许后续 fetch 恢复状态 - this.pendingReadMap.delete(conversationId); + this.pendingReadMap.delete(normalizedId); return; } // API 成功后,设置延迟清除保护 // 这确保在 API 完成后的一段时间内,fetchConversations 不会用服务器旧数据覆盖已读状态 const clearTimer = setTimeout(() => { - const record = this.pendingReadMap.get(conversationId); + const record = this.pendingReadMap.get(normalizedId); // 只有版本号匹配时才清除(防止清除新的已读请求的保护) if (record && record.version === currentVersion) { - this.pendingReadMap.delete(conversationId); + this.pendingReadMap.delete(normalizedId); } }, READ_STATE_PROTECTION_DELAY); // 更新记录,保存定时器ID - this.pendingReadMap.set(conversationId, { + this.pendingReadMap.set(normalizedId, { timestamp: Date.now(), version: currentVersion, lastReadSeq: seq, @@ -1980,6 +2132,13 @@ class MessageManager { // ==================== 状态查询方法 ==================== + /** + * 会话列表是否还能加载更多(由数据源响应推导,不暴露游标本身) + */ + canLoadMoreConversations(): boolean { + return this.remoteConversationListSource.hasMore; + } + /** * 获取会话列表 */ diff --git a/src/stores/messageManagerHooks.ts b/src/stores/messageManagerHooks.ts index ccf36cd..3962210 100644 --- a/src/stores/messageManagerHooks.ts +++ b/src/stores/messageManagerHooks.ts @@ -16,17 +16,20 @@ interface UseConversationsReturn { conversations: ConversationResponse[]; isLoading: boolean; refresh: () => Promise; + loadMore: () => Promise; + hasMore: boolean; } /** - * 获取会话列表 - * 用于MessageListScreen等需要显示会话列表的组件 + * 获取会话列表(仅消费 MessageManager;网络游标与 SQLite 回退均在 Manager 内完成,调用方无感) + * 用于 MessageListScreen 等需要显示会话列表的组件 */ export function useConversations(): UseConversationsReturn { const [conversations, setConversations] = useState( () => messageManager.getConversations() ); const [isLoading, setIsLoading] = useState(() => messageManager.isLoading()); + const [hasMore, setHasMore] = useState(() => messageManager.canLoadMoreConversations()); useEffect(() => { // 订阅MessageManager的事件 @@ -34,6 +37,10 @@ export function useConversations(): UseConversationsReturn { switch (event.type) { case 'conversations_updated': setConversations(messageManager.getConversations()); + setHasMore(messageManager.canLoadMoreConversations()); + break; + case 'conversations_loading': + setIsLoading(!!event.payload?.loading); break; case 'connection_changed': // 连接状态变化时可能需要刷新 @@ -60,15 +67,19 @@ export function useConversations(): UseConversationsReturn { }, []); const refresh = useCallback(async () => { - setIsLoading(true); await messageManager.fetchConversations(true); - setIsLoading(false); + }, []); + + const loadMore = useCallback(async () => { + await messageManager.loadMoreConversations(); }, []); return { conversations, isLoading, refresh, + loadMore, + hasMore, }; } @@ -672,6 +683,8 @@ interface UseMessageListReturn { conversations: ConversationResponse[]; isLoading: boolean; refresh: () => Promise; + loadMore: () => Promise; + hasMore: boolean; totalUnreadCount: number; systemUnreadCount: number; markAllAsRead: () => Promise; @@ -679,7 +692,7 @@ interface UseMessageListReturn { } export function useMessageList(): UseMessageListReturn { - const { conversations, isLoading, refresh } = useConversations(); + const { conversations, isLoading, refresh, loadMore, hasMore } = useConversations(); const { totalUnreadCount, systemUnreadCount } = useUnreadCount(); const { markAllAsRead, isMarking } = useMarkAsRead(null); @@ -687,6 +700,8 @@ export function useMessageList(): UseMessageListReturn { conversations, isLoading, refresh, + loadMore, + hasMore, totalUnreadCount, systemUnreadCount, markAllAsRead, From 7305254e110f3670c2c50c9905b8b70a84fa2a10 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Mon, 23 Mar 2026 14:21:22 +0800 Subject: [PATCH 16/36] refactor(PostCard, ImageGrid, SmartImage): enhance post editing display and image handling - Introduced a new method to determine the last edited time for posts, prioritizing content_edited_at for better accuracy in display. - Updated PostCard and PostDetailScreen components to utilize the new editing display logic. - Enhanced ImageGrid and SmartImage components to support additional preview URLs and improve image loading behavior, including lazy loading optimizations for web. - Modified Post and PostDTO interfaces to include contentEditedAt for better tracking of post edits. --- src/components/business/PostCard.tsx | 37 +++++++++++++----- src/components/common/ImageGrid.tsx | 52 +++++++++++++++++-------- src/components/common/SmartImage.tsx | 44 ++++++++++++++++++++- src/core/entities/Post.ts | 6 ++- src/data/mappers/PostMapper.ts | 7 +++- src/data/repositories/PostRepository.ts | 3 ++ src/screens/home/PostDetailScreen.tsx | 36 ++++++++++++----- src/types/dto.ts | 2 + 8 files changed, 148 insertions(+), 39 deletions(-) diff --git a/src/components/business/PostCard.tsx b/src/components/business/PostCard.tsx index fb5fabd..7f1e39c 100644 --- a/src/components/business/PostCard.tsx +++ b/src/components/business/PostCard.tsx @@ -135,12 +135,24 @@ const PostCard: React.FC = ({ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; }; - const isPostEdited = (createdAt?: string, updatedAt?: string): boolean => { - if (!createdAt || !updatedAt) return false; - const created = new Date(createdAt).getTime(); - const updated = new Date(updatedAt).getTime(); - if (Number.isNaN(created) || Number.isNaN(updated)) return false; - return updated - created > 1000; + /** 返回应展示的「最后编辑」时间字符串;优先 content_edited_at(与点赞/评论等统计更新解耦) */ + const getPostEditedDisplayAt = ( + createdAt?: string | null, + contentEditedAt?: string | null, + updatedAt?: string | null, + ): string | null => { + if (contentEditedAt) { + const c = new Date(createdAt || '').getTime(); + const e = new Date(contentEditedAt).getTime(); + if (!Number.isNaN(c) && !Number.isNaN(e) && e - c > 1000) return contentEditedAt; + return null; + } + if (createdAt && updatedAt) { + const c = new Date(createdAt).getTime(); + const u = new Date(updatedAt).getTime(); + if (!Number.isNaN(c) && !Number.isNaN(u) && u - c > 1000) return updatedAt; + } + return null; }; const getTruncatedContent = (content: string | undefined | null, maxLength: number = 100): string => { @@ -560,11 +572,18 @@ const PostCard: React.FC = ({ 发布 {formatDateTime(post.created_at)} - {isPostEdited(post.created_at, post.updated_at) && ( + {(() => { + const editedAt = getPostEditedDisplayAt( + post.created_at, + post.content_edited_at, + post.updated_at, + ); + return editedAt ? ( - {' · 修改 '}{formatDateTime(post.updated_at)} + {' · 修改 '}{formatDateTime(editedAt)} - )} + ) : null; + })()}
{post.is_pinned && ( diff --git a/src/components/common/ImageGrid.tsx b/src/components/common/ImageGrid.tsx index ca28182..a96fb9b 100644 --- a/src/components/common/ImageGrid.tsx +++ b/src/components/common/ImageGrid.tsx @@ -15,7 +15,7 @@ import { Text, Image, } from 'react-native'; -import { SmartImage, ImageSource } from './SmartImage'; +import { SmartImage } from './SmartImage'; import { colors, spacing, borderRadius } from '../../theme'; import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper'; @@ -40,6 +40,8 @@ export interface ImageGridItem { url?: string; thumbnailUrl?: string; thumbnail_url?: string; + preview_url?: string; + preview_url_large?: string; width?: number; height?: number; } @@ -108,6 +110,8 @@ interface SingleImageItemProps { maxHeight: number; borderRadiusValue: number; onPress: () => void; + usePreview: boolean; + displayMode: ImageDisplayMode; } const SingleImageItem: React.FC = ({ @@ -116,23 +120,34 @@ const SingleImageItem: React.FC = ({ maxHeight, borderRadiusValue, onPress, + usePreview, + displayMode, }) => { const [aspectRatio, setAspectRatio] = useState(null); - const uri = image.uri || image.url || ''; + const mainUri = image.uri || image.url || ''; + const thumbnailUri = + image.thumbnail_url || + image.thumbnailUrl || + image.preview_url || + ''; useEffect(() => { - if (!uri) return; + if (image.width && image.height) { + setAspectRatio(image.width / image.height); + return; + } + const probeUri = thumbnailUri || mainUri; + if (!probeUri) return; let cancelled = false; - - // 添加超时处理,防止高分辨率图片加载卡住 + const timeoutId = setTimeout(() => { if (!cancelled && aspectRatio === null) { setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO); } }, 3000); - + Image.getSize( - uri, + probeUri, (w, h) => { if (!cancelled) { clearTimeout(timeoutId); @@ -146,11 +161,11 @@ const SingleImageItem: React.FC = ({ } }, ); - return () => { - cancelled = true; + return () => { + cancelled = true; clearTimeout(timeoutId); }; - }, [uri]); + }, [image.width, image.height, thumbnailUri, mainUri]); const effectiveContainerWidth = containerWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING; @@ -189,13 +204,19 @@ const SingleImageItem: React.FC = ({ width = Math.min(SINGLE_IMAGE_MIN_HEIGHT * aspectRatio, effectiveContainerWidth); } + const previewForSmart = + usePreview && displayMode ? getPreviewImageUrl(image as any, displayMode) : ''; + const useSmartPreview = !!(usePreview && previewForSmart && mainUri && previewForSmart !== mainUri); + return ( = ({ const renderSingleImage = () => { const image = displayImages[0]; if (!image) return null; - - // 获取预览图 URL - const previewUrl = usePreview && displayMode - ? getPreviewImageUrl(image as any, displayMode) - : (image.uri || image.url || ''); - + return ( = ({ maxHeight={singleImageMaxHeight} borderRadiusValue={borderRadiusValue} onPress={() => handleImagePress(0)} + usePreview={usePreview} + displayMode={displayMode} /> ); }; diff --git a/src/components/common/SmartImage.tsx b/src/components/common/SmartImage.tsx index da7851c..c2a089a 100644 --- a/src/components/common/SmartImage.tsx +++ b/src/components/common/SmartImage.tsx @@ -13,6 +13,7 @@ import { ActivityIndicator, Pressable, StyleProp, + Platform, } from 'react-native'; import { Image as ExpoImage } from 'expo-image'; import { MaterialCommunityIcons } from '@expo/vector-icons'; @@ -61,6 +62,8 @@ export interface SmartImageProps { usePreview?: boolean; /** 是否懒加载 */ lazyLoad?: boolean; + /** Web 端 IntersectionObserver rootMargin,略提前加载进入视口附近的图 */ + lazyRootMargin?: string; /** 缓存策略 */ cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none'; } @@ -85,11 +88,13 @@ export const SmartImage: React.FC = ({ previewUrl, usePreview = false, lazyLoad = false, + lazyRootMargin = '160px', cachePolicy = 'memory-disk', }) => { const [loadState, setLoadState] = useState('loading'); - const [isVisible, setIsVisible] = useState(!lazyLoad); + const [isVisible, setIsVisible] = useState(() => !lazyLoad || Platform.OS !== 'web'); const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false); + const lazyTargetRef = useRef(null); // 解析图片源 - 支持 uri 或 url 字段 const imageUri = typeof source === 'string' @@ -99,6 +104,39 @@ export const SmartImage: React.FC = ({ // 解析预览图 URL const previewUri = typeof previewUrl === 'string' ? previewUrl : ''; + useEffect(() => { + setShouldLoadOriginal(false); + setLoadState('loading'); + }, [imageUri]); + + useEffect(() => { + if (!lazyLoad) { + setIsVisible(true); + return; + } + if (Platform.OS !== 'web') { + setIsVisible(true); + return; + } + setIsVisible(false); + const node = lazyTargetRef.current as unknown as Element | null; + if (!node || typeof IntersectionObserver === 'undefined') { + setIsVisible(true); + return; + } + const observer = new IntersectionObserver( + entries => { + if (entries.some(e => e.isIntersecting)) { + setIsVisible(true); + observer.disconnect(); + } + }, + { rootMargin: lazyRootMargin } + ); + observer.observe(node); + return () => observer.disconnect(); + }, [lazyLoad, lazyRootMargin, imageUri]); + // 智能选择图片 URL const getImageUrl = useCallback((): string => { // 如果应该加载原图(预览图已加载完成或没有预览图) @@ -200,10 +238,12 @@ export const SmartImage: React.FC = ({ ); } - // 如果是懒加载且不可见,显示占位 + // 如果是懒加载且不可见,显示占位(Web 上由 IntersectionObserver 驱动进入视口后再加载) if (lazyLoad && !isVisible) { return ( diff --git a/src/core/entities/Post.ts b/src/core/entities/Post.ts index 0d008dc..7a92309 100644 --- a/src/core/entities/Post.ts +++ b/src/core/entities/Post.ts @@ -81,7 +81,9 @@ export interface Post { createdAt: string; /** 更新时间 */ updatedAt: string; - + /** 用户编辑内容的时间(与统计类更新解耦) */ + contentEditedAt?: string; + // ==================== 向后兼容字段 (snake_case) ==================== // 这些字段用于与旧代码兼容,新代码应使用 camelCase 版本 @@ -107,6 +109,8 @@ export interface Post { created_at?: string; /** @deprecated 请使用 updatedAt */ updated_at?: string; + /** @deprecated 请使用 contentEditedAt */ + content_edited_at?: string; /** @deprecated 请使用 communityId */ community_id?: string; /** @deprecated 请使用 status */ diff --git a/src/data/mappers/PostMapper.ts b/src/data/mappers/PostMapper.ts index 32b5a52..3f4a2e0 100644 --- a/src/data/mappers/PostMapper.ts +++ b/src/data/mappers/PostMapper.ts @@ -27,7 +27,12 @@ export class PostMapper { communityId: response.community_id, tags: [], createdAt: new Date(response.created_at || Date.now()), - updatedAt: new Date(response.updated_at || Date.now()), + // 空字符串/缺省时用 created_at,避免误用 Date.now() 导致全部显示「刚修改」 + updatedAt: new Date( + response.updated_at || + response.created_at || + Date.now() + ), }; } diff --git a/src/data/repositories/PostRepository.ts b/src/data/repositories/PostRepository.ts index 4841a11..a592c98 100644 --- a/src/data/repositories/PostRepository.ts +++ b/src/data/repositories/PostRepository.ts @@ -41,6 +41,7 @@ interface PostApiResponse { tags?: string[]; created_at: string; updated_at: string; + content_edited_at?: string; author?: { id: number; username: string; @@ -122,6 +123,7 @@ export class PostRepository implements IPostRepository { tags: model.tags || [], createdAt: model.createdAt.toISOString(), updatedAt: model.updatedAt.toISOString(), + contentEditedAt: response.content_edited_at, // snake_case 字段(向后兼容) likes_count: model.likeCount, @@ -135,6 +137,7 @@ export class PostRepository implements IPostRepository { user_id: model.authorId, created_at: model.createdAt.toISOString(), updated_at: model.updatedAt.toISOString(), + content_edited_at: response.content_edited_at, community_id: model.communityId, }; } diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index ad5e071..2e8a639 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -318,12 +318,23 @@ export const PostDetailScreen: React.FC = () => { return formatDateTime(dateString); }; - const isPostEdited = (createdAt?: string, updatedAt?: string): boolean => { - if (!createdAt || !updatedAt) return false; - const created = new Date(createdAt).getTime(); - const updated = new Date(updatedAt).getTime(); - if (Number.isNaN(created) || Number.isNaN(updated)) return false; - return updated-created > 1000; + const getPostEditedDisplayAt = ( + createdAt?: string | null, + contentEditedAt?: string | null, + updatedAt?: string | null, + ): string | null => { + if (contentEditedAt) { + const c = new Date(createdAt || '').getTime(); + const e = new Date(contentEditedAt).getTime(); + if (!Number.isNaN(c) && !Number.isNaN(e) && e - c > 1000) return contentEditedAt; + return null; + } + if (createdAt && updatedAt) { + const c = new Date(createdAt).getTime(); + const u = new Date(updatedAt).getTime(); + if (!Number.isNaN(c) && !Number.isNaN(u) && u - c > 1000) return updatedAt; + } + return null; }; // 格式化数字 @@ -1081,14 +1092,21 @@ export const PostDetailScreen: React.FC = () => { 发布 {formatRelativeTime(post.created_at)} - {isPostEdited(post.created_at, post.updated_at) && ( + {(() => { + const editedAt = getPostEditedDisplayAt( + post.created_at, + post.content_edited_at, + post.updated_at, + ); + return editedAt ? ( <> · - 修改 {formatRelativeTime(post.updated_at)} + 修改 {formatRelativeTime(editedAt)} - )} + ) : null; + })()} {post.views_count !== undefined && post.views_count > 0 && ( <> · diff --git a/src/types/dto.ts b/src/types/dto.ts index b8cae24..bf610e5 100644 --- a/src/types/dto.ts +++ b/src/types/dto.ts @@ -74,6 +74,8 @@ export interface PostDTO { is_vote: boolean; created_at: string; updated_at?: string; + /** 用户编辑正文/标题/图片的时间;优先用于「已编辑」展示 */ + content_edited_at?: string; author: UserDTO | null; is_liked: boolean; is_favorited: boolean; From c98f1917f733dc41f500aaca7351514873d2064f Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Mon, 23 Mar 2026 23:06:19 +0800 Subject: [PATCH 17/36] refactor(LocalDataSource, PostRepository, ChatScreen): enhance database handling and message loading - Introduced a promise-based initialization mechanism in LocalDataSource to prevent concurrent initialization issues. - Updated PostRepository to utilize an enqueueWrite method for database operations, ensuring thread-safe writes. - Refactored ChatScreen to improve message loading logic, including preloading history and managing scroll behavior more effectively. - Enhanced message rendering logic to maintain correct indices in inverted lists and optimize user experience during scrolling. --- src/data/datasources/LocalDataSource.ts | 27 ++ src/data/repositories/PostRepository.ts | 18 +- src/screens/message/ChatScreen.tsx | 206 +++++++++++---- src/screens/message/MessageListScreen.tsx | 5 +- .../components/ChatScreen/LongPressMenu.tsx | 35 ++- .../components/ChatScreen/useChatScreen.ts | 239 ++++++++++-------- src/services/database.ts | 16 +- src/services/sseService.ts | 5 - src/stores/messageManager.ts | 30 ++- 9 files changed, 396 insertions(+), 185 deletions(-) diff --git a/src/data/datasources/LocalDataSource.ts b/src/data/datasources/LocalDataSource.ts index 7126779..a0b5e1e 100644 --- a/src/data/datasources/LocalDataSource.ts +++ b/src/data/datasources/LocalDataSource.ts @@ -20,6 +20,7 @@ export class LocalDataSource implements ILocalDataSource { private db: SQLite.SQLiteDatabase | null = null; private dbName: string; private initialized = false; + private initializingPromise: Promise | null = null; constructor(config: LocalDataSourceConfig = {}) { // 如果提供了userId,使用用户专属数据库 @@ -34,6 +35,21 @@ export class LocalDataSource implements ILocalDataSource { return; } + // 防止并发初始化导致连接状态竞争 + if (this.initializingPromise) { + await this.initializingPromise; + return; + } + + this.initializingPromise = this.doInitialize(); + try { + await this.initializingPromise; + } finally { + this.initializingPromise = null; + } + } + + private async doInitialize(): Promise { try { // 使用全局实例管理 if (dbInstance && currentDbName === this.dbName) { @@ -227,6 +243,17 @@ export class LocalDataSource implements ILocalDataSource { } return await db.runAsync(sql); } catch (error) { + if (this.isRecoverableError(error)) { + this.db = null; + this.initialized = false; + dbInstance = null; + await this.initialize(); + const db = this.ensureDb(); + if (params && params.length > 0) { + return await db.runAsync(sql, params as any); + } + return await db.runAsync(sql); + } this.handleError(error, 'RUN'); } } diff --git a/src/data/repositories/PostRepository.ts b/src/data/repositories/PostRepository.ts index a592c98..0c189be 100644 --- a/src/data/repositories/PostRepository.ts +++ b/src/data/repositories/PostRepository.ts @@ -195,10 +195,12 @@ export class PostRepository implements IPostRepository { private async saveToLocalCache(post: Post): Promise { try { await this.localDb.initialize(); - await this.localDb.run( - `INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`, - [post.id, JSON.stringify(post), new Date().toISOString()] - ); + await this.localDb.enqueueWrite(async () => { + await this.localDb.run( + `INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`, + [post.id, JSON.stringify(post), new Date().toISOString()] + ); + }); } catch (error) { console.error('[PostRepository] 保存本地缓存失败:', error); } @@ -210,7 +212,9 @@ export class PostRepository implements IPostRepository { private async clearLocalCache(id: string): Promise { try { await this.localDb.initialize(); - await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]); + await this.localDb.enqueueWrite(async () => { + await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]); + }); } catch (error) { console.error('[PostRepository] 清除本地缓存失败:', error); } @@ -545,7 +549,9 @@ export class PostRepository implements IPostRepository { this.memoryCache.clear(); try { await this.localDb.initialize(); - await this.localDb.run('DELETE FROM posts_cache'); + await this.localDb.enqueueWrite(async () => { + await this.localDb.run('DELETE FROM posts_cache'); + }); } catch (error) { console.error('[PostRepository] 清除所有缓存失败:', error); } diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index 5dafe28..5211e47 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -22,12 +22,11 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { View, - TouchableWithoutFeedback, + FlatList, ActivityIndicator, KeyboardAvoidingView, Platform, } from 'react-native'; -import { FlashList } from "@shopify/flash-list"; import { useNavigation } from '@react-navigation/native'; import { StatusBar } from 'expo-status-bar'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -78,6 +77,15 @@ export const ChatScreen: React.FC = () => { // 输入框区域高度(用于定位浮动 mention 面板) const [inputWrapperHeight, setInputWrapperHeight] = useState(60); + const isPreloadingRef = useRef(false); + const lastScrollYRef = useRef(0); + const isUserDraggingRef = useRef(false); + const historyAnchorRef = useRef<{ active: boolean; beforeY: number; beforeHeight: number }>({ + active: false, + beforeY: 0, + beforeHeight: 0, + }); + const preloadCooldownTimerRef = useRef | null>(null); const containerStyle = useMemo(() => ([ styles.container, @@ -194,8 +202,17 @@ export const ChatScreen: React.FC = () => { navigateToChatSettings, loadMoreHistory, handleMessageListContentSizeChange, + handleReachLatestEdge, } = useChatScreen(); + useEffect(() => { + return () => { + if (preloadCooldownTimerRef.current) { + clearTimeout(preloadCooldownTimerRef.current); + } + }; + }, []); + // 监听返回事件,刷新会话列表 useEffect(() => { const unsubscribe = navigation.addListener('beforeRemove', () => { @@ -206,38 +223,92 @@ export const ChatScreen: React.FC = () => { }, [navigation]); // 渲染消息气泡 - const renderMessage = ({ item, index }: { item: any; index: number }) => ( - - ); + const messageIndexMap = useMemo(() => { + const map = new Map(); + messages.forEach((msg, idx) => { + map.set(String(msg.id), idx); + }); + return map; + }, [messages]); + + const renderMessage = ({ item, index }: { item: any; index: number }) => { + // inverted 下 renderItem 的 index 与时间顺序不一致,需回查原始序索引 + const logicalIndex = messageIndexMap.get(String(item.id)) ?? index; + return ( + + ); + }; // 获取正在输入提示 const typingHint = getTypingHint(); + const displayMessages = useMemo(() => [...messages].reverse(), [messages]); const handleMessageListScroll = useCallback((event: any) => { - const { contentSize, contentOffset } = event.nativeEvent; + const { contentSize, contentOffset, layoutMeasurement } = event.nativeEvent; scrollPositionRef.current = { contentHeight: contentSize.height, scrollY: contentOffset.y, + viewportHeight: layoutMeasurement.height, }; - }, [scrollPositionRef]); + + // 仅用户手势主动回到底部时才解除历史阅读锁(避免加载阶段误解锁) + const isScrollingTowardLatest = contentOffset.y < lastScrollYRef.current; + if ( + isUserDraggingRef.current && + !loadingMore && + isScrollingTowardLatest && + contentOffset.y <= 40 + ) { + handleReachLatestEdge(); + } + + // inverted 下历史端在“列表尾部”,对应 offset 增大且距离尾部变小 + const distanceToHistoryEdge = contentSize.height - (contentOffset.y + layoutMeasurement.height); + const isScrollingTowardHistory = contentOffset.y > lastScrollYRef.current; + lastScrollYRef.current = contentOffset.y; + + const PRELOAD_HISTORY_THRESHOLD = 120; + if ( + distanceToHistoryEdge <= PRELOAD_HISTORY_THRESHOLD && + isScrollingTowardHistory && + hasMoreHistory && + !loadingMore && + !loading && + !isPreloadingRef.current + ) { + isPreloadingRef.current = true; + loadMoreHistory().finally(() => { + if (preloadCooldownTimerRef.current) { + clearTimeout(preloadCooldownTimerRef.current); + } + preloadCooldownTimerRef.current = setTimeout(() => { + isPreloadingRef.current = false; + }, 350); + }); + } + }, [scrollPositionRef, hasMoreHistory, loadingMore, loading, loadMoreHistory, handleReachLatestEdge]); + + const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => { + handleMessageListContentSizeChange(contentWidth, contentHeight); + }, [handleMessageListContentSizeChange]); return ( { /> {/* 消息列表 */} - - - {loading ? ( - - + { + scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height; + }} + > + {loading ? ( + + + + ) : ( + String(item.id)} + contentContainerStyle={listContentStyle as any} + showsVerticalScrollIndicator={false} + keyboardShouldPersistTaps="handled" + onScroll={handleMessageListScroll} + onScrollBeginDrag={() => { + isUserDraggingRef.current = true; + handleDismiss(); + }} + onScrollEndDrag={() => { + isUserDraggingRef.current = false; + }} + onMomentumScrollEnd={() => { + isUserDraggingRef.current = false; + }} + scrollEventThrottle={16} + onContentSizeChange={handleContentSizeChange} + /> + )} + {loadingMore && ( + + + - ) : ( - String(item.id)} - contentContainerStyle={listContentStyle as any} - showsVerticalScrollIndicator={false} - keyboardShouldPersistTaps="handled" - refreshing={loadingMore} - onRefresh={hasMoreHistory ? loadMoreHistory : undefined} - // @ts-ignore FlashList 类型定义问题:estimatedItemSize 是必需属性但类型定义缺失 - estimatedItemSize={80} - maintainVisibleContentPosition={{ - minIndexForVisible: 0, - autoscrollToTopThreshold: undefined, - } as any} - onScroll={handleMessageListScroll} - /> - )} - - + + )} + {/* 底部区域:输入框 + 面板 */} 0 ? keyboardHeight : 0 }}> diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index 2694356..e128038 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -23,6 +23,7 @@ import { TextInput, Keyboard, BackHandler, + Platform, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useIsFocused } from '@react-navigation/native'; @@ -334,12 +335,12 @@ export const MessageListScreen: React.FC = () => { Animated.timing(scaleAnims[index], { toValue: 0.98, duration: 100, - useNativeDriver: true, + useNativeDriver: Platform.OS !== 'web', }), Animated.timing(scaleAnims[index], { toValue: 1, duration: 100, - useNativeDriver: true, + useNativeDriver: Platform.OS !== 'web', }), ]).start(() => { if (conversation.id === SYSTEM_MESSAGE_CHANNEL_ID) { diff --git a/src/screens/message/components/ChatScreen/LongPressMenu.tsx b/src/screens/message/components/ChatScreen/LongPressMenu.tsx index 3c21ddf..1c6f901 100644 --- a/src/screens/message/components/ChatScreen/LongPressMenu.tsx +++ b/src/screens/message/components/ChatScreen/LongPressMenu.tsx @@ -7,11 +7,12 @@ import { View, Modal, TouchableOpacity, - TouchableWithoutFeedback, + Pressable, Animated, Alert, Clipboard, Dimensions, + Platform, } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { chatScreenStyles as styles } from './styles'; @@ -35,34 +36,41 @@ export const LongPressMenu: React.FC = ({ }) => { 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(); Animated.parallel([ Animated.spring(scaleAnimation, { toValue: 1, - useNativeDriver: true, + useNativeDriver: Platform.OS !== 'web', friction: 8, tension: 100, }), Animated.timing(opacityAnimation, { toValue: 1, duration: 150, - useNativeDriver: true, + useNativeDriver: Platform.OS !== 'web', }), ]).start(); } else { + blurActiveElementOnWeb(); Animated.parallel([ Animated.timing(scaleAnimation, { toValue: 0, duration: 150, - useNativeDriver: true, + useNativeDriver: Platform.OS !== 'web', }), Animated.timing(opacityAnimation, { toValue: 0, duration: 150, - useNativeDriver: true, + useNativeDriver: Platform.OS !== 'web', }), ]).start(); } @@ -242,11 +250,18 @@ export const LongPressMenu: React.FC = ({ visible={visible} transparent animationType="none" + onShow={blurActiveElementOnWeb} onRequestClose={onClose} > - - - + { + blurActiveElementOnWeb(); + onClose(); + }} + style={styles.qqMenuOverlay} + > + + {}}> = ({ ))} - + - +
); }; diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 77351b6..d622f94 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -114,10 +114,15 @@ export const useChatScreen = () => { const flatListRef = useRef(null); const textInputRef = useRef(null); - // 防止重复加载的 ref - const shouldAutoScrollOnEnterRef = useRef(true); - const autoScrollTimersRef = useRef[]>([]); - const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0 }); + // 滚动状态机 refs(Telegram/Element 风格:底部粘附 + 阅读锚点) + const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0, viewportHeight: 0 }); + const hasInitialAnchorDoneRef = useRef(false); + const prevMessageCountRef = useRef(0); + const prevLatestSeqRef = useRef(0); + const prevMarkedReadSeqRef = useRef(0); + const isProgrammaticScrollRef = useRef(false); + const suppressAutoFollowRef = useRef(false); + const isBrowsingHistoryRef = useRef(false); // 回复消息状态 const [replyingTo, setReplyingTo] = useState(null); @@ -196,10 +201,13 @@ export const useChatScreen = () => { return '对方未关注你前:仅可发送1条文字消息,且不能发送图片'; }, [followRestricted, myPrivateSentCount]); - // 【改造】同步加载状态 + // 加载态语义修正: + // isLoadingMessages 在分页加载历史时也会短暂为 true。 + // 若直接驱动 ChatScreen 的 loading,会导致消息列表被卸载重挂载,触发“回到底部”。 + // 这里只在“首屏且尚无消息”时展示 loading 占位。 useEffect(() => { - setLoading(isLoadingMessages); - }, [isLoadingMessages]); + setLoading(isLoadingMessages && messageManagerMessages.length === 0); + }, [isLoadingMessages, messageManagerMessages.length]); // 【改造】同步 hasMore 状态 useEffect(() => { @@ -263,32 +271,73 @@ export const useChatScreen = () => { }; }, [isGroupChat, otherUserId]); - // 进入新会话时重置首次自动滚动标记 + // 进入新会话时重置滚动状态 useEffect(() => { - shouldAutoScrollOnEnterRef.current = true; - autoScrollTimersRef.current.forEach(clearTimeout); - autoScrollTimersRef.current = []; + hasInitialAnchorDoneRef.current = false; + prevMessageCountRef.current = 0; + prevLatestSeqRef.current = 0; + prevMarkedReadSeqRef.current = 0; + suppressAutoFollowRef.current = false; + isBrowsingHistoryRef.current = false; }, [conversationId]); - // 组件卸载时清理定时器 - useEffect(() => { - return () => { - autoScrollTimersRef.current.forEach(clearTimeout); - autoScrollTimersRef.current = []; - }; - }, []); + const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => { + if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current)) return; + // inverted 列表下,最新消息端对应 offset=0 + isProgrammaticScrollRef.current = true; + flatListRef.current?.scrollToOffset({ + offset: 0, + animated, + }); + setTimeout(() => { + isProgrammaticScrollRef.current = false; + }, animated ? 220 : 32); + }, [flatListRef, scrollPositionRef, messages.length]); - // 消息加载完成后滚动到底部 + const isNearBottom = useCallback(() => { + const scrollY = scrollPositionRef.current.scrollY || 0; + // inverted 列表下,越接近 0 越靠近最新消息端 + return scrollY <= 100; + }, [scrollPositionRef]); + + // 首屏加载完成后仅锚定一次到最新消息端 useEffect(() => { - if (!loading && !loadingMore && messages.length > 0 && shouldAutoScrollOnEnterRef.current) { - const timer = setTimeout(() => { - if (shouldAutoScrollOnEnterRef.current) { - flatListRef.current?.scrollToEnd({ animated: false }); - } - }, 500); - return () => clearTimeout(timer); + if ( + loading || + loadingMore || + messages.length === 0 || + hasInitialAnchorDoneRef.current || + scrollPositionRef.current.viewportHeight <= 0 + ) { + return; } - }, [loading, loadingMore, messages.length]); + hasInitialAnchorDoneRef.current = true; + const timer = setTimeout(() => { + scrollToLatest(false, true, 'initial-anchor'); + }, 0); + return () => clearTimeout(timer); + }, [loading, loadingMore, messages.length, scrollToLatest]); + + // 新消息跟随策略(Telegram/QQ 风格): + // 仅当“最新端消息 seq 增长”且用户在底部附近时才跟随; + // 历史加载只会增加旧消息,不会提升 latest seq,因此不会触发回底。 + useEffect(() => { + const currentCount = messages.length; + const latestSeq = currentCount > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0; + const prevLatestSeq = prevLatestSeqRef.current; + prevMessageCountRef.current = currentCount; + prevLatestSeqRef.current = latestSeq; + + if (loading || loadingMore) return; + if (latestSeq <= prevLatestSeq) return; + if (suppressAutoFollowRef.current) return; + if (!isNearBottom()) return; + + const timer = setTimeout(() => { + scrollToLatest(false, false, 'new-message-follow'); + }, 0); + return () => clearTimeout(timer); + }, [messages, loading, loadingMore, isNearBottom, scrollToLatest]); // 获取当前用户信息 useEffect(() => { @@ -384,22 +433,18 @@ export const useChatScreen = () => { }; }, [routeUserId, conversationId, currentUserId, isGroupChat]); - // 【改造】加载更多历史消息 + // 加载更多历史消息(inverted 下保持阅读锚点) const loadMoreHistory = useCallback(async () => { if (!conversationId || !hasMoreHistory || loadingMore) { return; } - // 禁用自动滚动到底部,防止加载历史消息后滚动位置跳转 - shouldAutoScrollOnEnterRef.current = false; - // 清除所有待执行的自动滚动定时器 - autoScrollTimersRef.current.forEach(clearTimeout); - autoScrollTimersRef.current = []; + // 历史加载期间禁止“新消息自动跟随到底” + suppressAutoFollowRef.current = true; // 保存加载前的滚动位置和内容高度 const scrollYBefore = scrollPositionRef.current.scrollY; const contentHeightBefore = scrollPositionRef.current.contentHeight; - setLoadingMore(true); try { @@ -411,22 +456,8 @@ export const useChatScreen = () => { setFirstSeq(minSeq); } - // 加载完成后,恢复滚动位置 - // 使用 setTimeout 确保 FlashList 已经更新 - setTimeout(() => { - if (flatListRef.current) { - // 计算新的滚动位置,保持相对位置不变 - const newContentHeight = scrollPositionRef.current.contentHeight; - const heightDiff = newContentHeight - contentHeightBefore; - const newScrollY = scrollYBefore + heightDiff; - - // 滚动到计算后的位置 - flatListRef.current.scrollToOffset({ - offset: newScrollY, - animated: false, - }); - } - }, 100); + // inverted + maintainVisibleContentPosition 下由列表原生保持位置 + // 不做手动 scrollToOffset,避免与原生锚点冲突导致回到底部 } catch (error) { console.error('加载历史消息失败:', error); } finally { @@ -434,48 +465,44 @@ export const useChatScreen = () => { } }, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]); - // 列表内容尺寸变化后触发首次自动滚动 + // 列表内容尺寸变化:仅同步内容高度,锚点补偿由 loadMoreHistory 统一处理 const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => { - // 如果是加载更多历史消息,不要自动滚动 - if (loadingMore) { - return; - } - - if (!shouldAutoScrollOnEnterRef.current) return; - if (loading) return; - if (messages.length === 0) return; - - const prevContentHeight = scrollPositionRef.current.contentHeight; scrollPositionRef.current.contentHeight = contentHeight; + }, []); - // 如果内容高度增加(加载了更多消息),不要自动滚动到底部 - if (prevContentHeight > 0 && contentHeight > prevContentHeight) { - return; - } + const handleReachLatestEdge = useCallback(() => { + suppressAutoFollowRef.current = false; + isBrowsingHistoryRef.current = false; + }, []); - shouldAutoScrollOnEnterRef.current = false; - autoScrollTimersRef.current.forEach(clearTimeout); - autoScrollTimersRef.current = []; + const setBrowsingHistory = useCallback((browsing: boolean) => { + isBrowsingHistoryRef.current = browsing; + }, []); - [100, 300, 500, 800, 1200].forEach(delay => { - const timer = setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: false }); - }, delay); - autoScrollTimersRef.current.push(timer); - }); - }, [loading, loadingMore, messages.length]); - - // 【改造】自动标记已读 - 当有新消息且是当前会话时 + // 自动标记已读(QQ/Telegram 风格): + // 仅当出现更大的 latest seq,且用户在最新端附近时才上报。 + // 历史加载/浏览历史不会触发 read。 useEffect(() => { if (!conversationId || messages.length === 0) return; + if (loading || loadingMore) return; + if (suppressAutoFollowRef.current || isBrowsingHistoryRef.current) return; + if (!isNearBottom()) return; - const maxSeq = Math.max(...messages.map(m => m.seq), 0); - if (maxSeq > 0) { - markAsRead(maxSeq).catch(error => { - console.error('[ChatScreen] 自动标记已读失败:', error); - }); - } - }, [messages, conversationId, markAsRead]); + const latestSeq = Math.max(...messages.map(m => m.seq), 0); + if (latestSeq <= 0) return; + + // 没有新增最新消息,不重复上报 + if (latestSeq <= prevLatestSeqRef.current) return; + prevLatestSeqRef.current = latestSeq; + + // 避免对同一 seq 重复标记 + if (latestSeq <= prevMarkedReadSeqRef.current) return; + prevMarkedReadSeqRef.current = latestSeq; + + markAsRead(latestSeq).catch(error => { + console.error('[ChatScreen] 自动标记已读失败:', error); + }); + }, [messages, conversationId, markAsRead, loading, loadingMore, isNearBottom]); // 使用 ref 存储 groupMembers const groupMembersRef = useRef(groupMembers); @@ -494,16 +521,20 @@ export const useChatScreen = () => { const keyboardWillShow = (e: KeyboardEvent) => { setKeyboardHeight(e.endCoordinates.height); setActivePanel(prev => prev === 'mention' ? 'mention' : 'none'); - setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: false }); - }, 150); + if (isNearBottom()) { + setTimeout(() => { + scrollToLatest(false, false, 'keyboard-show'); + }, 150); + } }; const keyboardWillHide = () => { setKeyboardHeight(0); - setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: false }); - }, 100); + if (isNearBottom()) { + setTimeout(() => { + scrollToLatest(false, false, 'keyboard-hide'); + }, 100); + } }; const showSubscription = Keyboard.addListener( @@ -519,7 +550,7 @@ export const useChatScreen = () => { showSubscription.remove(); hideSubscription.remove(); }; - }, []); + }, [scrollToLatest, isNearBottom]); // 格式化时间 const formatTime = useCallback((dateString: string): string => { @@ -773,7 +804,7 @@ export const useChatScreen = () => { } setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: true }); + scrollToLatest(false, false, 'send-text'); }, 100); } catch (error) { console.error('发送消息失败:', error); @@ -781,7 +812,7 @@ export const useChatScreen = () => { } finally { setSending(false); } - }, [inputText, conversationId, isGroupChat, routeGroupId, selectedMentions, mentionAll, sendMessageViaManager, isMuted, muteAll, buildTextSegments, replyingTo, getSendErrorMessage, canSendFirstPrivateText]); + }, [inputText, conversationId, isGroupChat, routeGroupId, selectedMentions, mentionAll, sendMessageViaManager, isMuted, muteAll, buildTextSegments, replyingTo, getSendErrorMessage, canSendFirstPrivateText, scrollToLatest]); // 发送图片消息 const handleSendImage = useCallback(async (imageUri: string, mimeType?: string) => { @@ -823,7 +854,7 @@ export const useChatScreen = () => { } setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: true }); + scrollToLatest(false, false, 'send-image'); }, 100); } catch (error) { console.error('发送图片失败:', error); @@ -831,7 +862,7 @@ export const useChatScreen = () => { } finally { setSendingImage(false); } - }, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, getSendErrorMessage, canSendPrivateImage]); + }, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, getSendErrorMessage, canSendPrivateImage, scrollToLatest]); // 选择图片 const handlePickImage = useCallback(async () => { @@ -950,7 +981,7 @@ export const useChatScreen = () => { } setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: true }); + scrollToLatest(false, false, 'send-sticker'); }, 100); } catch (error) { console.error('发送自定义表情失败:', error); @@ -958,35 +989,35 @@ export const useChatScreen = () => { } finally { setSendingImage(false); } - }, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, canSendPrivateImage]); + }, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, canSendPrivateImage, scrollToLatest]); // 切换表情面板 const toggleEmojiPanel = useCallback(() => { Keyboard.dismiss(); setActivePanel(prev => { const newPanel = prev === 'emoji' ? 'none' : 'emoji'; - if (newPanel !== 'none') { + if (newPanel !== 'none' && isNearBottom()) { setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: false }); + scrollToLatest(false, false, 'open-emoji-panel'); }, 200); } return newPanel; }); - }, []); + }, [scrollToLatest, isNearBottom]); // 切换更多功能面板 const toggleMorePanel = useCallback(() => { Keyboard.dismiss(); setActivePanel(prev => { const newPanel = prev === 'more' ? 'none' : 'more'; - if (newPanel !== 'none') { + if (newPanel !== 'none' && isNearBottom()) { setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: false }); + scrollToLatest(false, false, 'open-more-panel'); }, 200); } return newPanel; }); - }, []); + }, [scrollToLatest, isNearBottom]); // 关闭面板 const closePanel = useCallback(() => { @@ -1245,5 +1276,7 @@ export const useChatScreen = () => { loadMoreHistory, handleClearConversation, handleMessageListContentSizeChange, + handleReachLatestEdge, + setBrowsingHistory, }; }; diff --git a/src/services/database.ts b/src/services/database.ts index 60dbd4e..8a5294e 100644 --- a/src/services/database.ts +++ b/src/services/database.ts @@ -231,10 +231,19 @@ const isRecoverableDbError = (error: unknown): boolean => { const message = String(error); return ( message.includes('NativeDatabase.prepareAsync') || - message.includes('NullPointerException') + message.includes('NullPointerException') || + message.includes('Cannot use shared object that was already released') || + message.includes('NativeStatement') ); }; +const recoverDbConnection = async (): Promise => { + // 清理当前引用,随后用当前用户上下文重建连接 + db = null; + await initDatabase(currentDbUserId || undefined); + return getDb(); +}; + const withDbRead = async (operation: (database: SQLite.SQLiteDatabase) => Promise): Promise => { let database = await getDb(); try { @@ -244,8 +253,7 @@ const withDbRead = async (operation: (database: SQLite.SQLiteDatabase) => Pro throw error; } console.error('数据库读取异常,尝试重连后重试:', error); - db = null; - database = await getDb(); + database = await recoverDbConnection(); return operation(database); } }; @@ -259,7 +267,7 @@ const enqueueWrite = async (operation: () => Promise): Promise => { throw error; } console.error('数据库写入异常,尝试重连后重试:', error); - db = null; + await recoverDbConnection(); return operation(); } }; diff --git a/src/services/sseService.ts b/src/services/sseService.ts index 45b4fe0..867c7d1 100644 --- a/src/services/sseService.ts +++ b/src/services/sseService.ts @@ -240,11 +240,6 @@ class SSEService { } catch { payload = {}; } - console.log('[SSE] 收到消息:', { - event: eventName, - lastEventId: this.lastEventId, - payload, - }); this.dispatchEvent(eventName, payload); } diff --git a/src/stores/messageManager.ts b/src/stores/messageManager.ts index a4c11f0..e0dff90 100644 --- a/src/stores/messageManager.ts +++ b/src/stores/messageManager.ts @@ -287,6 +287,29 @@ class MessageManager { return Array.from(merged.values()).sort((a, b) => a.seq - b.seq); } + /** + * 历史消息合并优化: + * - 常见场景:incoming 全部比 existing 更旧,直接前插,避免全量排序 + * - 异常/重叠场景:回退到通用 mergeMessagesById + */ + private mergeOlderMessages(existing: MessageResponse[], incoming: MessageResponse[]): MessageResponse[] { + if (incoming.length === 0) return existing; + + const incomingAsc = [...incoming].sort((a, b) => a.seq - b.seq); + if (existing.length === 0) return incomingAsc; + + const existingMinSeq = existing[0]?.seq ?? Number.MAX_SAFE_INTEGER; + const incomingMaxSeq = incomingAsc[incomingAsc.length - 1]?.seq ?? Number.MIN_SAFE_INTEGER; + + // 纯历史前插快路径:避免全量 merge + sort + if (incomingMaxSeq < existingMinSeq) { + return [...incomingAsc, ...existing]; + } + + // 兜底:存在重叠/乱序时走通用路径 + return this.mergeMessagesById(existing, incomingAsc); + } + private updateConversationList() { // 会话排序:置顶优先,再按最后消息时间排序 const list = Array.from(this.state.conversations.values()).sort((a, b) => { @@ -1513,7 +1536,8 @@ class MessageManager { if (localMessages.length >= limit) { // 本地有足够数据 - const formattedMessages: MessageResponse[] = localMessages.map(m => ({ + // 本地查询是 seq DESC,这里转成 ASC,匹配渲染时间序 + const formattedMessages: MessageResponse[] = [...localMessages].reverse().map(m => ({ id: m.id, conversation_id: m.conversationId, sender_id: m.senderId, @@ -1525,7 +1549,7 @@ class MessageManager { // 合并到现有消息 const existingMessages = this.state.messagesMap.get(conversationId) || []; - const mergedMessages = this.mergeMessagesById(existingMessages, formattedMessages); + const mergedMessages = this.mergeOlderMessages(existingMessages, formattedMessages); this.state.messagesMap.set(conversationId, mergedMessages); this.notifySubscribers({ @@ -1561,7 +1585,7 @@ class MessageManager { // 合并消息 const existingMessages = this.state.messagesMap.get(conversationId) || []; - const mergedMessages = this.mergeMessagesById(existingMessages, serverMessages); + const mergedMessages = this.mergeOlderMessages(existingMessages, serverMessages); this.state.messagesMap.set(conversationId, mergedMessages); this.notifySubscribers({ From aaf53d1c3ba44165152f813e09101f474efae0d4 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Mon, 23 Mar 2026 23:38:53 +0800 Subject: [PATCH 18/36] refactor(GroupMembersScreen): enhance member list management with remote data source - Introduced a new remote data source for managing group members using cursor pagination. - Updated member list loading logic to utilize the new data source, improving data synchronization and loading states. - Enhanced member enrichment process by integrating user profiles into the member list. - Refactored related imports and hooks for better organization and clarity. --- src/screens/message/GroupMembersScreen.tsx | 41 ++++++-- src/services/commentService.ts | 4 +- src/services/groupService.ts | 8 +- src/services/messageService.ts | 4 +- src/services/notificationService.ts | 2 +- src/stores/groupMemberListSources.ts | 110 +++++++++++++++++++++ src/stores/groupMemberProfileResolver.ts | 46 +++++++++ src/stores/index.ts | 9 ++ 8 files changed, 205 insertions(+), 19 deletions(-) create mode 100644 src/stores/groupMemberListSources.ts create mode 100644 src/stores/groupMemberProfileResolver.ts diff --git a/src/screens/message/GroupMembersScreen.tsx b/src/screens/message/GroupMembersScreen.tsx index 6f7b189..15ffa5d 100644 --- a/src/screens/message/GroupMembersScreen.tsx +++ b/src/screens/message/GroupMembersScreen.tsx @@ -25,7 +25,12 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { useAuthStore } from '../../stores'; import { groupService } from '../../services/groupService'; -import { groupManager } from '../../stores/groupManager'; +import { + createRemoteGroupMemberListSource, + GroupMemberListSourceKind, + IGroupMemberListPagedSource, +} from '../../stores/groupMemberListSources'; +import { enrichGroupMembersWithUserProfiles } from '../../stores/groupMemberProfileResolver'; import { Avatar, Text, Button, Loading, EmptyState, Divider, ResponsiveContainer } from '../../components/common'; import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive'; import { useCursorPagination } from '../../hooks/useCursorPagination'; @@ -36,6 +41,7 @@ import { import { RootStackParamList } from '../../navigation/types'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const GROUP_MEMBER_REMOTE_LIST_KIND: GroupMemberListSourceKind = 'cursor'; // 网格布局配置 const GRID_CONFIG = { @@ -70,7 +76,11 @@ const GroupMembersScreen: React.FC = () => { return GRID_CONFIG.mobile; }, [width]); - // 使用游标分页 Hook 管理成员列表 + const memberListSource = useMemo(() => { + return createRemoteGroupMemberListSource(GROUP_MEMBER_REMOTE_LIST_KIND, groupId, 50); + }, [groupId]); + + // 使用统一数据源 + 游标 Hook 管理成员列表 const { list: members, isLoading, @@ -80,11 +90,12 @@ const GroupMembersScreen: React.FC = () => { refresh, error, } = useCursorPagination( - async ({ cursor, pageSize }) => { - return await groupService.getGroupMembersCursor(groupId, { - cursor, - page_size: pageSize, - }); + async ({ cursor }) => { + // cursor 为空表示第一页/刷新,此时重置数据源状态 + if (!cursor) { + memberListSource.restart(); + } + return await memberListSource.loadNext(); }, { pageSize: 50 } ); @@ -95,8 +106,20 @@ const GroupMembersScreen: React.FC = () => { // 同步分页数据到本地状态 useEffect(() => { - setLocalMembers(members); - setLoading(false); + let active = true; + + const syncMembers = async () => { + const enrichedMembers = await enrichGroupMembersWithUserProfiles(members); + if (!active) return; + setLocalMembers(enrichedMembers); + setLoading(false); + }; + + syncMembers(); + + return () => { + active = false; + }; }, [members]); // 当前用户的成员信息 diff --git a/src/services/commentService.ts b/src/services/commentService.ts index 4049ec5..698c0b7 100644 --- a/src/services/commentService.ts +++ b/src/services/commentService.ts @@ -255,7 +255,7 @@ class CommentService { params: CursorPaginationRequest = {} ): Promise> { try { - const response = await api.get(`/comments/post/${postId}/cursor`, { params }); + const response = await api.get(`/comments/post/${postId}/cursor`, params); const raw = response.data; return { list: Array.isArray(raw?.list) ? raw.list : [], @@ -285,7 +285,7 @@ class CommentService { params: CursorPaginationRequest = {} ): Promise> { try { - const response = await api.get(`/comments/${commentId}/replies/cursor`, { params }); + const response = await api.get(`/comments/${commentId}/replies/cursor`, params); const raw = response.data; return { list: Array.isArray(raw?.list) ? raw.list : [], diff --git a/src/services/groupService.ts b/src/services/groupService.ts index f6ca233..5efb847 100644 --- a/src/services/groupService.ts +++ b/src/services/groupService.ts @@ -334,9 +334,7 @@ class GroupService { params: CursorPaginationRequest = {} ): Promise> { try { - const response = await api.get>('/groups/cursor', { - params, - }); + const response = await api.get>('/groups/cursor', params); return response.data; } catch (error) { console.error('获取群组列表失败:', error); @@ -362,7 +360,7 @@ class GroupService { try { const response = await api.get>( `/groups/${encodeURIComponent(String(groupId))}/members/cursor`, - { params } + params ); return response.data; } catch (error) { @@ -389,7 +387,7 @@ class GroupService { try { const response = await api.get>( `/groups/${encodeURIComponent(String(groupId))}/announcements/cursor`, - { params } + params ); return response.data; } catch (error) { diff --git a/src/services/messageService.ts b/src/services/messageService.ts index 1bcab13..270ca1f 100644 --- a/src/services/messageService.ts +++ b/src/services/messageService.ts @@ -571,7 +571,7 @@ class MessageService { try { const response = await api.get>( '/conversations/cursor', - { params } + params ); const raw = response.data; return { @@ -604,7 +604,7 @@ class MessageService { try { const response = await api.get( `/conversations/${encodeURIComponent(conversationId)}/messages/cursor`, - { params } + params ); const raw = response.data; return { diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index b5de12e..99a912d 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -166,7 +166,7 @@ class NotificationService { try { const response = await api.get>( '/notifications/cursor', - { params } + params ); return response.data; } catch (error) { diff --git a/src/stores/groupMemberListSources.ts b/src/stores/groupMemberListSources.ts new file mode 100644 index 0000000..a9de187 --- /dev/null +++ b/src/stores/groupMemberListSources.ts @@ -0,0 +1,110 @@ +import { groupService } from '../services/groupService'; +import { CursorPaginationResponse, GroupMemberResponse } from '../types/dto'; + +export const GROUP_MEMBER_LIST_PAGE_SIZE = 50; + +export type GroupMemberListSourceKind = 'cursor' | 'offset'; + +export interface IGroupMemberListPagedSource { + restart(): void; + loadNext(): Promise>; + readonly hasMore: boolean; +} + +export class NetworkCursorGroupMemberListPagedSource implements IGroupMemberListPagedSource { + private nextCursor: string | null = null; + private hasMoreAfterLastLoad = false; + private loadedOnce = false; + private readonly groupId: number | string; + private readonly pageSize: number; + + constructor(groupId: number | string, pageSize: number = GROUP_MEMBER_LIST_PAGE_SIZE) { + this.groupId = groupId; + this.pageSize = pageSize; + } + + restart(): void { + this.nextCursor = null; + this.hasMoreAfterLastLoad = false; + this.loadedOnce = false; + } + + get hasMore(): boolean { + return this.loadedOnce && this.hasMoreAfterLastLoad; + } + + async loadNext(): Promise> { + const response = await groupService.getGroupMembersCursor( + this.groupId, + this.nextCursor == null + ? { page_size: this.pageSize } + : { cursor: this.nextCursor, page_size: this.pageSize } + ); + + this.nextCursor = response.next_cursor ?? null; + this.hasMoreAfterLastLoad = Boolean(response.has_more && response.next_cursor != null); + this.loadedOnce = true; + + return { + list: response.list || [], + next_cursor: response.next_cursor ?? null, + prev_cursor: response.prev_cursor ?? null, + has_more: response.has_more ?? false, + }; + } +} + +export class NetworkOffsetGroupMemberListPagedSource implements IGroupMemberListPagedSource { + private nextPage = 1; + private hasMoreAfterLastLoad = false; + private loadedOnce = false; + private readonly groupId: number | string; + private readonly pageSize: number; + + constructor(groupId: number | string, pageSize: number = GROUP_MEMBER_LIST_PAGE_SIZE) { + this.groupId = groupId; + this.pageSize = pageSize; + } + + restart(): void { + this.nextPage = 1; + this.hasMoreAfterLastLoad = false; + this.loadedOnce = false; + } + + get hasMore(): boolean { + return this.loadedOnce && this.hasMoreAfterLastLoad; + } + + async loadNext(): Promise> { + const response = await groupService.getMembers(this.groupId, this.nextPage, this.pageSize); + const list = response.list || []; + const currentPage = response.page ?? this.nextPage; + const totalPages = response.total_pages ?? currentPage; + const hasMore = currentPage < totalPages; + const nextPageCursor = hasMore ? String(currentPage + 1) : null; + const prevPageCursor = currentPage > 1 ? String(currentPage - 1) : null; + + this.nextPage = currentPage + 1; + this.hasMoreAfterLastLoad = hasMore; + this.loadedOnce = true; + + return { + list, + next_cursor: nextPageCursor, + prev_cursor: prevPageCursor, + has_more: hasMore, + }; + } +} + +export function createRemoteGroupMemberListSource( + kind: GroupMemberListSourceKind, + groupId: number | string, + pageSize: number = GROUP_MEMBER_LIST_PAGE_SIZE +): IGroupMemberListPagedSource { + return kind === 'offset' + ? new NetworkOffsetGroupMemberListPagedSource(groupId, pageSize) + : new NetworkCursorGroupMemberListPagedSource(groupId, pageSize); +} + diff --git a/src/stores/groupMemberProfileResolver.ts b/src/stores/groupMemberProfileResolver.ts new file mode 100644 index 0000000..0b14c63 --- /dev/null +++ b/src/stores/groupMemberProfileResolver.ts @@ -0,0 +1,46 @@ +import { GroupMemberResponse, UserDTO } from '../types/dto'; +import { getUserCache } from '../services/database'; +import { userManager } from './userManager'; + +/** + * 为群成员列表补全用户资料(昵称、头像、用户名)。 + * cursor 接口仅返回 user_id 时,通过本地缓存和用户接口回填。 + */ +export async function enrichGroupMembersWithUserProfiles( + members: GroupMemberResponse[] +): Promise { + if (!Array.isArray(members) || members.length === 0) { + return []; + } + + const userIds = [...new Set(members.map((member) => String(member.user_id)))]; + const userMap = new Map(); + + await Promise.all( + userIds.map(async (userId) => { + try { + const cachedUser = await getUserCache(userId); + if (cachedUser) { + userMap.set(userId, cachedUser); + return; + } + + const fetchedUser = await userManager.getUserById(userId); + if (fetchedUser) { + userMap.set(userId, fetchedUser); + } + } catch (error) { + console.error('补全群成员用户资料失败:', error); + } + }) + ); + + return members.map((member) => { + if (member.user?.nickname || member.user?.username) { + return member; + } + const user = userMap.get(String(member.user_id)); + return user ? { ...member, user } : member; + }); +} + diff --git a/src/stores/index.ts b/src/stores/index.ts index 927185a..fe00c21 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -30,6 +30,15 @@ export { SqliteConversationListPagedSource, createRemoteConversationListSource, } from './conversationListSources'; +export { + GROUP_MEMBER_LIST_PAGE_SIZE, + type GroupMemberListSourceKind, + type IGroupMemberListPagedSource, + NetworkCursorGroupMemberListPagedSource, + NetworkOffsetGroupMemberListPagedSource, + createRemoteGroupMemberListSource, +} from './groupMemberListSources'; +export { enrichGroupMembersWithUserProfiles } from './groupMemberProfileResolver'; export { postManager } from './postManager'; export { groupManager } from './groupManager'; export { userManager } from './userManager'; From 5c9cb6acea16b2478bcbfb81c2709af5e999656f Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Tue, 24 Mar 2026 01:19:09 +0800 Subject: [PATCH 19/36] refactor(ProcessPostUseCase, useCursorPagination, useDifferentialPosts): enhance post merging and loading states - Introduced new methods for merging posts and comments efficiently, improving performance during data updates. - Updated loading state management in hooks to include initial loading and loading more states for better user feedback. - Refactored HomeScreen and PostDetailScreen to utilize the new loading states and merging logic, enhancing user experience during data fetching. - Improved pagination handling in useCursorPagination and useDifferentialPosts to ensure consistent data management across components. --- src/core/usecases/ProcessPostUseCase.ts | 112 ++++++++++++- src/hooks/useCursorPagination.ts | 96 ++++++++++- src/hooks/useDifferentialPosts.ts | 6 + src/infrastructure/pagination/types.ts | 4 + .../hooks/responsive/useBreakpoint.ts | 30 +++- src/screens/home/HomeScreen.tsx | 41 +++-- src/screens/home/PostDetailScreen.tsx | 155 ++++++++++++++---- src/screens/message/ChatScreen.tsx | 60 ++++++- .../components/ChatScreen/useChatScreen.ts | 51 +++--- src/stores/messageManager.ts | 8 +- src/stores/messageManagerHooks.ts | 13 +- 11 files changed, 475 insertions(+), 101 deletions(-) diff --git a/src/core/usecases/ProcessPostUseCase.ts b/src/core/usecases/ProcessPostUseCase.ts index c87bffa..fb709f7 100644 --- a/src/core/usecases/ProcessPostUseCase.ts +++ b/src/core/usecases/ProcessPostUseCase.ts @@ -109,6 +109,8 @@ class ProcessPostUseCase { // 当前用户ID private currentUserId: string | null = null; + private readonly mergePerfWarnThresholdMs = 12; + // 私有构造函数 private constructor() { this.paginationManager = new PaginationStateManager(); @@ -241,6 +243,83 @@ class ProcessPostUseCase { this.notifyStateChanged(key); } + private getEntityId(item: unknown): string | null { + if (!item || typeof item !== 'object') { + return null; + } + const maybeId = (item as { id?: unknown }).id; + if (typeof maybeId === 'string' || typeof maybeId === 'number') { + return String(maybeId); + } + return null; + } + + private mergePostListWithFastPath(previousInput: Post[] | null | undefined, incomingInput: Post[] | null | undefined): Post[] { + const previous = Array.isArray(previousInput) ? previousInput : []; + const incoming = Array.isArray(incomingInput) ? incomingInput : []; + if (incoming.length === 0) { + return previous; + } + if (previous.length === 0) { + return incoming; + } + + const lastPrevId = this.getEntityId(previous[previous.length - 1]); + const firstIncomingId = this.getEntityId(incoming[0]); + if (lastPrevId && firstIncomingId && lastPrevId !== firstIncomingId) { + const prevIdSet = new Set(previous.map((item) => this.getEntityId(item)).filter(Boolean) as string[]); + if (!prevIdSet.has(firstIncomingId)) { + return [...previous, ...incoming]; + } + } + + const merged = [...previous]; + const indexById = new Map(); + for (let i = 0; i < merged.length; i += 1) { + const id = this.getEntityId(merged[i]); + if (id) { + indexById.set(id, i); + } + } + + for (const post of incoming) { + const id = this.getEntityId(post); + if (!id) { + merged.push(post); + continue; + } + const existingIndex = indexById.get(id); + if (existingIndex === undefined) { + indexById.set(id, merged.length); + merged.push(post); + } else { + merged[existingIndex] = post; + } + } + + return merged; + } + + private mergeRefreshWindow(previousInput: Post[] | null | undefined, latestInput: Post[] | null | undefined): Post[] { + const previous = Array.isArray(previousInput) ? previousInput : []; + const latest = Array.isArray(latestInput) ? latestInput : []; + if (latest.length === 0) { + return previous; + } + if (previous.length === 0) { + return latest; + } + + const latestIdSet = new Set( + latest.map((item) => this.getEntityId(item)).filter(Boolean) as string[] + ); + const remaining = previous.filter((item) => { + const id = this.getEntityId(item); + return !id || !latestIdSet.has(id); + }); + return [...latest, ...remaining]; + } + /** * 更新帖子详情状态 * @param postId 帖子ID @@ -280,8 +359,17 @@ class ProcessPostUseCase { const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null; // 更新状态(保存请求参数以便加载更多时使用) + const mergeStart = Date.now(); + const currentState = this.getPostsState(key); + const incomingPosts = Array.isArray(result.posts) ? result.posts : []; + const mergedPosts = this.mergeRefreshWindow(currentState.posts, incomingPosts); + const mergeCost = Date.now() - mergeStart; + if (mergeCost >= this.mergePerfWarnThresholdMs) { + console.log('[PostPerf] fetchPosts merge cost', { key, costMs: mergeCost, prev: currentState.posts.length, next: incomingPosts.length }); + } + this.updatePostsState(key, { - posts: result.posts, + posts: mergedPosts, hasMore: result.hasMore, cursor: isCursorMode ? result.nextCursor : null, // cursor 模式保存 cursor,否则设为 null 表示 page 模式 currentPage: isCursorMode ? undefined : 1, // page 模式从第 1 页开始 @@ -293,7 +381,7 @@ class ProcessPostUseCase { // 通知订阅者 this.notifySubscribers({ type: 'posts_loaded', - payload: { key, posts: result.posts, hasMore: result.hasMore }, + payload: { key, posts: incomingPosts, hasMore: result.hasMore }, timestamp: Date.now(), }); @@ -338,8 +426,16 @@ class ProcessPostUseCase { const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null; // 更新状态 + const mergeStart = Date.now(); + const incomingPosts = Array.isArray(result.posts) ? result.posts : []; + const mergedPosts = this.mergeRefreshWindow(state.posts, incomingPosts); + const mergeCost = Date.now() - mergeStart; + if (mergeCost >= this.mergePerfWarnThresholdMs) { + console.log('[PostPerf] refreshPosts merge cost', { key, costMs: mergeCost, prev: state.posts.length, next: incomingPosts.length }); + } + this.updatePostsState(key, { - posts: result.posts, + posts: mergedPosts, hasMore: result.hasMore, cursor: isCursorMode ? result.nextCursor : null, currentPage: isCursorMode ? undefined : 1, @@ -417,7 +513,13 @@ class ProcessPostUseCase { const result = await postRepository.getPosts(queryParams); // 合并新数据 - const newPosts = [...state.posts, ...result.posts]; + const mergeStart = Date.now(); + const incomingPosts = Array.isArray(result.posts) ? result.posts : []; + const newPosts = this.mergePostListWithFastPath(state.posts, incomingPosts); + const mergeCost = Date.now() - mergeStart; + if (mergeCost >= this.mergePerfWarnThresholdMs) { + console.log('[PostPerf] loadMore merge cost', { key, costMs: mergeCost, prev: state.posts.length, incoming: incomingPosts.length, merged: newPosts.length }); + } // 判断响应是否为 cursor 模式(有 next_cursor 返回) const responseIsCursorMode = result.nextCursor !== undefined && result.nextCursor !== null; @@ -433,7 +535,7 @@ class ProcessPostUseCase { }); return { - posts: result.posts, + posts: incomingPosts, hasMore: result.hasMore, nextCursor: result.nextCursor, }; diff --git a/src/hooks/useCursorPagination.ts b/src/hooks/useCursorPagination.ts index 1fa6d3c..b48f1b6 100644 --- a/src/hooks/useCursorPagination.ts +++ b/src/hooks/useCursorPagination.ts @@ -11,6 +11,58 @@ import { CursorPaginationResponse } from '../types/dto'; const DEFAULT_PAGE_SIZE = 20; const MAX_PAGE_SIZE = 100; +function getItemId(item: unknown): string | null { + if (!item || typeof item !== 'object') return null; + const maybeId = (item as { id?: unknown }).id; + if (typeof maybeId === 'string' || typeof maybeId === 'number') { + return String(maybeId); + } + return null; +} + +function mergeByIdFastPath(previousInput: T[] | null | undefined, incomingInput: T[] | null | undefined): T[] { + const previous = Array.isArray(previousInput) ? previousInput : []; + const incoming = Array.isArray(incomingInput) ? incomingInput : []; + if (incoming.length === 0) return previous; + if (previous.length === 0) return incoming; + + const prevLastId = getItemId(previous[previous.length - 1]); + const incomingFirstId = getItemId(incoming[0]); + if (prevLastId && incomingFirstId && prevLastId !== incomingFirstId) { + const prevSet = new Set(previous.map((item) => getItemId(item)).filter(Boolean) as string[]); + if (!prevSet.has(incomingFirstId)) { + return [...previous, ...incoming]; + } + } + + const merged = [...previous]; + const indexById = new Map(); + for (let i = 0; i < merged.length; i += 1) { + const id = getItemId(merged[i]); + if (id) indexById.set(id, i); + } + + for (const item of incoming) { + const id = getItemId(item); + if (!id) { + merged.push(item); + continue; + } + const existingIndex = indexById.get(id); + if (existingIndex === undefined) { + indexById.set(id, merged.length); + merged.push(item); + } else { + merged[existingIndex] = item; + } + } + return merged; +} + +function normalizeList(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : []; +} + /** * 游标分页 Hook * @@ -34,6 +86,8 @@ export function useCursorPagination( prevCursor: null, hasMore: true, // 初始设为 true,以便首次加载可以执行 isLoading: false, + isInitialLoading: false, + isLoadingMore: false, isRefreshing: false, error: null, isFirstLoad: true, @@ -66,7 +120,13 @@ export function useCursorPagination( isLoadingRef.current = true; cancelledRef.current = false; - setState(prev => ({ ...prev, isLoading: true, error: null })); + setState(prev => ({ + ...prev, + isLoading: true, + isInitialLoading: prev.list.length === 0, + isLoadingMore: prev.list.length > 0, + error: null, + })); try { const response: CursorPaginationResponse = await fetchFunction({ @@ -84,13 +144,16 @@ export function useCursorPagination( // 标记首次加载完成 isFirstLoadRef.current = false; + const incomingList = normalizeList(response.list); setState(prev => ({ ...prev, - list: [...prev.list, ...response.list], + list: mergeByIdFastPath(prev.list, incomingList), nextCursor: response.next_cursor, prevCursor: response.prev_cursor, hasMore: response.has_more && response.next_cursor !== null, isLoading: false, + isInitialLoading: false, + isLoadingMore: false, isFirstLoad: false, })); @@ -104,6 +167,8 @@ export function useCursorPagination( setState(prev => ({ ...prev, isLoading: false, + isInitialLoading: false, + isLoadingMore: false, error: error instanceof Error ? error.message : '加载失败', })); @@ -119,7 +184,7 @@ export function useCursorPagination( cancelledRef.current = false; - setState(prev => ({ ...prev, isLoading: true, error: null })); + setState(prev => ({ ...prev, isLoading: true, isLoadingMore: true, error: null })); try { const response: CursorPaginationResponse = await fetchFunction({ @@ -131,14 +196,16 @@ export function useCursorPagination( if (cancelledRef.current) return; + const incomingList = normalizeList(response.list); setState(prev => ({ ...prev, // 向前加载时,新数据放在前面 - list: [...response.list, ...prev.list], + list: mergeByIdFastPath(incomingList, prev.list), nextCursor: response.next_cursor, prevCursor: response.prev_cursor, hasMore: response.has_more, isLoading: false, + isLoadingMore: false, isFirstLoad: false, })); } catch (error) { @@ -147,6 +214,7 @@ export function useCursorPagination( setState(prev => ({ ...prev, isLoading: false, + isLoadingMore: false, error: error instanceof Error ? error.message : '加载失败', })); } @@ -168,12 +236,15 @@ export function useCursorPagination( if (cancelledRef.current) return; + const refreshedList = normalizeList(response.list); setState({ - list: response.list, + list: mergeByIdFastPath([], refreshedList), nextCursor: response.next_cursor, prevCursor: response.prev_cursor, hasMore: response.has_more && response.next_cursor !== null, isLoading: false, + isInitialLoading: false, + isLoadingMore: false, isRefreshing: false, error: null, isFirstLoad: false, @@ -201,6 +272,8 @@ export function useCursorPagination( prevCursor: null, hasMore: true, // 重置后也设为 true,以便可以重新加载 isLoading: false, + isInitialLoading: false, + isLoadingMore: false, isRefreshing: false, error: null, isFirstLoad: true, @@ -217,10 +290,12 @@ export function useCursorPagination( ) => { setState(prev => ({ ...prev, - list, + list: mergeByIdFastPath([], normalizeList(list)), nextCursor, prevCursor, hasMore, + isInitialLoading: false, + isLoadingMore: false, isFirstLoad: false, })); }, @@ -242,7 +317,7 @@ export function useCursorPagination( isLoadingRef.current = true; cancelledRef.current = false; - setState(prev => ({ ...prev, isLoading: true, error: null })); + setState(prev => ({ ...prev, isLoading: true, isInitialLoading: prev.list.length === 0, error: null })); try { const response: CursorPaginationResponse = await fetchFunction({ @@ -259,13 +334,16 @@ export function useCursorPagination( isFirstLoadRef.current = false; + const initialList = normalizeList(response.list); setState(prev => ({ ...prev, - list: response.list, + list: mergeByIdFastPath([], initialList), nextCursor: response.next_cursor, prevCursor: response.prev_cursor, hasMore: response.has_more && response.next_cursor !== null, isLoading: false, + isInitialLoading: false, + isLoadingMore: false, isFirstLoad: false, })); @@ -279,6 +357,8 @@ export function useCursorPagination( setState(prev => ({ ...prev, isLoading: false, + isInitialLoading: false, + isLoadingMore: false, error: error instanceof Error ? error.message : '加载失败', })); diff --git a/src/hooks/useDifferentialPosts.ts b/src/hooks/useDifferentialPosts.ts index d10150b..9a37618 100644 --- a/src/hooks/useDifferentialPosts.ts +++ b/src/hooks/useDifferentialPosts.ts @@ -70,6 +70,10 @@ export interface UseDifferentialPostsResult { posts: T[]; /** 是否正在加载 */ loading: boolean; + /** 是否首屏加载 */ + isInitialLoading: boolean; + /** 是否正在加载更多 */ + isLoadingMore: boolean; /** 是否正在刷新 */ refreshing: boolean; /** 错误信息 */ @@ -514,6 +518,8 @@ export function useDifferentialPosts( return { posts, loading, + isInitialLoading: loading && posts.length === 0 && !refreshing, + isLoadingMore: loading && posts.length > 0 && !refreshing, refreshing, error, hasMore, diff --git a/src/infrastructure/pagination/types.ts b/src/infrastructure/pagination/types.ts index f2e7798..6e54336 100644 --- a/src/infrastructure/pagination/types.ts +++ b/src/infrastructure/pagination/types.ts @@ -214,6 +214,10 @@ export interface CursorPaginationState { hasMore: boolean; /** 是否正在加载 */ isLoading: boolean; + /** 是否首屏加载(空列表初始化) */ + isInitialLoading: boolean; + /** 是否正在加载更多 */ + isLoadingMore: boolean; /** 是否正在刷新 */ isRefreshing: boolean; /** 错误信息 */ diff --git a/src/presentation/hooks/responsive/useBreakpoint.ts b/src/presentation/hooks/responsive/useBreakpoint.ts index 8a2bdb9..6f35b46 100644 --- a/src/presentation/hooks/responsive/useBreakpoint.ts +++ b/src/presentation/hooks/responsive/useBreakpoint.ts @@ -3,8 +3,8 @@ * 断点检测 - 检测当前断点 */ -import { useMemo } from 'react'; -import { useWindowDimensions } from './useScreenSize'; +import { useMemo, useState, useEffect } from 'react'; +import { Dimensions, ScaledSize } from 'react-native'; import { BREAKPOINTS, FINE_BREAKPOINTS } from './types'; import type { BreakpointKey, FineBreakpointKey } from './types'; @@ -74,6 +74,22 @@ export function isBreakpointBetween( // ==================== Hooks ==================== +function useWindowDimensionsLocal(): ScaledSize { + const [dimensions, setDimensions] = useState(() => Dimensions.get('window')); + + useEffect(() => { + const subscription = Dimensions.addEventListener('change', ({ window }) => { + setDimensions(window); + }); + + return () => { + subscription.remove(); + }; + }, []); + + return dimensions; +} + /** * 断点检测 Hook * 返回当前的基础断点 @@ -85,7 +101,7 @@ export function isBreakpointBetween( * // 'mobile' | 'tablet' | 'desktop' | 'wide' */ export function useBreakpoint(): BreakpointKey { - const { width } = useWindowDimensions(); + const { width } = useWindowDimensionsLocal(); return useMemo(() => { return getBreakpoint(width); @@ -103,7 +119,7 @@ export function useBreakpoint(): BreakpointKey { * // 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' */ export function useFineBreakpoint(): FineBreakpointKey { - const { width } = useWindowDimensions(); + const { width } = useWindowDimensionsLocal(); return useMemo(() => { return getFineBreakpoint(width); @@ -120,7 +136,7 @@ export function useFineBreakpoint(): FineBreakpointKey { * const isMediumUp = useBreakpointGTE('md'); */ export function useBreakpointGTE(target: FineBreakpointKey): boolean { - const { width } = useWindowDimensions(); + const { width } = useWindowDimensionsLocal(); return useMemo(() => { const current = getFineBreakpoint(width); @@ -138,7 +154,7 @@ export function useBreakpointGTE(target: FineBreakpointKey): boolean { * const isMobileOnly = useBreakpointLT('lg'); */ export function useBreakpointLT(target: FineBreakpointKey): boolean { - const { width } = useWindowDimensions(); + const { width } = useWindowDimensionsLocal(); return useMemo(() => { const current = getFineBreakpoint(width); @@ -160,7 +176,7 @@ export function useBreakpointBetween( min: FineBreakpointKey, max: FineBreakpointKey ): boolean { - const { width } = useWindowDimensions(); + const { width } = useWindowDimensionsLocal(); return useMemo(() => { const current = getFineBreakpoint(width); diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index bf672c0..bbf9134 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -119,6 +119,8 @@ export const HomeScreen: React.FC = () => { const { posts, loading: isLoading, + isInitialLoading, + isLoadingMore, refreshing: isRefreshing, hasMore, error, @@ -135,17 +137,23 @@ export const HomeScreen: React.FC = () => { // 加载更多方法 const loadMore = useCallback(async () => { - if (hasMore && !isLoading && !isLoadingMoreRef.current) { + if (hasMore && !isLoadingMore && !isLoadingMoreRef.current) { isLoadingMoreRef.current = true; + const startedAt = Date.now(); try { await processPostUseCase.loadMorePosts(listKey); + console.log('[PostPerf] home loadMore', { + listKey, + costMs: Date.now() - startedAt, + totalItems: posts.length, + }); } catch (err) { console.error('加载更多失败:', err); } finally { isLoadingMoreRef.current = false; } } - }, [listKey, hasMore, isLoading]); + }, [posts.length, listKey, hasMore, isLoadingMore]); // 网格模式滚动处理 - 检测是否滚动到底部 const handleGridScroll = useCallback((event: any) => { @@ -366,7 +374,9 @@ export const HomeScreen: React.FC = () => { }; // 渲染帖子卡片(列表模式) - const renderPostList = ({ item }: { item: Post }) => { + const keyExtractor = useCallback((item: Post) => item.id, []); + + const renderPostList = useCallback(({ item }: { item: Post }) => { const authorId = item.author?.id || ''; const isPostAuthor = currentUser?.id === authorId; return ( @@ -394,7 +404,7 @@ export const HomeScreen: React.FC = () => { /> ); - }; + }, [currentUser?.id, handleBookmark, handleDeletePost, handleImagePress, handleLike, handlePostPress, handleShare, handleUserPress, isMobile, listItemWidth, responsiveGap]); // 估算帖子在瀑布流中的高度(用于均匀分配) const estimatePostHeight = (post: Post, columnWidth: number): number => { @@ -558,8 +568,8 @@ export const HomeScreen: React.FC = () => { }; // 渲染空状态 - const renderEmpty = () => { - if (isLoading) return null; + const renderEmpty = useCallback(() => { + if (isInitialLoading) return null; return ( { description={activeIndex === 1 ? '关注一些用户来获取内容吧' : '暂无帖子,快去发布第一条内容吧'} /> ); - }; + }, [activeIndex, isInitialLoading]); // 渲染列表内容 const renderListContent = () => { @@ -581,7 +591,7 @@ export const HomeScreen: React.FC = () => { item.id} + keyExtractor={keyExtractor} contentContainerStyle={[ styles.listContent, { @@ -602,7 +612,12 @@ export const HomeScreen: React.FC = () => { onEndReached={loadMore} onEndReachedThreshold={0.3} ListEmptyComponent={renderEmpty} - ListFooterComponent={isLoading ? : null} + ListFooterComponent={isLoadingMore ? : null} + initialNumToRender={8} + maxToRenderPerBatch={8} + updateCellsBatchingPeriod={60} + windowSize={7} + removeClippedSubviews /> ); }; @@ -661,7 +676,9 @@ export const HomeScreen: React.FC = () => { // 移动端:使用 GestureDetector 支持手势切换 Tab - {viewMode === 'list' ? ( + {isInitialLoading ? ( + + ) : viewMode === 'list' ? ( renderListContent() ) : ( // 网格模式:使用响应式网格 @@ -672,7 +689,9 @@ export const HomeScreen: React.FC = () => { ) : ( // 桌面端/平板端:不使用 GestureDetector,避免拦截侧边栏点击 - {viewMode === 'list' ? ( + {isInitialLoading ? ( + + ) : viewMode === 'list' ? ( renderListContent() ) : ( // 网格模式:使用响应式网格 diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index 2e8a639..c0ca5f5 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -67,13 +67,15 @@ export const PostDetailScreen: React.FC = () => { const [post, setPost] = useState(null); const [comments, setComments] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); + const [isPostInitialLoading, setIsPostInitialLoading] = useState(true); + const [isPostRefreshing, setIsPostRefreshing] = useState(false); // 使用游标分页 Hook 管理评论列表 const { list: paginatedComments, isLoading: isCommentsLoading, + isInitialLoading: isCommentsInitialLoading, + isLoadingMore: isCommentsLoadingMore, isRefreshing: isCommentsRefreshing, hasMore: hasMoreComments, loadMore: loadMoreComments, @@ -88,11 +90,71 @@ export const PostDetailScreen: React.FC = () => { }, { pageSize: 20 } ); + + const getCommentId = useCallback((comment: Comment) => String(comment.id), []); + + const mergeCommentsById = useCallback((previousInput: Comment[] | null | undefined, incomingInput: Comment[] | null | undefined): Comment[] => { + const previous = Array.isArray(previousInput) ? previousInput : []; + const incoming = Array.isArray(incomingInput) ? incomingInput : []; + if (incoming.length === 0) return previous; + if (previous.length === 0) return incoming; + + const previousSet = new Set(previous.map(getCommentId)); + const incomingFirstId = getCommentId(incoming[0]); + if (!previousSet.has(incomingFirstId)) { + return [...previous, ...incoming]; + } + + const merged = [...previous]; + const indexById = new Map(); + for (let i = 0; i < merged.length; i += 1) { + indexById.set(getCommentId(merged[i]), i); + } + + for (const comment of incoming) { + const id = getCommentId(comment); + const existingIndex = indexById.get(id); + if (existingIndex === undefined) { + indexById.set(id, merged.length); + merged.push(comment); + } else { + merged[existingIndex] = comment; + } + } + return merged; + }, [getCommentId]); + + const mergeCommentsRefreshWindow = useCallback((previousInput: Comment[] | null | undefined, latestInput: Comment[] | null | undefined): Comment[] => { + const previous = Array.isArray(previousInput) ? previousInput : []; + const latest = Array.isArray(latestInput) ? latestInput : []; + if (latest.length === 0) return previous; + if (previous.length === 0) return latest; + + const latestSet = new Set(latest.map(getCommentId)); + const tail = previous.filter((item) => !latestSet.has(getCommentId(item))); + return [...latest, ...tail]; + }, [getCommentId]); - // 同步分页评论到本地状态 + // 切换帖子时,先清空上一帖评论,避免跨帖残留。 useEffect(() => { - setComments(paginatedComments); - }, [paginatedComments]); + setComments([]); + }, [postId]); + + // 同步分页评论到本地状态(增量合并,避免全量替换) + useEffect(() => { + setComments((prev) => { + if (isCommentsRefreshing) { + return mergeCommentsRefreshWindow(prev, paginatedComments); + } + return mergeCommentsById(prev, paginatedComments); + }); + }, [isCommentsRefreshing, mergeCommentsById, mergeCommentsRefreshWindow, paginatedComments]); + + useEffect(() => { + if (commentsError) { + console.warn('[PostPerf] comments request error', { postId, error: commentsError }); + } + }, [commentsError, postId]); const [commentText, setCommentText] = useState(''); const [showImageModal, setShowImageModal] = useState(false); const [selectedImageIndex, setSelectedImageIndex] = useState(0); @@ -131,7 +193,7 @@ export const PostDetailScreen: React.FC = () => { // 加载帖子详情(可选择是否记录浏览量) const loadPostDetail = useCallback(async (recordView: boolean = false) => { if (!postId) { - setLoading(false); + setIsPostInitialLoading(false); return; } @@ -188,7 +250,7 @@ export const PostDetailScreen: React.FC = () => { } catch (error) { console.error('加载帖子详情失败:', error); } finally { - setLoading(false); + setIsPostInitialLoading(false); } }, [postId]); @@ -206,13 +268,13 @@ export const PostDetailScreen: React.FC = () => { // 如果是从评论按钮跳转过来的,加载完成后滚动到评论区 useEffect(() => { - if (shouldScrollToComments && !loading && (comments?.length ?? 0) > 0) { + if (shouldScrollToComments && !isPostInitialLoading && (comments?.length ?? 0) > 0) { // 延迟确保列表已完成渲染和布局 setTimeout(() => { flatListRef.current?.scrollToIndex({ index: 0, animated: true, viewPosition: 0 }); }, 500); } - }, [shouldScrollToComments, loading, comments?.length]); + }, [shouldScrollToComments, isPostInitialLoading, comments?.length]); // 动态设置导航头部 useEffect(() => { @@ -280,11 +342,26 @@ export const PostDetailScreen: React.FC = () => { // 下拉刷新 - 同时刷新帖子和评论 const onRefresh = useCallback(async () => { - setRefreshing(true); + const startedAt = Date.now(); + setIsPostRefreshing(true); await loadPostDetail(); - await refreshComments(); - setRefreshing(false); - }, [loadPostDetail, refreshComments]); + console.log('[PostPerf] detail refresh', { + postId, + costMs: Date.now() - startedAt, + comments: comments.length, + }); + setIsPostRefreshing(false); + }, [comments.length, loadPostDetail, postId, refreshComments]); + + const handleLoadMoreComments = useCallback(async () => { + const startedAt = Date.now(); + await loadMoreComments(); + console.log('[PostPerf] detail loadMoreComments', { + postId, + costMs: Date.now() - startedAt, + totalItems: comments.length, + }); + }, [comments.length, loadMoreComments, postId]); const formatDateTime = (dateString?: string | null): string => { if (!dateString) return ''; @@ -1259,7 +1336,9 @@ export const PostDetailScreen: React.FC = () => { }; // 渲染评论 - 带楼层号 - const renderComment = ({ item, index }: { item: Comment; index: number }) => { + const commentKeyExtractor = useCallback((item: Comment) => item.id, []); + + const renderComment = useCallback(({ item, index }: { item: Comment; index: number }) => { const authorId = item.author?.id || ''; // 收集当前评论的所有回复,用于根据 target_id 查找被回复用户 const allReplies = item.replies || []; @@ -1283,10 +1362,10 @@ export const PostDetailScreen: React.FC = () => { currentUserId={currentUser?.id} /> ); - }; + }, [currentUser?.id, handleDeleteComment, handleImagePress, handleLikeComment, handleLoadMoreReplies, post?.user_id]); // 渲染空评论 - 现代化设计 - const renderEmptyComments = () => ( + const renderEmptyComments = useCallback(() => ( { 成为第一个评论的人吧~ - ); + ), [isDesktop]); // 渲染评论输入框 const renderCommentInput = () => ( @@ -1398,7 +1477,7 @@ export const PostDetailScreen: React.FC = () => { ref={flatListRef} data={comments} renderItem={renderComment} - keyExtractor={item => item.id} + keyExtractor={commentKeyExtractor} ListEmptyComponent={renderEmptyComments} contentContainerStyle={{ paddingBottom: responsiveGap }} showsVerticalScrollIndicator={false} @@ -1409,23 +1488,32 @@ export const PostDetailScreen: React.FC = () => { }} refreshControl={ } - onEndReached={loadMoreComments} + onEndReached={handleLoadMoreComments} onEndReachedThreshold={0.3} + initialNumToRender={10} + maxToRenderPerBatch={10} + updateCellsBatchingPeriod={60} + windowSize={7} + removeClippedSubviews ListFooterComponent={ - isCommentsLoading ? ( + isCommentsInitialLoading ? ( + + + + ) : isCommentsLoadingMore ? ( ) : hasMoreComments ? ( 加载更多评论 @@ -1456,7 +1544,7 @@ export const PostDetailScreen: React.FC = () => { ); - if (loading) { + if (isPostInitialLoading) { return ; } @@ -1489,7 +1577,7 @@ export const PostDetailScreen: React.FC = () => { showsVerticalScrollIndicator={false} refreshControl={ { ref={flatListRef} data={comments} renderItem={renderComment} - keyExtractor={item => item.id} + keyExtractor={commentKeyExtractor} ListHeaderComponent={renderPostHeader} ListEmptyComponent={renderEmptyComments} contentContainerStyle={{ paddingBottom: responsiveGap }} @@ -1542,23 +1630,32 @@ export const PostDetailScreen: React.FC = () => { }} refreshControl={ } - onEndReached={loadMoreComments} + onEndReached={handleLoadMoreComments} onEndReachedThreshold={0.3} + initialNumToRender={10} + maxToRenderPerBatch={10} + updateCellsBatchingPeriod={60} + windowSize={7} + removeClippedSubviews ListFooterComponent={ - isCommentsLoading ? ( + isCommentsInitialLoading ? ( + + + + ) : isCommentsLoadingMore ? ( ) : hasMoreComments ? ( 加载更多评论 diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index 5211e47..b6223be 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -77,6 +77,7 @@ export const ChatScreen: React.FC = () => { // 输入框区域高度(用于定位浮动 mention 面板) const [inputWrapperHeight, setInputWrapperHeight] = useState(60); + const [showEdgeLoadingIndicator, setShowEdgeLoadingIndicator] = useState(false); const isPreloadingRef = useRef(false); const lastScrollYRef = useRef(0); const isUserDraggingRef = useRef(false); @@ -203,8 +204,15 @@ export const ChatScreen: React.FC = () => { loadMoreHistory, handleMessageListContentSizeChange, handleReachLatestEdge, + setBrowsingHistory, } = useChatScreen(); + useEffect(() => { + if (!loadingMore && showEdgeLoadingIndicator) { + setShowEdgeLoadingIndicator(false); + } + }, [loadingMore, showEdgeLoadingIndicator]); + useEffect(() => { return () => { if (preloadCooldownTimerRef.current) { @@ -231,7 +239,7 @@ export const ChatScreen: React.FC = () => { return map; }, [messages]); - const renderMessage = ({ item, index }: { item: any; index: number }) => { + const renderMessage = useCallback(({ item, index }: { item: any; index: number }) => { // inverted 下 renderItem 的 index 与时间顺序不一致,需回查原始序索引 const logicalIndex = messageIndexMap.get(String(item.id)) ?? index; return ( @@ -255,7 +263,26 @@ export const ChatScreen: React.FC = () => { onReply={handleReplyMessage} /> ); - }; + }, [ + messageIndexMap, + currentUserId, + currentUser, + otherUser, + isGroupChat, + groupMembers, + otherUserLastReadSeq, + selectedMessageId, + messageMap, + handleLongPressMessage, + handleAvatarPress, + handleAvatarLongPress, + formatTime, + shouldShowTime, + handleImagePress, + handleReplyMessage, + ]); + + const keyExtractor = useCallback((item: any) => String(item.id), []); // 获取正在输入提示 const typingHint = getTypingHint(); @@ -269,6 +296,11 @@ export const ChatScreen: React.FC = () => { viewportHeight: layoutMeasurement.height, }; + // 离开最新消息端后进入“浏览历史”模式,屏蔽自动跟随到底 + if (contentOffset.y > 120) { + setBrowsingHistory(true); + } + // 仅用户手势主动回到底部时才解除历史阅读锁(避免加载阶段误解锁) const isScrollingTowardLatest = contentOffset.y < lastScrollYRef.current; if ( @@ -282,13 +314,19 @@ export const ChatScreen: React.FC = () => { // inverted 下历史端在“列表尾部”,对应 offset 增大且距离尾部变小 const distanceToHistoryEdge = contentSize.height - (contentOffset.y + layoutMeasurement.height); - const isScrollingTowardHistory = contentOffset.y > lastScrollYRef.current; lastScrollYRef.current = contentOffset.y; - const PRELOAD_HISTORY_THRESHOLD = 120; + // 预加载阈值:按屏幕高度动态提前(至少 420),确保边界前开始加载 + const PRELOAD_HISTORY_THRESHOLD = Math.max(420, layoutMeasurement.height * 1.2); + // 仅在真正接近边界时才显示旋转提示 + const EDGE_LOADING_INDICATOR_THRESHOLD = 60; + const shouldShowEdgeIndicator = loadingMore && distanceToHistoryEdge <= EDGE_LOADING_INDICATOR_THRESHOLD; + if (shouldShowEdgeIndicator !== showEdgeLoadingIndicator) { + setShowEdgeLoadingIndicator(shouldShowEdgeIndicator); + } + if ( distanceToHistoryEdge <= PRELOAD_HISTORY_THRESHOLD && - isScrollingTowardHistory && hasMoreHistory && !loadingMore && !loading && @@ -304,7 +342,7 @@ export const ChatScreen: React.FC = () => { }, 350); }); } - }, [scrollPositionRef, hasMoreHistory, loadingMore, loading, loadMoreHistory, handleReachLatestEdge]); + }, [scrollPositionRef, hasMoreHistory, loadingMore, loading, loadMoreHistory, handleReachLatestEdge, showEdgeLoadingIndicator, setBrowsingHistory]); const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => { handleMessageListContentSizeChange(contentWidth, contentHeight); @@ -338,6 +376,7 @@ export const ChatScreen: React.FC = () => { onLayout={e => { scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height; }} + onTouchEnd={handleDismiss} > {loading ? ( @@ -349,10 +388,15 @@ export const ChatScreen: React.FC = () => { inverted data={displayMessages} renderItem={renderMessage} - keyExtractor={(item: any) => String(item.id)} + keyExtractor={keyExtractor} contentContainerStyle={listContentStyle as any} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" + initialNumToRender={14} + maxToRenderPerBatch={10} + updateCellsBatchingPeriod={50} + windowSize={9} + removeClippedSubviews={Platform.OS !== 'web'} onScroll={handleMessageListScroll} onScrollBeginDrag={() => { isUserDraggingRef.current = true; @@ -368,7 +412,7 @@ export const ChatScreen: React.FC = () => { onContentSizeChange={handleContentSizeChange} /> )} - {loadingMore && ( + {showEdgeLoadingIndicator && ( { const prevMessageCountRef = useRef(0); const prevLatestSeqRef = useRef(0); const prevMarkedReadSeqRef = useRef(0); + const enterMarkedKeyRef = useRef(''); const isProgrammaticScrollRef = useRef(false); const suppressAutoFollowRef = useRef(false); const isBrowsingHistoryRef = useRef(false); @@ -277,6 +278,7 @@ export const useChatScreen = () => { prevMessageCountRef.current = 0; prevLatestSeqRef.current = 0; prevMarkedReadSeqRef.current = 0; + enterMarkedKeyRef.current = ''; suppressAutoFollowRef.current = false; isBrowsingHistoryRef.current = false; }, [conversationId]); @@ -479,6 +481,29 @@ export const useChatScreen = () => { isBrowsingHistoryRef.current = browsing; }, []); + // 进入聊天详情后立即清未读(不依赖滚动位置) + useEffect(() => { + if (!conversationId) return; + if (!conversation) return; + + const unreadCount = Number((conversation as any).unread_count || 0); + if (unreadCount <= 0) return; + + const latestFromConversation = Number((conversation as any).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; + + const markKey = `${conversationId}:${targetSeq}`; + if (enterMarkedKeyRef.current === markKey) return; + enterMarkedKeyRef.current = markKey; + + prevMarkedReadSeqRef.current = Math.max(prevMarkedReadSeqRef.current, targetSeq); + markAsRead(targetSeq).catch(error => { + console.error('[ChatScreen] 进入会话清未读失败:', error); + }); + }, [conversationId, conversation, messages, markAsRead]); + // 自动标记已读(QQ/Telegram 风格): // 仅当出现更大的 latest seq,且用户在最新端附近时才上报。 // 历史加载/浏览历史不会触发 read。 @@ -521,20 +546,10 @@ export const useChatScreen = () => { const keyboardWillShow = (e: KeyboardEvent) => { setKeyboardHeight(e.endCoordinates.height); setActivePanel(prev => prev === 'mention' ? 'mention' : 'none'); - if (isNearBottom()) { - setTimeout(() => { - scrollToLatest(false, false, 'keyboard-show'); - }, 150); - } }; const keyboardWillHide = () => { setKeyboardHeight(0); - if (isNearBottom()) { - setTimeout(() => { - scrollToLatest(false, false, 'keyboard-hide'); - }, 100); - } }; const showSubscription = Keyboard.addListener( @@ -550,7 +565,7 @@ export const useChatScreen = () => { showSubscription.remove(); hideSubscription.remove(); }; - }, [scrollToLatest, isNearBottom]); + }, []); // 格式化时间 const formatTime = useCallback((dateString: string): string => { @@ -996,28 +1011,18 @@ export const useChatScreen = () => { Keyboard.dismiss(); setActivePanel(prev => { const newPanel = prev === 'emoji' ? 'none' : 'emoji'; - if (newPanel !== 'none' && isNearBottom()) { - setTimeout(() => { - scrollToLatest(false, false, 'open-emoji-panel'); - }, 200); - } return newPanel; }); - }, [scrollToLatest, isNearBottom]); + }, []); // 切换更多功能面板 const toggleMorePanel = useCallback(() => { Keyboard.dismiss(); setActivePanel(prev => { const newPanel = prev === 'more' ? 'none' : 'more'; - if (newPanel !== 'none' && isNearBottom()) { - setTimeout(() => { - scrollToLatest(false, false, 'open-more-panel'); - }, 200); - } return newPanel; }); - }, [scrollToLatest, isNearBottom]); + }, []); // 关闭面板 const closePanel = useCallback(() => { diff --git a/src/stores/messageManager.ts b/src/stores/messageManager.ts index e0dff90..e95c4d9 100644 --- a/src/stores/messageManager.ts +++ b/src/stores/messageManager.ts @@ -1557,8 +1557,8 @@ class MessageManager { payload: { conversationId, messages: mergedMessages, source: 'local_history' }, timestamp: Date.now(), }); - // 异步填充 sender 信息 - this.enrichMessagesWithSenderInfo(conversationId, mergedMessages); + // 性能优化:历史分页仅对新批次做 sender 补全,避免每次扫描全量消息 + this.enrichMessagesWithSenderInfo(conversationId, formattedMessages); return formattedMessages; } @@ -1593,8 +1593,8 @@ class MessageManager { payload: { conversationId, messages: mergedMessages, source: 'server_history' }, timestamp: Date.now(), }); - // 异步填充 sender 信息 - this.enrichMessagesWithSenderInfo(conversationId, mergedMessages); + // 性能优化:仅对本次服务端批次做 sender 补全 + this.enrichMessagesWithSenderInfo(conversationId, serverMessages); return serverMessages; } diff --git a/src/stores/messageManagerHooks.ts b/src/stores/messageManagerHooks.ts index 3962210..3f65986 100644 --- a/src/stores/messageManagerHooks.ts +++ b/src/stores/messageManagerHooks.ts @@ -103,6 +103,7 @@ export function useMessages(conversationId: string | null): UseMessagesReturn { const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); const [hasMore, setHasMore] = useState(true); + const loadMoreInFlightRef = useRef(false); useEffect(() => { if (!conversationId) { @@ -156,23 +157,23 @@ export function useMessages(conversationId: string | null): UseMessagesReturn { }, [conversationId]); const loadMore = useCallback(async () => { - if (!conversationId || isLoading || !hasMore) return; + if (!conversationId || !hasMore || loadMoreInFlightRef.current) return; const currentMessages = messageManager.getMessages(conversationId); if (currentMessages.length === 0) return; - // 获取最早的消息seq - const minSeq = Math.min(...currentMessages.map(m => m.seq)); + // 消息数组在 MessageManager 内保持 seq 升序,首项即最早消息 + const minSeq = currentMessages[0]?.seq ?? Math.min(...currentMessages.map(m => m.seq)); - setIsLoading(true); + loadMoreInFlightRef.current = true; const loadedMessages = await messageManager.loadMoreMessages(conversationId, minSeq, 20); - setIsLoading(false); + loadMoreInFlightRef.current = false; // 如果没有加载到新消息,说明没有更多了 if (loadedMessages.length === 0) { setHasMore(false); } - }, [conversationId, isLoading, hasMore]); + }, [conversationId, hasMore]); const refresh = useCallback(async () => { if (!conversationId) return; From 82e99d24d87adac370768e07719ed58aa88f9d8b Mon Sep 17 00:00:00 2001 From: lan Date: Tue, 24 Mar 2026 03:02:54 +0800 Subject: [PATCH 20/36] Remove unnecessary console logs and replace them with void expressions for better performance and cleaner code. Update error logging to use console.error for consistency. This change enhances code readability and reduces console noise during execution. --- package-lock.json | 4 +- .../{PostCard.tsx => PostCard.legacy.tsx} | 0 src/components/business/PostCard/PostCard.tsx | 74 +++++ .../business/PostCard/PostCardGrid.tsx | 115 +++++++ .../PostCard/components/PostImages.tsx | 53 ++++ .../business/PostCard/components/index.ts | 12 + .../business/PostCard/hooks/index.ts | 8 + src/components/business/PostCard/index.ts | 58 ++++ src/components/business/PostCard/types.ts | 285 ++++++++++++++++++ src/components/business/index.ts | 14 + src/screens/home/HomeScreen.tsx | 47 ++- src/screens/profile/ProfileScreen.tsx | 40 ++- src/screens/profile/UserScreen.tsx | 50 ++- 13 files changed, 728 insertions(+), 32 deletions(-) rename src/components/business/{PostCard.tsx => PostCard.legacy.tsx} (100%) create mode 100644 src/components/business/PostCard/PostCard.tsx create mode 100644 src/components/business/PostCard/PostCardGrid.tsx create mode 100644 src/components/business/PostCard/components/PostImages.tsx create mode 100644 src/components/business/PostCard/components/index.ts create mode 100644 src/components/business/PostCard/hooks/index.ts create mode 100644 src/components/business/PostCard/index.ts create mode 100644 src/components/business/PostCard/types.ts diff --git a/package-lock.json b/package-lock.json index 586d224..507e99e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "carrot_bbs", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "carrot_bbs", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "@expo/vector-icons": "^15.1.1", "@react-native-async-storage/async-storage": "^2.2.0", diff --git a/src/components/business/PostCard.tsx b/src/components/business/PostCard.legacy.tsx similarity index 100% rename from src/components/business/PostCard.tsx rename to src/components/business/PostCard.legacy.tsx diff --git a/src/components/business/PostCard/PostCard.tsx b/src/components/business/PostCard/PostCard.tsx new file mode 100644 index 0000000..6cc61f9 --- /dev/null +++ b/src/components/business/PostCard/PostCard.tsx @@ -0,0 +1,74 @@ +/** + * PostCard 主组件 + * 帖子卡片组件 - 支持列表模式和网格模式 + * + * @example + * // 新 API 使用方式 + * handleAction(post, action)} + * variant="list" + * features="full" + * /> + */ + +import React from 'react'; +import { PostCardProps, LegacyPostCardProps } from './types'; +import PostCardList from './PostCardList'; +import PostCardGrid from './PostCardGrid'; +import { usePostCardFeatures } from './hooks/usePostCardFeatures'; +import { createLegacyAdapter, isLegacyProps } from './legacyAdapter'; + +/** + * PostCard 主组件 + * 支持新旧两种 API + */ +const PostCard: React.FC = (props) => { + // 检测是否使用旧 API + const isLegacy = isLegacyProps(props as LegacyPostCardProps); + + // 兼容层:转换旧 Props 到新 Props + const normalizedProps: PostCardProps = isLegacy + ? createLegacyAdapter(props as LegacyPostCardProps) + : (props as PostCardProps); + + const { + post, + onAction, + variant = 'list', + features: featuresConfig, + isPostAuthor = false, + isAuthor = false, + index, + style, + } = normalizedProps; + + // 解析 Features 配置 + const resolvedFeatures = usePostCardFeatures(featuresConfig); + + // 根据 variant 选择渲染模式 + if (variant === 'grid') { + return ( + + ); + } + + return ( + + ); +}; + +export default PostCard; \ No newline at end of file diff --git a/src/components/business/PostCard/PostCardGrid.tsx b/src/components/business/PostCard/PostCardGrid.tsx new file mode 100644 index 0000000..93306d2 --- /dev/null +++ b/src/components/business/PostCard/PostCardGrid.tsx @@ -0,0 +1,115 @@ +/** + * PostCardGrid 容器组件 + * 网格模式(小红书风格)的帖子卡片容器 + */ + +import React from 'react'; +import { TouchableOpacity, StyleSheet, StyleProp, ViewStyle, GestureResponderEvent } from 'react-native'; +import { colors, borderRadius } from '../../../theme'; +import { useResponsive } from '../../../hooks/useResponsive'; +import { PostCardGridProps } from './types'; +import { usePostCardActions } from './hooks/usePostCardActions'; +import { usePostGridStyles } from './hooks/usePostCardStyles'; +import { PostGridCover, PostGridInfo } from './components'; +import { convertToImageGridItems, getConsistentAspectRatio } from './utils'; + +const PostCardGrid: React.FC = ({ + post, + onAction, + features, + style, +}) => { + const { isDesktop } = useResponsive(); + const gridStyles = usePostGridStyles(); + + const { + handlePress, + handleUserPress, + handleImagePress, + } = usePostCardActions(onAction); + + // 防御性检查:确保 author 存在 + const author = post.author ?? { + id: '', + nickname: '匿名用户', + avatar: '', + username: '', + cover_url: '', + bio: '', + website: '', + location: '', + posts_count: 0, + followers_count: 0, + following_count: 0, + created_at: '', + }; + + // 获取封面图(第一张图) + const coverImage = post.images && post.images.length > 0 ? post.images[0] : null; + + // 根据帖子 ID 生成一致的宽高比 + const aspectRatio = getConsistentAspectRatio(post.id); + + // 样式计算 + const containerStyle: StyleProp = [ + styles.container, + !coverImage && styles.containerNoImage, + isDesktop && styles.containerDesktop, + ]; + + const handleContainerPress = () => { + handlePress(); + }; + + const handleImagePressLocal = () => { + if (post.images && post.images.length > 0) { + handleImagePress(convertToImageGridItems(post.images), 0); + } + }; + + const handleUserPressLocal = () => { + handleUserPress(); + }; + + return ( + + {/* 封面 */} + + + {/* 标题和信息 */} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#FFF', + overflow: 'hidden', + borderRadius: 8, + }, + containerDesktop: { + borderRadius: 12, + }, + containerNoImage: { + minHeight: 200, + justifyContent: 'space-between', + }, +}); + +export default PostCardGrid; \ No newline at end of file diff --git a/src/components/business/PostCard/components/PostImages.tsx b/src/components/business/PostCard/components/PostImages.tsx new file mode 100644 index 0000000..8a4ee81 --- /dev/null +++ b/src/components/business/PostCard/components/PostImages.tsx @@ -0,0 +1,53 @@ +/** + * PostImages 子组件 + * 显示帖子图片网格 + */ + +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { colors, spacing, borderRadius } from '../../../../theme'; +import { useResponsive } from '../../../../hooks/useResponsive'; +import { ImageGrid, ImageGridItem } from '../../../common'; +import { PostImagesProps } from '../types'; + +const PostImages: React.FC = ({ images, displayMode, onImagePress }) => { + const { isDesktop, isWideScreen } = useResponsive(); + + if (!images || images.length === 0) return null; + + // 计算图片间距和圆角 + const imageGap = isDesktop ? 12 : 8; + const imageBorderRadius = isDesktop ? borderRadius.xl : borderRadius.md; + + return ( + + ({ + id: img.id, + url: img.url, + thumbnail_url: img.thumbnail_url, + preview_url: img.preview_url, + preview_url_large: img.preview_url_large, + width: img.width, + height: img.height, + }))} + maxDisplayCount={isWideScreen ? 12 : 9} + mode="auto" + gap={imageGap} + borderRadius={imageBorderRadius} + showMoreOverlay={true} + onImagePress={onImagePress} + displayMode={displayMode} + usePreview={true} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + marginBottom: spacing.md, + }, +}); + +export default PostImages; \ No newline at end of file diff --git a/src/components/business/PostCard/components/index.ts b/src/components/business/PostCard/components/index.ts new file mode 100644 index 0000000..98f9fc3 --- /dev/null +++ b/src/components/business/PostCard/components/index.ts @@ -0,0 +1,12 @@ +/** + * PostCard 子组件导出 + */ + +export { default as PostHeader } from './PostHeader'; +export { default as PostTitle } from './PostTitle'; +export { default as PostContent } from './PostContent'; +export { default as PostImages } from './PostImages'; +export { default as PostActions } from './PostActions'; +export { default as PostTopComment } from './PostTopComment'; +export { default as PostGridCover } from './PostGridCover'; +export { default as PostGridInfo } from './PostGridInfo'; \ No newline at end of file diff --git a/src/components/business/PostCard/hooks/index.ts b/src/components/business/PostCard/hooks/index.ts new file mode 100644 index 0000000..0025b4e --- /dev/null +++ b/src/components/business/PostCard/hooks/index.ts @@ -0,0 +1,8 @@ +/** + * PostCard Hooks 导出 + */ + +export { usePostCardActions } from './usePostCardActions'; +export { usePostCardFeatures } from './usePostCardFeatures'; +export { usePostCardStyles, usePostGridStyles } from './usePostCardStyles'; +export type { PostCardStylesConfig, PostGridStylesConfig } from './usePostCardStyles'; diff --git a/src/components/business/PostCard/index.ts b/src/components/business/PostCard/index.ts new file mode 100644 index 0000000..b85d14e --- /dev/null +++ b/src/components/business/PostCard/index.ts @@ -0,0 +1,58 @@ +/** + * PostCard 组件模块导出 + */ + +// 主组件(默认导出) +export { default } from './PostCard'; + +// 类型导出 +export type { + PostCardActionType, + PostCardActionPayload, + PostCardAction, + PostCardOnAction, + PostCardFeatures, + PostCardFeaturesPreset, + PostCardFeaturesConfig, + PostCardProps, + PostHeaderProps, + PostTitleProps, + PostContentProps, + PostImagesProps, + PostActionsProps, + PostVotePreviewProps, + PostTopCommentProps, + PostGridCoverProps, + PostGridInfoProps, + PostCardListProps, + PostCardGridProps, + LegacyPostCardProps, + Badge, +} from './types'; + +// 预设配置导出 +export { PostCardPresets, defaultFeatures, resolveFeatures } from './presets'; + +// Hooks 导出 +export { + usePostCardActions, + usePostCardFeatures, + usePostCardStyles, + usePostGridStyles, +} from './hooks'; +export type { PostCardStylesConfig, PostGridStylesConfig } from './hooks'; + +// 子组件导出 +export { + PostHeader, + PostTitle, + PostContent, + PostImages, + PostActions, + PostTopComment, + PostGridCover, + PostGridInfo, +} from './components'; + +// 兼容层导出(用于迁移帮助) +export { isLegacyProps, createLegacyAdapter } from './legacyAdapter'; \ No newline at end of file diff --git a/src/components/business/PostCard/types.ts b/src/components/business/PostCard/types.ts new file mode 100644 index 0000000..510edb0 --- /dev/null +++ b/src/components/business/PostCard/types.ts @@ -0,0 +1,285 @@ +/** + * PostCard 组件类型定义 + * 统一的帖子卡片组件类型系统 + */ + +import { StyleProp, ViewStyle } from 'react-native'; +import { Post, PostImageDTO, UserDTO, CommentDTO } from '../../../types'; +import { ImageGridItem } from '../../common'; + +// ==================== Action 类型定义 ==================== + +/** + * PostCard 支持的操作类型 + */ +export type PostCardActionType = + | 'press' // 点击帖子主体 + | 'userPress' // 点击用户头像/名称 + | 'like' // 点赞 + | 'unlike' // 取消点赞 + | 'comment' // 评论 + | 'bookmark' // 收藏 + | 'unbookmark' // 取消收藏 + | 'share' // 分享 + | 'imagePress' // 点击图片 + | 'delete'; // 删除 + +/** + * Action payload 类型 + */ +export interface PostCardActionPayload { + images?: ImageGridItem[]; + imageIndex?: number; + [key: string]: unknown; +} + +/** + * 统一的 Action 对象 + */ +export interface PostCardAction { + type: PostCardActionType; + payload?: PostCardActionPayload; +} + +/** + * 统一的回调函数类型 + */ +export type PostCardOnAction = (action: PostCardAction) => void; + +// ==================== Features 配置类型 ==================== + +/** + * 显示特性配置 + */ +export interface PostCardFeatures { + /** 显示用户信息 */ + showAuthor?: boolean; + /** 显示标题 */ + showTitle?: boolean; + /** 显示内容 */ + showContent?: boolean; + /** 显示图片 */ + showImages?: boolean; + /** 显示投票预览 */ + showVote?: boolean; + /** 显示热门评论 */ + showTopComment?: boolean; + /** 显示操作栏 */ + showActions?: boolean; + /** 显示浏览数 */ + showViews?: boolean; + /** 显示分享按钮 */ + showShare?: boolean; + /** 显示收藏按钮 */ + showBookmark?: boolean; + /** 显示删除按钮(需配合权限判断) */ + showDelete?: boolean; + /** 显示置顶标签 */ + showPinned?: boolean; + /** 显示徽章(楼主/管理员) */ + showBadges?: boolean; +} + +/** + * Features 预设名称 + */ +export type PostCardFeaturesPreset = 'full' | 'compact' | 'grid'; + +/** + * Features 配置(可以是预设名称或自定义对象) + */ +export type PostCardFeaturesConfig = PostCardFeaturesPreset | PostCardFeatures; + +// ==================== 主组件 Props ==================== + +/** + * PostCard 主组件 Props + */ +export interface PostCardProps { + /** 帖子数据 */ + post: Post; + + /** 统一事件回调 */ + onAction: PostCardOnAction; + + /** 显示模式:列表模式 | 网格模式 */ + variant?: 'list' | 'grid'; + + /** 预设配置名称,或自定义配置对象 */ + features?: PostCardFeaturesConfig; + + /** 当前用户是否为帖子作者(用于显示删除按钮) */ + isPostAuthor?: boolean; + + /** 当前用户是否为楼主(在帖子详情页显示"楼主"徽章) */ + isAuthor?: boolean; + + /** 索引(用于虚拟化列表优化) */ + index?: number; + + /** 自定义样式 */ + style?: StyleProp; +} + +// ==================== 子组件 Props ==================== + +/** + * PostHeader 子组件 Props + */ +export interface PostHeaderProps { + author: UserDTO; + createdAt: string; + editedAt?: string; + isPinned?: boolean; + badges?: Array<{ type: 'author' | 'admin'; label: string }>; + onDelete?: () => void; + onUserPress: () => void; + showDelete?: boolean; + showPinned?: boolean; + showBadges?: boolean; + isDeleting?: boolean; +} + +/** + * PostTitle 子组件 Props + */ +export interface PostTitleProps { + title?: string; + numberOfLines?: number; +} + +/** + * PostContent 子组件 Props + */ +export interface PostContentProps { + content?: string; + compact?: boolean; + maxLines?: number; + isExpanded?: boolean; + onToggleExpand?: () => void; +} + +/** + * PostImages 子组件 Props + */ +export interface PostImagesProps { + images: PostImageDTO[]; + displayMode: 'list' | 'grid'; + onImagePress: (images: ImageGridItem[], index: number) => void; +} + +/** + * PostActions 子组件 Props + */ +export interface PostActionsProps { + viewsCount: number; + likesCount: number; + commentsCount: number; + isLiked: boolean; + isFavorited: boolean; + onLike: () => void; + onComment: () => void; + onShare: () => void; + onBookmark: () => void; + showViews?: boolean; + showShare?: boolean; + showBookmark?: boolean; +} + +/** + * PostVotePreview 子组件 Props + */ +export interface PostVotePreviewProps { + isVote: boolean; + totalVotes?: number; + optionsCount?: number; + onPress: () => void; +} + +/** + * PostTopComment 子组件 Props + */ +export interface PostTopCommentProps { + comment: CommentDTO | null; + totalComments: number; + onPress: () => void; +} + +/** + * PostGridCover 子组件 Props + */ +export interface PostGridCoverProps { + image: PostImageDTO | null; + content?: string; + isVote?: boolean; + aspectRatio: number; + onImagePress: () => void; +} + +/** + * PostGridInfo 子组件 Props + */ +export interface PostGridInfoProps { + title?: string; + author: UserDTO; + likesCount: number; + onUserPress: () => void; +} + +// ==================== 容器组件 Props ==================== + +/** + * PostCardList 容器组件 Props + */ +export interface PostCardListProps { + post: Post; + onAction: PostCardOnAction; + features: PostCardFeatures; + isPostAuthor?: boolean; + isAuthor?: boolean; + index?: number; + style?: StyleProp; +} + +/** + * PostCardGrid 容器组件 Props + */ +export interface PostCardGridProps { + post: Post; + onAction: PostCardOnAction; + features: PostCardFeatures; + style?: StyleProp; +} + +// ==================== 旧版 Props(兼容层使用) ==================== + +/** + * 旧版 PostCard Props(用于兼容层) + * @deprecated 请迁移到新的 PostCardProps + */ +export interface LegacyPostCardProps { + post: Post; + onPress: () => void; + onUserPress: () => void; + onLike: () => void; + onComment: () => void; + onBookmark: () => void; + onShare: () => void; + onImagePress?: (images: ImageGridItem[], index: number) => void; + onDelete?: () => void; + compact?: boolean; + index?: number; + isAuthor?: boolean; + isPostAuthor?: boolean; + variant?: 'default' | 'grid'; +} + +// ==================== 工具类型 ==================== + +/** + * 徽章类型 + */ +export interface Badge { + type: 'author' | 'admin'; + label: string; +} diff --git a/src/components/business/index.ts b/src/components/business/index.ts index 7b319e5..5547a0d 100644 --- a/src/components/business/index.ts +++ b/src/components/business/index.ts @@ -2,7 +2,21 @@ * 业务组件导出 */ +// PostCard 新架构(从 PostCard 目录导出) export { default as PostCard } from './PostCard'; +// 导出 PostCard 相关类型和工具 +export type { + PostCardActionType, + PostCardAction, + PostCardOnAction, + PostCardFeatures, + PostCardFeaturesPreset, + PostCardFeaturesConfig, + PostCardProps, + LegacyPostCardProps, +} from './PostCard'; +export { PostCardPresets, resolveFeatures } from './PostCard'; +export { usePostCardActions, usePostCardFeatures } from './PostCard'; export { default as CommentItem } from './CommentItem'; export { default as UserProfileHeader } from './UserProfileHeader'; export { default as SystemMessageItem } from './SystemMessageItem'; diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index bbf9134..288e50e 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -29,6 +29,7 @@ import { useUserStore } from '../../stores'; import { useCurrentUser } from '../../stores/authStore'; import { postService } from '../../services'; import { PostCard, TabBar, SearchBar } from '../../components/business'; +import type { PostCardAction } from '../../components/business/PostCard'; import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common'; import { HomeStackParamList, RootStackParamList } from '../../navigation/types'; import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive'; @@ -368,6 +369,41 @@ export const HomeScreen: React.FC = () => { setShowImageViewer(true); }; + // 统一处理 PostCard 的所有操作 + const handlePostAction = (post: Post, action: PostCardAction) => { + switch (action.type) { + case 'press': + handlePostPress(post.id); + break; + case 'userPress': + const authorId = post.author?.id; + if (authorId) handleUserPress(authorId); + break; + case 'like': + case 'unlike': + handleLike(post); + break; + case 'comment': + handlePostPress(post.id, true); + break; + case 'bookmark': + case 'unbookmark': + handleBookmark(post); + break; + case 'share': + handleShare(post); + break; + case 'imagePress': + if (action.payload?.images && action.payload?.imageIndex !== undefined) { + handleImagePress(action.payload.images, action.payload.imageIndex); + } + break; + case 'delete': + handleDeletePost(post.id); + break; + } + }; + // 跳转到发帖页面(使用 Modal 方式) const handleCreatePost = () => { setShowCreatePost(true); @@ -392,19 +428,12 @@ export const HomeScreen: React.FC = () => { ]}> handlePostPress(item.id)} - onUserPress={() => authorId && handleUserPress(authorId)} - onLike={() => handleLike(item)} - onComment={() => handlePostPress(item.id, true)} - onBookmark={() => handleBookmark(item)} - onShare={() => handleShare(item)} - onImagePress={(images, index) => handleImagePress(images, index)} - onDelete={() => handleDeletePost(item.id)} + onAction={(action) => handlePostAction(item, action)} isPostAuthor={isPostAuthor} /> ); - }, [currentUser?.id, handleBookmark, handleDeletePost, handleImagePress, handleLike, handlePostPress, handleShare, handleUserPress, isMobile, listItemWidth, responsiveGap]); + }, [currentUser?.id, handlePostAction, isMobile, listItemWidth, responsiveGap]); // 估算帖子在瀑布流中的高度(用于均匀分配) const estimatePostHeight = (post: Post, columnWidth: number): number => { diff --git a/src/screens/profile/ProfileScreen.tsx b/src/screens/profile/ProfileScreen.tsx index 28bdc3a..b100df0 100644 --- a/src/screens/profile/ProfileScreen.tsx +++ b/src/screens/profile/ProfileScreen.tsx @@ -22,6 +22,7 @@ import { Post } from '../../types'; import { useAuthStore, useUserStore } from '../../stores'; import { postService } from '../../services'; import { UserProfileHeader, PostCard, TabBar } from '../../components/business'; +import { PostCardAction } from '../../components/business/PostCard'; import { Loading, EmptyState, Text } from '../../components/common'; import { ResponsiveContainer } from '../../components/common'; import { useResponsive } from '../../hooks'; @@ -245,6 +246,37 @@ export const ProfileScreen: React.FC = () => { } }, []); + // 统一的 PostCard Action 处理 + const handlePostAction = useCallback((post: Post, action: PostCardAction) => { + switch (action.type) { + case 'press': + handlePostPress(post.id); + break; + case 'userPress': + if (post.author) { + handleUserPress(post.author.id); + } + break; + case 'like': + case 'unlike': + post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id); + break; + case 'comment': + handlePostPress(post.id, true); + break; + case 'bookmark': + case 'unbookmark': + post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id); + break; + case 'share': + // 暂不处理分享 + break; + case 'delete': + handleDeletePost(post.id); + break; + } + }, [handlePostPress, handleUserPress, handleDeletePost]); + // 渲染内容 const renderContent = useCallback(() => { if (loading) return ; @@ -273,13 +305,7 @@ export const ProfileScreen: React.FC = () => { ]}> handlePostPress(post.id)} - onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}} - onLike={() => post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id)} - onComment={() => handlePostPress(post.id, true)} - onBookmark={() => post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id)} - onShare={() => {}} - onDelete={() => handleDeletePost(post.id)} + onAction={(action) => handlePostAction(post, action)} isPostAuthor={isPostAuthor} /> diff --git a/src/screens/profile/UserScreen.tsx b/src/screens/profile/UserScreen.tsx index 5383caf..ffd5d17 100644 --- a/src/screens/profile/UserScreen.tsx +++ b/src/screens/profile/UserScreen.tsx @@ -23,6 +23,7 @@ import { useCurrentUser } from '../../stores/authStore'; import { authService, postService, messageService } from '../../services'; import { userManager } from '../../stores/userManager'; import { UserProfileHeader, PostCard, TabBar } from '../../components/business'; +import { PostCardAction } from '../../components/business/PostCard'; import { Loading, EmptyState, ResponsiveContainer } from '../../components/common'; import { useResponsive } from '../../hooks'; import { HomeStackParamList, RootStackParamList } from '../../navigation/types'; @@ -218,6 +219,39 @@ export const UserScreen: React.FC = () => { } }; + // 统一处理 PostCard 的所有操作 + const handlePostAction = (post: Post, action: PostCardAction) => { + switch (action.type) { + case 'press': + handlePostPress(post.id); + break; + case 'userPress': + if (post.author) handleUserPress(post.author.id); + break; + case 'like': + postService.likePost(post.id); + break; + case 'unlike': + postService.unlikePost(post.id); + break; + case 'comment': + handlePostPress(post.id, true); + break; + case 'bookmark': + postService.favoritePost(post.id); + break; + case 'unbookmark': + postService.unfavoritePost(post.id); + break; + case 'share': + // 暂不处理 + break; + case 'delete': + handleDeletePost(post.id); + break; + } + }; + // 跳转到聊天界面 const handleMessage = async () => { if (!user) return; @@ -317,13 +351,7 @@ export const UserScreen: React.FC = () => { ]}> handlePostPress(post.id)} - onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}} - onLike={() => post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id)} - onComment={() => handlePostPress(post.id, true)} - onBookmark={() => post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id)} - onShare={() => {}} - onDelete={() => handleDeletePost(post.id)} + onAction={(action) => handlePostAction(post, action)} isPostAuthor={isPostAuthor} /> @@ -357,13 +385,7 @@ export const UserScreen: React.FC = () => { ]}> handlePostPress(post.id)} - onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}} - onLike={() => post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id)} - onComment={() => handlePostPress(post.id, true)} - onBookmark={() => post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id)} - onShare={() => {}} - onDelete={() => handleDeletePost(post.id)} + onAction={(action) => handlePostAction(post, action)} isPostAuthor={isPostAuthor} /> From 357c1d4995b315db2e19916b488096dd17c5ec1c Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Tue, 24 Mar 2026 04:23:13 +0800 Subject: [PATCH 21/36] refactor(PostCard, PostCard.legacy): remove legacy PostCard component and update exports - Deleted the legacy PostCard component to streamline the codebase and improve maintainability. - Updated exports in PostCard and index files to remove references to the legacy component. - Adjusted PostCardProps to eliminate legacy properties, ensuring only the new API is supported. - Enhanced the PostCard component to focus on modern implementation and features. --- src/components/business/PostCard.legacy.tsx | 996 ------------------ src/components/business/PostCard/PostCard.tsx | 554 +++++++++- src/components/business/PostCard/index.ts | 30 +- src/components/business/PostCard/types.ts | 23 - src/components/business/index.ts | 3 - src/components/common/ImageGallery.tsx | 36 +- src/core/entities/Post.ts | 4 + src/data/repositories/PostRepository.ts | 28 +- src/hooks/usePrefetch.ts | 5 +- src/screens/home/HomeScreen.tsx | 20 +- src/screens/home/PostDetailScreen.tsx | 29 +- src/screens/home/SearchScreen.tsx | 56 +- src/screens/message/ChatScreen.tsx | 62 +- .../components/ChatScreen/MessageBubble.tsx | 4 +- .../message/components/ChatScreen/types.ts | 2 + .../components/ChatScreen/useChatScreen.ts | 31 +- src/screens/profile/ProfileScreen.tsx | 520 +-------- src/screens/profile/UserProfileScreen.tsx | 235 +++++ src/screens/profile/UserScreen.tsx | 571 +--------- src/screens/profile/index.ts | 8 + src/screens/profile/useUserProfile.ts | 504 +++++++++ src/services/sseService.ts | 71 +- src/stores/messageManager.ts | 98 +- src/stores/messageManagerHooks.ts | 15 +- 24 files changed, 1662 insertions(+), 2243 deletions(-) delete mode 100644 src/components/business/PostCard.legacy.tsx create mode 100644 src/screens/profile/UserProfileScreen.tsx create mode 100644 src/screens/profile/useUserProfile.ts diff --git a/src/components/business/PostCard.legacy.tsx b/src/components/business/PostCard.legacy.tsx deleted file mode 100644 index 7f1e39c..0000000 --- a/src/components/business/PostCard.legacy.tsx +++ /dev/null @@ -1,996 +0,0 @@ -/** - * PostCard 帖子卡片组件 - QQ频道风格(响应式版本) - * 简洁的帖子展示,无卡片边框 - * 支持响应式布局,宽屏下优化显示 - */ - -import React, { useState, useCallback, useMemo } from 'react'; -import { - View, - TouchableOpacity, - StyleSheet, - Alert, - useWindowDimensions, -} from 'react-native'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing, fontSizes, borderRadius } from '../../theme'; -import { Post } from '../../types'; -import Text from '../common/Text'; -import Avatar from '../common/Avatar'; -import { ImageGrid, ImageGridItem, SmartImage } from '../common'; -import VotePreview from './VotePreview'; -import { useResponsive, useResponsiveValue } from '../../hooks/useResponsive'; -import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper'; - -interface PostCardProps { - post: Post; - onPress: () => void; - onUserPress: () => void; - onLike: () => void; - onComment: () => void; - onBookmark: () => void; - onShare: () => void; - onImagePress?: (images: ImageGridItem[], index: number) => void; - onDelete?: () => void; - compact?: boolean; - index?: number; - isAuthor?: boolean; - isPostAuthor?: boolean; // 当前用户是否为帖子作者 - variant?: 'default' | 'grid'; -} - -const PostCard: React.FC = ({ - post, - onPress, - onUserPress, - onLike, - onComment, - onBookmark, - onShare, - onImagePress, - onDelete, - compact = false, - index, - isAuthor = false, - isPostAuthor = false, - variant = 'default', -}) => { - const [isExpanded, setIsExpanded] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - - // 使用响应式 hook - const { - width, - isMobile, - isTablet, - isDesktop, - isWideScreen, - isLandscape, - orientation - } = useResponsive(); - - const { width: windowWidth } = useWindowDimensions(); - - // 响应式字体大小 - const responsiveFontSize = useResponsiveValue({ - xs: fontSizes.md, - sm: fontSizes.md, - md: fontSizes.lg, - lg: isLandscape ? fontSizes.lg : fontSizes.xl, - xl: fontSizes.xl, - '2xl': fontSizes.xl, - '3xl': fontSizes['2xl'], - '4xl': fontSizes['2xl'], - }); - - // 响应式标题字体大小 - const responsiveTitleFontSize = useResponsiveValue({ - xs: fontSizes.lg, - sm: fontSizes.lg, - md: fontSizes.xl, - lg: isLandscape ? fontSizes.xl : fontSizes['2xl'], - xl: fontSizes['2xl'], - '2xl': fontSizes['2xl'], - '3xl': fontSizes['3xl'], - '4xl': fontSizes['3xl'], - }); - - // 响应式头像大小 - const avatarSize = useResponsiveValue({ - xs: 36, - sm: 38, - md: 40, - lg: 44, - xl: 48, - '2xl': 48, - '3xl': 52, - '4xl': 56, - }); - - // 响应式内边距 - 宽屏下增加更多内边距 - const responsivePadding = useResponsiveValue({ - xs: spacing.md, - sm: spacing.md, - md: spacing.lg, - lg: spacing.xl, - xl: spacing.xl * 1.2, - '2xl': spacing.xl * 1.5, - '3xl': spacing.xl * 2, - '4xl': spacing.xl * 2.5, - }); - - // 宽屏下额外的卡片内边距 - const cardPadding = useMemo(() => { - if (isWideScreen) return spacing.xl; - if (isDesktop) return spacing.lg; - if (isTablet) return spacing.md; - return 0; // 移动端无额外内边距 - }, [isWideScreen, isDesktop, isTablet]); - - const formatDateTime = (dateString?: string | null): string => { - if (!dateString) return ''; - const date = new Date(dateString); - if (Number.isNaN(date.getTime())) return ''; - const pad = (num: number) => String(num).padStart(2, '0'); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; - }; - - /** 返回应展示的「最后编辑」时间字符串;优先 content_edited_at(与点赞/评论等统计更新解耦) */ - const getPostEditedDisplayAt = ( - createdAt?: string | null, - contentEditedAt?: string | null, - updatedAt?: string | null, - ): string | null => { - if (contentEditedAt) { - const c = new Date(createdAt || '').getTime(); - const e = new Date(contentEditedAt).getTime(); - if (!Number.isNaN(c) && !Number.isNaN(e) && e - c > 1000) return contentEditedAt; - return null; - } - if (createdAt && updatedAt) { - const c = new Date(createdAt).getTime(); - const u = new Date(updatedAt).getTime(); - if (!Number.isNaN(c) && !Number.isNaN(u) && u - c > 1000) return updatedAt; - } - return null; - }; - - const getTruncatedContent = (content: string | undefined | null, maxLength: number = 100): string => { - if (!content) return ''; - if (content.length <= maxLength || isExpanded) return content; - return content.substring(0, maxLength) + '...'; - }; - - // 宽屏下显示更多内容 - const getResponsiveMaxLength = () => { - if (isWideScreen) return 300; - if (isDesktop) return 250; - if (isTablet) return 200; - return 100; - }; - - const formatNumber = (num: number): string => { - if (num >= 10000) { - return (num / 10000).toFixed(1) + 'w'; - } - if (num >= 1000) { - return (num / 1000).toFixed(1) + 'k'; - } - return num.toString(); - }; - - // 处理删除帖子 - const handleDelete = () => { - if (!onDelete || isDeleting) return; - - Alert.alert( - '删除帖子', - '确定要删除这篇帖子吗?删除后将无法恢复。', - [ - { - text: '取消', - style: 'cancel', - }, - { - text: '删除', - style: 'destructive', - onPress: async () => { - setIsDeleting(true); - try { - await onDelete(); - } catch (error) { - console.error('删除帖子失败:', error); - Alert.alert('删除失败', '删除帖子时发生错误,请稍后重试'); - } finally { - setIsDeleting(false); - } - }, - }, - ], - ); - }; - - // 渲染图片 - 使用新的 ImageGrid 组件 - const renderImages = () => { - if (!post.images || !Array.isArray(post.images) || post.images.length === 0) return null; - - // 转换图片数据格式 - const gridImages: ImageGridItem[] = post.images.map(img => ({ - id: img.id, - url: img.url, - thumbnail_url: img.thumbnail_url, - preview_url: img.preview_url, - preview_url_large: img.preview_url_large, - width: img.width, - height: img.height, - })); - - // 宽屏下显示更大的图片 - const imageGap = isDesktop ? 12 : isTablet ? 8 : 4; - const imageBorderRadius = isDesktop ? borderRadius.xl : borderRadius.md; - - return ( - - - - ); - }; - - const renderBadges = () => { - const badges = []; - - if (isAuthor) { - badges.push( - - 楼主 - - ); - } - - if (post.author?.id === '1') { - badges.push( - - 管理员 - - ); - } - - return badges; - }; - - const renderTopComment = () => { - if (!post.top_comment) return null; - - const { top_comment } = post; - - const topCommentContainerStyles = [ - styles.topCommentContainer, - ...(isDesktop ? [styles.topCommentContainerWide] : []) - ]; - - const topCommentAuthorStyles = [ - styles.topCommentAuthor, - ...(isDesktop ? [styles.topCommentAuthorWide] : []) - ]; - - const topCommentTextStyles = [ - styles.topCommentText, - ...(isDesktop ? [styles.topCommentTextWide] : []) - ]; - - const moreCommentsStyles = [ - styles.moreComments, - ...(isDesktop ? [styles.moreCommentsWide] : []) - ]; - - return ( - - - - {top_comment.author?.nickname || '匿名用户'}: - - - {top_comment.content} - - - {post.comments_count > 1 && ( - - 查看全部{formatNumber(post.comments_count)}条评论 - - )} - - ); - }; - - // 渲染投票预览 - const renderVotePreview = () => { - if (!post.is_vote) return null; - - return ( - - ); - }; - - // 渲染小红书风格的两栏卡片 - const renderGridVariant = () => { - // 获取封面图(第一张图) - const coverImage = post.images && Array.isArray(post.images) && post.images.length > 0 ? post.images[0] : null; - const hasImage = !!coverImage; - - // 防御性检查:确保 author 存在 - const author = post.author || { id: '', nickname: '匿名用户', avatar: null, username: '' }; - - // 根据图片实际宽高比或使用随机值创建错落感 - // 使用帖子 ID 生成一个伪随机值,确保每次渲染一致但不同帖子有差异 - const postId = post.id || ''; - const hash = postId.split('').reduce((a, b) => a + b.charCodeAt(0), 0); - const aspectRatios = [0.7, 0.75, 0.8, 0.85, 0.9, 1, 1.1, 1.2]; - const aspectRatio = aspectRatios[hash % aspectRatios.length]; - - // 获取封面图预览 URL - const coverUrl = hasImage - ? getPreviewImageUrl(coverImage, 'grid') - : ''; - - // 获取正文预览(无图时显示) - const getContentPreview = (content: string | undefined | null): string => { - if (!content) return ''; - // 移除 Markdown 标记和多余空白 - return content - .replace(/[#*_~`\[\]()]/g, '') - .replace(/\n+/g, ' ') - .trim(); - }; - - const contentPreview = getContentPreview(post.content); - - // 响应式字体大小(网格模式) - const gridTitleFontSize = isDesktop ? 16 : isTablet ? 15 : 14; - const gridUsernameFontSize = isDesktop ? 14 : 12; - const gridLikeFontSize = isDesktop ? 14 : 12; - - const handleContainerPress = () => { - onPress(); - }; - - const handleImagePress = (e: any) => { - e.stopPropagation(); - onImagePress?.(post.images || [], 0); - }; - - const handleUserPress = (e: any) => { - e.stopPropagation(); - onUserPress(); - }; - - return ( - - {/* 封面图 - 只有有图片时才渲染,无图时不显示占位区域 */} - {hasImage && ( - - - - )} - - {/* 无图时的正文预览区域 */} - {!hasImage && contentPreview && ( - - - {contentPreview} - - - )} - - {/* 投票标识 */} - {post.is_vote && ( - - - 投票 - - )} - - {/* 标题 - 无图时显示更多行 */} - {post.title && ( - - {post.title} - - )} - - {/* 底部信息 */} - - - - - {author.nickname} - - - - - - - {formatNumber(post.likes_count)} - - - - - ); - }; - - // 根据 variant 渲染不同样式 - if (variant === 'grid') { - return renderGridVariant(); - } - - // 防御性检查:确保 author 存在 - const author = post.author || { id: '', nickname: '匿名用户', avatar: null, username: '' }; - - // 计算响应式内容最大行数 - const contentNumberOfLines = useMemo(() => { - if (isWideScreen) return 8; - if (isDesktop) return 6; - if (isTablet) return 5; - return 3; - }, [isWideScreen, isDesktop, isTablet]); - - const handleContainerPress = () => { - onPress(); - }; - - const handleUserPress = (e: any) => { - e.stopPropagation(); - onUserPress(); - }; - - const handleLikePress = (e: any) => { - e.stopPropagation(); - onLike(); - }; - - const handleCommentPress = (e: any) => { - e.stopPropagation(); - onComment(); - }; - - const handleSharePress = (e: any) => { - e.stopPropagation(); - onShare(); - }; - - const handleBookmarkPress = (e: any) => { - e.stopPropagation(); - onBookmark(); - }; - - const handleDeletePress = (e: any) => { - e.stopPropagation(); - handleDelete(); - }; - - return ( - - {/* 用户信息 */} - - - - - - - - - {author.nickname} - - - {renderBadges()} - - - - 发布 {formatDateTime(post.created_at)} - - {(() => { - const editedAt = getPostEditedDisplayAt( - post.created_at, - post.content_edited_at, - post.updated_at, - ); - return editedAt ? ( - - {' · 修改 '}{formatDateTime(editedAt)} - - ) : null; - })()} - - - {post.is_pinned && ( - - - 置顶 - - )} - {/* 删除按钮 - 只对帖子作者显示 */} - {isPostAuthor && onDelete && ( - - - - )} - - - {/* 标题 */} - {post.title && ( - - {post.title} - - )} - - {/* 内容 */} - {!compact && ( - <> - - {getTruncatedContent(post.content, getResponsiveMaxLength())} - - {post.content && post.content.length > getResponsiveMaxLength() && ( - setIsExpanded(!isExpanded)} - style={styles.expandButton} - > - - {isExpanded ? '收起' : '展开全文'} - - - )} - - )} - - {/* 图片 */} - {renderImages()} - - {/* 投票预览 */} - {renderVotePreview()} - - {/* 热门评论预览 */} - {renderTopComment()} - - {/* 交互按钮 */} - - - - - {formatNumber(post.views_count || 0)} - - - - - - - - {post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'} - - - - - - - {post.comments_count > 0 ? formatNumber(post.comments_count) : '评论'} - - - - - - - - - - - - - - ); -}; - -const styles = StyleSheet.create({ - container: { - backgroundColor: colors.background.paper, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.divider, - }, - // 宽屏卡片样式 - wideCard: { - backgroundColor: colors.background.paper, - borderRadius: borderRadius.lg, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.08, - shadowRadius: 8, - elevation: 3, - }, - userSection: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: spacing.sm, - }, - userInfo: { - flex: 1, - marginLeft: spacing.sm, - }, - userNameRow: { - flexDirection: 'row', - alignItems: 'center', - flexWrap: 'wrap', - }, - username: { - fontWeight: '600', - color: colors.text.primary, - }, - badge: { - paddingHorizontal: 4, - paddingVertical: 1, - borderRadius: 2, - marginLeft: spacing.xs, - }, - authorBadge: { - backgroundColor: colors.primary.main, - }, - adminBadge: { - backgroundColor: colors.error.main, - }, - badgeText: { - color: colors.text.inverse, - fontSize: fontSizes.xs, - fontWeight: '600', - }, - postMeta: { - flexDirection: 'row', - alignItems: 'center', - marginTop: 2, - }, - timeText: { - fontSize: fontSizes.sm, - color: colors.text.hint, - }, - pinnedTag: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.warning.light + '20', - paddingHorizontal: spacing.xs, - paddingVertical: 2, - borderRadius: borderRadius.sm, - }, - pinnedText: { - marginLeft: 2, - fontSize: fontSizes.xs, - fontWeight: '500', - }, - deleteButton: { - padding: spacing.xs, - marginLeft: spacing.sm, - }, - title: { - marginBottom: spacing.xs, - fontWeight: '600', - color: colors.text.primary, - }, - content: { - marginBottom: spacing.xs, - color: colors.text.secondary, - }, - expandButton: { - marginBottom: spacing.sm, - }, - imagesContainer: { - marginBottom: spacing.md, - }, - topCommentContainer: { - backgroundColor: colors.background.default, - borderRadius: borderRadius.sm, - padding: spacing.sm, - marginBottom: spacing.sm, - }, - topCommentContainerWide: { - padding: spacing.md, - borderRadius: borderRadius.md, - }, - topCommentContent: { - flexDirection: 'row', - alignItems: 'center', - }, - topCommentAuthor: { - fontWeight: '600', - marginRight: spacing.xs, - }, - topCommentAuthorWide: { - fontSize: fontSizes.sm, - }, - topCommentText: { - flex: 1, - }, - topCommentTextWide: { - fontSize: fontSizes.sm, - }, - moreComments: { - marginTop: spacing.xs, - fontWeight: '500', - }, - moreCommentsWide: { - fontSize: fontSizes.sm, - marginTop: spacing.sm, - }, - actionBar: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - actionBarWide: { - marginTop: spacing.sm, - paddingTop: spacing.sm, - }, - viewCount: { - flexDirection: 'row', - alignItems: 'center', - }, - actionButtons: { - flexDirection: 'row', - alignItems: 'center', - }, - actionButton: { - flexDirection: 'row', - alignItems: 'center', - marginLeft: spacing.md, - paddingVertical: spacing.xs, - }, - actionButtonWide: { - marginLeft: spacing.lg, - paddingVertical: spacing.sm, - }, - actionText: { - marginLeft: 6, - }, - actionTextWide: { - fontSize: fontSizes.md, - }, - - // ========== 小红书风格两栏样式 ========== - gridContainer: { - backgroundColor: '#FFF', - overflow: 'hidden', - borderRadius: 8, - }, - gridContainerDesktop: { - borderRadius: 12, - }, - gridContainerNoImage: { - minHeight: 200, - justifyContent: 'space-between', - }, - gridCoverImage: { - width: '100%', - aspectRatio: 0.7, - backgroundColor: '#F5F5F5', - }, - gridCoverPlaceholder: { - width: '100%', - aspectRatio: 0.7, - backgroundColor: '#F5F5F5', - alignItems: 'center', - justifyContent: 'center', - }, - // 无图时的正文预览区域 - gridContentPreview: { - backgroundColor: '#F8F8F8', - margin: 4, - padding: 8, - borderRadius: 8, - minHeight: 100, - }, - gridContentPreviewDesktop: { - margin: 8, - padding: 12, - borderRadius: 12, - minHeight: 150, - }, - gridContentText: { - fontSize: 13, - color: '#666', - lineHeight: 20, - }, - gridContentTextDesktop: { - fontSize: 15, - lineHeight: 24, - }, - gridTitle: { - color: '#333', - lineHeight: 20, - paddingHorizontal: 4, - paddingTop: 8, - minHeight: 44, - }, - gridTitleNoImage: { - minHeight: 0, - paddingTop: 4, - }, - gridTitleDesktop: { - paddingHorizontal: 8, - paddingTop: 12, - lineHeight: 24, - minHeight: 56, - }, - gridFooter: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 4, - paddingVertical: 8, - }, - gridFooterDesktop: { - paddingHorizontal: 8, - paddingVertical: 12, - }, - gridUserInfo: { - flexDirection: 'row', - alignItems: 'center', - flex: 1, - }, - gridUsername: { - color: '#666', - marginLeft: 6, - flex: 1, - }, - gridLikeInfo: { - flexDirection: 'row', - alignItems: 'center', - }, - gridLikeCount: { - color: '#666', - marginLeft: 4, - }, - gridVoteBadge: { - position: 'absolute', - top: 8, - right: 8, - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.primary.main + 'CC', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: borderRadius.full, - gap: 4, - }, - gridVoteBadgeText: { - fontSize: 10, - color: colors.primary.contrast, - fontWeight: '600', - }, -}); - -export default PostCard; diff --git a/src/components/business/PostCard/PostCard.tsx b/src/components/business/PostCard/PostCard.tsx index 6cc61f9..47254be 100644 --- a/src/components/business/PostCard/PostCard.tsx +++ b/src/components/business/PostCard/PostCard.tsx @@ -1,6 +1,6 @@ /** * PostCard 主组件 - * 帖子卡片组件 - 支持列表模式和网格模式 + * 帖子卡片组件(新实现) * * @example * // 新 API 使用方式 @@ -12,63 +12,543 @@ * /> */ -import React from 'react'; -import { PostCardProps, LegacyPostCardProps } from './types'; -import PostCardList from './PostCardList'; -import PostCardGrid from './PostCardGrid'; -import { usePostCardFeatures } from './hooks/usePostCardFeatures'; -import { createLegacyAdapter, isLegacyProps } from './legacyAdapter'; +import React, { useMemo, useState } from 'react'; +import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { PostCardProps, PostCardAction } from './types'; +import { ImageGridItem, SmartImage } from '../../common'; +import Text from '../../common/Text'; +import Avatar from '../../common/Avatar'; +import { colors, spacing, borderRadius, fontSizes } from '../../../theme'; +import { getPreviewImageUrl } from '../../../utils/imageHelper'; +import PostImages from './components/PostImages'; +import { useResponsive } from '../../../hooks/useResponsive'; /** * PostCard 主组件 - * 支持新旧两种 API + * 仅支持新 API */ -const PostCard: React.FC = (props) => { - // 检测是否使用旧 API - const isLegacy = isLegacyProps(props as LegacyPostCardProps); - - // 兼容层:转换旧 Props 到新 Props - const normalizedProps: PostCardProps = isLegacy - ? createLegacyAdapter(props as LegacyPostCardProps) - : (props as PostCardProps); - +const PostCard: React.FC = (normalizedProps) => { const { post, onAction, variant = 'list', - features: featuresConfig, + features, isPostAuthor = false, isAuthor = false, index, style, } = normalizedProps; - // 解析 Features 配置 - const resolvedFeatures = usePostCardFeatures(featuresConfig); + const [isExpanded, setIsExpanded] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const isCompact = + features === 'compact' || + (typeof features === 'object' && features?.showContent === false); + + const emit = (action: PostCardAction) => { + onAction(action); + }; + + const handleImagePress = (images: ImageGridItem[], imageIndex: number) => { + emit({ type: 'imagePress', payload: { images, imageIndex } }); + }; + + const showGrid = variant === 'grid'; + const author = post.author; + const rawContent = post.content ?? ''; + const { isDesktop, isTablet, isWideScreen, isLandscape } = useResponsive(); + + const handleCardPress = () => emit({ type: 'press' }); + const handleUserPress = () => emit({ type: 'userPress' }); + const handleLike = () => emit({ type: post.is_liked ? 'unlike' : 'like' }); + const handleComment = () => emit({ type: 'comment' }); + const handleBookmark = () => emit({ type: post.is_favorited ? 'unbookmark' : 'bookmark' }); + const handleShare = () => emit({ type: 'share' }); + const handleDelete = () => { + if (isDeleting) return; + Alert.alert( + '删除帖子', + '确定要删除这篇帖子吗?删除后将无法恢复。', + [ + { text: '取消', style: 'cancel' }, + { + text: '删除', + style: 'destructive', + onPress: async () => { + setIsDeleting(true); + try { + emit({ type: 'delete' }); + } finally { + setIsDeleting(false); + } + }, + }, + ], + ); + }; + + const images: ImageGridItem[] = Array.isArray(post.images) + ? post.images.map((img) => ({ + id: img.id, + url: img.url, + thumbnail_url: img.thumbnail_url, + preview_url: img.preview_url, + preview_url_large: img.preview_url_large, + width: img.width, + height: img.height, + })) + : []; + + const formatNumber = (num: number): string => { + if (num >= 10000) return `${(num / 10000).toFixed(1)}w`; + if (num >= 1000) return `${(num / 1000).toFixed(1)}k`; + return String(num); + }; + + const getResponsiveMaxLength = () => { + if (isWideScreen) return 300; + if (isDesktop) return 250; + if (isTablet) return 200; + return 100; + }; + + const contentNumberOfLines = useMemo(() => { + if (isWideScreen) return 8; + if (isDesktop) return 6; + if (isTablet) return 5; + return 3; + }, [isWideScreen, isDesktop, isTablet]); + + const getTruncatedContent = (value: string): string => { + const maxLength = getResponsiveMaxLength(); + if (value.length <= maxLength || isExpanded) return value; + return `${value.substring(0, maxLength)}...`; + }; + + const shouldTruncate = !showGrid && !isCompact && rawContent.length > getResponsiveMaxLength(); + const content = useMemo( + () => (showGrid || isCompact ? rawContent : getTruncatedContent(rawContent)), + [rawContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet] + ); + + const renderImages = () => { + if (images.length === 0) return null; + + if (showGrid) { + const cover = images[0]; + const coverUrl = getPreviewImageUrl(cover as any, 'grid'); + return ( + handleImagePress(images, 0)}> + + + ); + } - // 根据 variant 选择渲染模式 - if (variant === 'grid') { return ( - ); - } + }; + + const renderTopComment = () => { + if (showGrid || !post.top_comment) return null; + return ( + + + {post.top_comment.author?.nickname || '匿名用户'}: + + + {post.top_comment.content} + + + ); + }; + + const renderGridCard = () => { + const cover = images[0]; + const hasImage = !!cover; + const coverUrl = hasImage ? getPreviewImageUrl(cover as any, 'grid') : ''; + const contentPreview = rawContent + .replace(/[#*_~`\[\]()]/g, '') + .replace(/\n+/g, ' ') + .trim(); + + return ( + + {hasImage ? ( + handleImagePress(images, 0)}> + + + ) : ( + + + {contentPreview} + + + )} + + {post.is_vote && ( + + + 投票 + + )} + + {!!post.title && ( + + {post.title} + + )} + + + + + {author?.nickname || '匿名用户'} + + + + {formatNumber(post.likes_count || 0)} + + + + ); + }; return ( - + showGrid ? renderGridCard() : ( + + + + + + {author?.nickname || '匿名用户'} + {!showGrid && ( + + + {post.created_at ? new Date(post.created_at).toLocaleDateString() : ''} + + {post.is_pinned && ( + + + 置顶 + + )} + {isAuthor && 楼主} + + )} + + + {isPostAuthor && ( + + + + )} + + + {!!post.title && ( + + {post.title} + + )} + + {!isCompact && !!content && ( + <> + + {content} + + {shouldTruncate && ( + setIsExpanded((prev) => !prev)} style={styles.expandBtn}> + {isExpanded ? '收起' : '展开全文'} + + )} + + )} + + {renderImages()} + {renderTopComment()} + + + + + {post.views_count || 0} + + + + + + {post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'} + + + + + {post.comments_count > 0 ? formatNumber(post.comments_count) : '评论'} + + + + + + + + + + + ) ); }; +const styles = StyleSheet.create({ + card: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + padding: spacing.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.sm, + }, + authorWrap: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + minWidth: 0, + }, + authorMeta: { + marginLeft: spacing.sm, + flex: 1, + minWidth: 0, + }, + authorName: { + color: colors.text.primary, + fontWeight: '600', + fontSize: fontSizes.md, + }, + timeText: { + color: colors.text.hint, + fontSize: fontSizes.xs, + marginTop: 2, + }, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginTop: 2, + }, + pinnedTag: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: `${colors.warning.light}22`, + borderRadius: borderRadius.sm, + paddingHorizontal: 4, + paddingVertical: 1, + gap: 2, + }, + pinnedText: { + color: colors.warning.main, + fontSize: fontSizes.xs, + }, + badgeText: { + color: colors.primary.main, + fontSize: fontSizes.xs, + fontWeight: '600', + }, + deleteButton: { + padding: spacing.xs, + marginLeft: spacing.sm, + }, + title: { + color: colors.text.primary, + fontSize: fontSizes.lg, + fontWeight: '600', + marginBottom: spacing.xs, + }, + content: { + color: colors.text.secondary, + fontSize: fontSizes.md, + marginBottom: spacing.xs, + lineHeight: fontSizes.md * 1.45, + }, + expandBtn: { + marginBottom: spacing.sm, + }, + expandText: { + color: colors.primary.main, + fontSize: fontSizes.sm, + }, + topCommentBox: { + backgroundColor: colors.background.default, + borderRadius: borderRadius.sm, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + marginBottom: spacing.sm, + flexDirection: 'row', + alignItems: 'center', + }, + topCommentAuthor: { + color: colors.text.secondary, + fontSize: fontSizes.sm, + fontWeight: '600', + marginRight: 4, + }, + topCommentText: { + color: colors.text.secondary, + fontSize: fontSizes.sm, + flex: 1, + }, + gridCover: { + width: '100%', + aspectRatio: 0.78, + backgroundColor: colors.background.default, + }, + actions: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + viewsWrap: { + flexDirection: 'row', + alignItems: 'center', + }, + viewsText: { + color: colors.text.hint, + fontSize: fontSizes.sm, + marginLeft: 4, + }, + actionButtons: { + flexDirection: 'row', + alignItems: 'center', + }, + actionBtn: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: spacing.md, + }, + actionText: { + color: colors.text.secondary, + fontSize: fontSizes.sm, + marginLeft: 4, + }, + activeActionText: { + color: colors.error.main, + }, + activeActionTextMerged: { + color: colors.error.main, + fontSize: fontSizes.sm, + marginLeft: 4, + }, + gridRootCard: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + overflow: 'hidden', + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, + }, + gridNoImagePreview: { + backgroundColor: colors.background.default, + margin: 6, + borderRadius: borderRadius.md, + minHeight: 120, + padding: 8, + }, + gridNoImageText: { + color: colors.text.secondary, + fontSize: fontSizes.sm, + lineHeight: 20, + }, + gridVoteTag: { + position: 'absolute', + top: 8, + right: 8, + backgroundColor: `${colors.primary.main}CC`, + borderRadius: borderRadius.full, + paddingHorizontal: 8, + paddingVertical: 4, + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + gridVoteTagText: { + color: colors.primary.contrast, + fontSize: 10, + fontWeight: '600', + }, + gridTitleMain: { + color: colors.text.primary, + fontSize: fontSizes.md, + lineHeight: 20, + paddingHorizontal: 8, + paddingTop: 8, + minHeight: 44, + }, + gridFooter: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 8, + paddingVertical: 10, + }, + gridUserArea: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + minWidth: 0, + }, + gridUsername: { + color: colors.text.secondary, + fontSize: fontSizes.sm, + marginLeft: 6, + flex: 1, + }, + gridLikeArea: { + flexDirection: 'row', + alignItems: 'center', + }, + gridLikeCount: { + color: colors.text.secondary, + fontSize: fontSizes.sm, + marginLeft: 4, + }, +}); + export default PostCard; \ No newline at end of file diff --git a/src/components/business/PostCard/index.ts b/src/components/business/PostCard/index.ts index b85d14e..0e8c2b6 100644 --- a/src/components/business/PostCard/index.ts +++ b/src/components/business/PostCard/index.ts @@ -26,33 +26,5 @@ export type { PostGridInfoProps, PostCardListProps, PostCardGridProps, - LegacyPostCardProps, Badge, -} from './types'; - -// 预设配置导出 -export { PostCardPresets, defaultFeatures, resolveFeatures } from './presets'; - -// Hooks 导出 -export { - usePostCardActions, - usePostCardFeatures, - usePostCardStyles, - usePostGridStyles, -} from './hooks'; -export type { PostCardStylesConfig, PostGridStylesConfig } from './hooks'; - -// 子组件导出 -export { - PostHeader, - PostTitle, - PostContent, - PostImages, - PostActions, - PostTopComment, - PostGridCover, - PostGridInfo, -} from './components'; - -// 兼容层导出(用于迁移帮助) -export { isLegacyProps, createLegacyAdapter } from './legacyAdapter'; \ No newline at end of file +} from './types'; \ No newline at end of file diff --git a/src/components/business/PostCard/types.ts b/src/components/business/PostCard/types.ts index 510edb0..5585ced 100644 --- a/src/components/business/PostCard/types.ts +++ b/src/components/business/PostCard/types.ts @@ -251,29 +251,6 @@ export interface PostCardGridProps { style?: StyleProp; } -// ==================== 旧版 Props(兼容层使用) ==================== - -/** - * 旧版 PostCard Props(用于兼容层) - * @deprecated 请迁移到新的 PostCardProps - */ -export interface LegacyPostCardProps { - post: Post; - onPress: () => void; - onUserPress: () => void; - onLike: () => void; - onComment: () => void; - onBookmark: () => void; - onShare: () => void; - onImagePress?: (images: ImageGridItem[], index: number) => void; - onDelete?: () => void; - compact?: boolean; - index?: number; - isAuthor?: boolean; - isPostAuthor?: boolean; - variant?: 'default' | 'grid'; -} - // ==================== 工具类型 ==================== /** diff --git a/src/components/business/index.ts b/src/components/business/index.ts index 5547a0d..91395e8 100644 --- a/src/components/business/index.ts +++ b/src/components/business/index.ts @@ -13,10 +13,7 @@ export type { PostCardFeaturesPreset, PostCardFeaturesConfig, PostCardProps, - LegacyPostCardProps, } from './PostCard'; -export { PostCardPresets, resolveFeatures } from './PostCard'; -export { usePostCardActions, usePostCardFeatures } from './PostCard'; export { default as CommentItem } from './CommentItem'; export { default as UserProfileHeader } from './UserProfileHeader'; export { default as SystemMessageItem } from './SystemMessageItem'; diff --git a/src/components/common/ImageGallery.tsx b/src/components/common/ImageGallery.tsx index 8cc0dc0..8e4aaf3 100644 --- a/src/components/common/ImageGallery.tsx +++ b/src/components/common/ImageGallery.tsx @@ -4,7 +4,7 @@ * 使用 expo-image,原生支持 GIF/WebP 动图 */ -import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { Modal, View, @@ -84,6 +84,8 @@ export const ImageGallery: React.FC = ({ onSave, backgroundOpacity = 1, }) => { + const closeTimerRef = useRef | null>(null); + const isClosingRef = useRef(false); const [currentIndex, setCurrentIndex] = useState(initialIndex); const [showControls, setShowControls] = useState(true); const [loading, setLoading] = useState(true); @@ -136,6 +138,20 @@ export const ImageGallery: React.FC = ({ } }, [visible, initialIndex, resetZoom]); + useEffect(() => { + if (visible) { + isClosingRef.current = false; + } + }, [visible]); + + useEffect(() => { + return () => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + } + }; + }, []); + // 图片变化时重置加载状态和缩放 useEffect(() => { setLoading(true); @@ -156,6 +172,18 @@ export const ImageGallery: React.FC = ({ setShowControls(prev => !prev); }, []); + const requestClose = useCallback(() => { + if (isClosingRef.current) { + return; + } + + isClosingRef.current = true; + // 延迟一个短时间窗口,避免关闭同一触摸触发到底层组件 + closeTimerRef.current = setTimeout(() => { + onClose(); + }, 120); + }, [onClose]); + const goToPrev = useCallback(() => { if (currentIndex > 0) { updateIndex(currentIndex - 1); @@ -297,7 +325,7 @@ export const ImageGallery: React.FC = ({ .numberOfTaps(1) .maxDistance(10) .onEnd(() => { - runOnJS(onClose)(); + runOnJS(requestClose)(); }); // 组合手势: @@ -326,7 +354,7 @@ export const ImageGallery: React.FC = ({ visible={visible} transparent animationType="fade" - onRequestClose={onClose} + onRequestClose={requestClose} statusBarTranslucent > @@ -334,7 +362,7 @@ export const ImageGallery: React.FC = ({ {/* 顶部控制栏 */} {showControls && ( - + diff --git a/src/core/entities/Post.ts b/src/core/entities/Post.ts index 7a92309..f52cad9 100644 --- a/src/core/entities/Post.ts +++ b/src/core/entities/Post.ts @@ -17,6 +17,8 @@ export interface PostAuthor { username: string; nickname?: string; avatar?: string; + is_following?: boolean; + is_following_me?: boolean; } /** @@ -133,6 +135,8 @@ export const createPostAuthor = (data: Partial): PostAuthor => ({ username: data.username || '', nickname: data.nickname, avatar: data.avatar, + is_following: data.is_following, + is_following_me: data.is_following_me, }); /** diff --git a/src/data/repositories/PostRepository.ts b/src/data/repositories/PostRepository.ts index 0c189be..6ab6631 100644 --- a/src/data/repositories/PostRepository.ts +++ b/src/data/repositories/PostRepository.ts @@ -47,6 +47,8 @@ interface PostApiResponse { username: string; nickname?: string; avatar?: string; + is_following?: boolean; + is_following_me?: boolean; }; } @@ -102,6 +104,9 @@ export class PostRepository implements IPostRepository { username: model.author.username, nickname: model.author.nickname, avatar: model.author.avatar, + // 关注关系直接透传 API 字段,供详情页关注按钮状态使用 + is_following: response.author?.is_following, + is_following_me: response.author?.is_following_me, } : undefined, title: model.title, content: model.content, @@ -286,20 +291,7 @@ export class PostRepository implements IPostRepository { */ async getPostById(id: string): Promise { try { - // 1. 先查内存缓存 - const memoryCached = this.getFromMemoryCache(id); - if (memoryCached) { - return memoryCached; - } - - // 2. 查本地数据库缓存 - const localCached = await this.getFromLocalCache(id); - if (localCached) { - this.saveToMemoryCache(localCached); - return localCached; - } - - // 3. 从API获取 + // 详情页优先取最新服务端数据,避免旧缓存导致关注态等关系字段过期 const response = await this.api.get(`/posts/${id}`); const post = this.mapToPost(response); @@ -309,10 +301,16 @@ export class PostRepository implements IPostRepository { return post; } catch (error) { - // 如果API请求失败,尝试返回本地缓存 + // API失败时再回退缓存,保证离线/弱网可用 + const memoryCached = this.getFromMemoryCache(id); + if (memoryCached) { + console.warn('[PostRepository] API获取失败,使用内存缓存:', error); + return memoryCached; + } const localCached = await this.getFromLocalCache(id); if (localCached) { console.warn('[PostRepository] API获取失败,使用本地缓存:', error); + this.saveToMemoryCache(localCached); return localCached; } this.handleError(error, '获取帖子详情'); diff --git a/src/hooks/usePrefetch.ts b/src/hooks/usePrefetch.ts index eab5ee4..ffe0556 100644 --- a/src/hooks/usePrefetch.ts +++ b/src/hooks/usePrefetch.ts @@ -152,7 +152,10 @@ function prefetchConversations(): void { key: 'conversations:list', executor: async () => { await messageManager.initialize(); - await messageManager.fetchConversations(true); + await messageManager.requestConversationListRefresh('prefetch', { + force: true, + allowDefer: true, + }); return messageManager.getConversations(); }, priority: Priority.HIGH, diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 288e50e..7d7d067 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -512,14 +512,7 @@ export const HomeScreen: React.FC = () => { handlePostPress(post.id)} - onUserPress={() => authorId && handleUserPress(authorId)} - onLike={() => handleLike(post)} - onComment={() => handlePostPress(post.id, true)} - onBookmark={() => handleBookmark(post)} - onShare={() => handleShare(post)} - onImagePress={(images, index) => handleImagePress(images, index)} - onDelete={() => handleDeletePost(post.id)} + onAction={(action) => handlePostAction(post, action)} isPostAuthor={isPostAuthor} /> @@ -579,15 +572,8 @@ export const HomeScreen: React.FC = () => { handlePostPress(post.id)} - onUserPress={() => authorId && handleUserPress(authorId)} - onLike={() => handleLike(post)} - onComment={() => handlePostPress(post.id, true)} - onBookmark={() => handleBookmark(post)} - onShare={() => handleShare(post)} - onImagePress={(images, index) => handleImagePress(images, index)} - onDelete={() => handleDeletePost(post.id)} + variant={viewMode === 'grid' ? 'grid' : 'list'} + onAction={(action) => handlePostAction(post, action)} isPostAuthor={isPostAuthor} /> ); diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index c0ca5f5..520f4a0 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -281,8 +281,25 @@ export const PostDetailScreen: React.FC = () => { if (!post?.author) return; const author = post.author; + const handleBackPress = () => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + navigation.navigate('Main', { + screen: 'HomeTab', + params: { screen: 'Home', params: undefined }, + }); + }; navigation.setOptions({ + headerBackVisible: false, + headerTitleAlign: 'left', + headerLeft: () => ( + + + + ), headerTitle: () => ( { handleUserPress(author.id)}> - {author.nickname} + {author.nickname && author.nickname.length > 10 + ? `${author.nickname.slice(0, 10)}...` + : author.nickname} @@ -1701,7 +1720,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', flex: 1, - marginLeft: -8, + marginLeft: spacing.sm, }, headerAvatarWrapper: { marginRight: spacing.sm, @@ -1720,6 +1739,12 @@ const styles = StyleSheet.create({ alignItems: 'center', marginRight: spacing.sm, }, + headerBackButton: { + paddingHorizontal: spacing.xs, + paddingVertical: spacing.xs, + marginLeft: spacing.xs, + marginRight: spacing.sm, + }, // 帖子容器 postContainer: { backgroundColor: colors.background.paper, diff --git a/src/screens/home/SearchScreen.tsx b/src/screens/home/SearchScreen.tsx index a00ad87..7a6e7b9 100644 --- a/src/screens/home/SearchScreen.tsx +++ b/src/screens/home/SearchScreen.tsx @@ -22,6 +22,7 @@ import { Post, User } from '../../types'; import { useUserStore } from '../../stores'; import { postService, authService } from '../../services'; import { PostCard, TabBar, SearchBar } from '../../components/business'; +import type { PostCardAction } from '../../components/business/PostCard'; import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common'; import { HomeStackParamList } from '../../navigation/types'; import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks/useResponsive'; @@ -175,6 +176,41 @@ export const SearchScreen: React.FC = ({ onBack, navigation: navigation.navigate('UserProfile', { userId }); }; + // 统一处理 PostCard 的操作(搜索页不支持删除) + const handlePostAction = (post: Post, action: PostCardAction) => { + switch (action.type) { + case 'press': + handlePostPress(post.id); + break; + case 'userPress': + if (post.author?.id) { + handleUserPress(post.author.id); + } + break; + case 'like': + postService.likePost(post.id); + break; + case 'unlike': + postService.unlikePost(post.id); + break; + case 'comment': + handlePostPress(post.id, true); + break; + case 'bookmark': + postService.favoritePost(post.id); + break; + case 'unbookmark': + postService.unfavoritePost(post.id); + break; + case 'share': + // 搜索页暂不处理分享 + break; + case 'imagePress': + case 'delete': + break; + } + }; + // 获取当前搜索类型 const getSearchType = (): SearchType => { switch (activeIndex) { @@ -222,13 +258,9 @@ export const SearchScreen: React.FC = ({ onBack, navigation: handlePostPress(post.id)} - onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}} - onLike={() => {}} - onComment={() => handlePostPress(post.id, true)} - onBookmark={() => {}} - onShare={() => {}} - compact={isMobile} + onAction={(action) => handlePostAction(post, action)} + variant="list" + features={isMobile ? 'compact' : 'full'} /> ))} @@ -248,13 +280,9 @@ export const SearchScreen: React.FC = ({ onBack, navigation: renderItem={({ item }) => ( handlePostPress(item.id)} - onUserPress={() => item.author ? handleUserPress(item.author.id) : () => {}} - onLike={() => {}} - onComment={() => handlePostPress(item.id, true)} - onBookmark={() => {}} - onShare={() => {}} - compact + onAction={(action) => handlePostAction(item, action)} + variant="list" + features="compact" /> )} keyExtractor={item => item.id} diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index b6223be..2dabce4 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -78,6 +78,8 @@ export const ChatScreen: React.FC = () => { // 输入框区域高度(用于定位浮动 mention 面板) const [inputWrapperHeight, setInputWrapperHeight] = useState(60); const [showEdgeLoadingIndicator, setShowEdgeLoadingIndicator] = useState(false); + const replyTargetMessageIdRef = useRef(null); + const replyHighlightTimerRef = useRef | null>(null); const isPreloadingRef = useRef(false); const lastScrollYRef = useRef(0); const isUserDraggingRef = useRef(false); @@ -151,6 +153,7 @@ export const ChatScreen: React.FC = () => { longPressMenuVisible, selectedMessage, selectedMessageId, + setSelectedMessageId, menuPosition, isGroupChat, groupInfo, @@ -206,6 +209,7 @@ export const ChatScreen: React.FC = () => { handleReachLatestEdge, setBrowsingHistory, } = useChatScreen(); + const displayMessages = useMemo(() => [...messages].reverse(), [messages]); useEffect(() => { if (!loadingMore && showEdgeLoadingIndicator) { @@ -218,14 +222,46 @@ export const ChatScreen: React.FC = () => { if (preloadCooldownTimerRef.current) { clearTimeout(preloadCooldownTimerRef.current); } + if (replyHighlightTimerRef.current) { + clearTimeout(replyHighlightTimerRef.current); + } }; }, []); + const highlightMessageTemporarily = useCallback((messageId: string, duration = 1500) => { + if (replyHighlightTimerRef.current) { + clearTimeout(replyHighlightTimerRef.current); + } + setSelectedMessageId(messageId); + replyHighlightTimerRef.current = setTimeout(() => { + setSelectedMessageId(prev => (prev === messageId ? null : prev)); + }, duration); + }, [setSelectedMessageId]); + + const handleReplyPreviewPress = useCallback((messageId: string) => { + const targetId = String(messageId); + const targetIndex = displayMessages.findIndex(msg => String(msg.id) === targetId); + if (targetIndex < 0) return; + + replyTargetMessageIdRef.current = targetId; + setBrowsingHistory(true); + highlightMessageTemporarily(targetId); + + flatListRef.current?.scrollToIndex({ + index: targetIndex, + animated: true, + viewPosition: 0.5, + }); + }, [displayMessages, flatListRef, highlightMessageTemporarily, setBrowsingHistory]); + // 监听返回事件,刷新会话列表 useEffect(() => { const unsubscribe = navigation.addListener('beforeRemove', () => { // 刷新会话列表,确保已读状态正确显示 - messageManager.fetchConversations(true); + messageManager.requestConversationListRefresh('chat-before-remove', { + force: true, + allowDefer: false, + }); }); return unsubscribe; }, [navigation]); @@ -261,6 +297,7 @@ export const ChatScreen: React.FC = () => { shouldShowTime={shouldShowTime} onImagePress={handleImagePress} onReply={handleReplyMessage} + onReplyPress={handleReplyPreviewPress} /> ); }, [ @@ -280,14 +317,13 @@ export const ChatScreen: React.FC = () => { shouldShowTime, handleImagePress, handleReplyMessage, + handleReplyPreviewPress, ]); const keyExtractor = useCallback((item: any) => String(item.id), []); // 获取正在输入提示 const typingHint = getTypingHint(); - const displayMessages = useMemo(() => [...messages].reverse(), [messages]); - const handleMessageListScroll = useCallback((event: any) => { const { contentSize, contentOffset, layoutMeasurement } = event.nativeEvent; scrollPositionRef.current = { @@ -410,6 +446,26 @@ export const ChatScreen: React.FC = () => { }} scrollEventThrottle={16} onContentSizeChange={handleContentSizeChange} + onScrollToIndexFailed={(info) => { + const targetId = replyTargetMessageIdRef.current; + if (!targetId || !flatListRef.current) return; + + flatListRef.current.scrollToOffset({ + offset: Math.max(0, info.averageItemLength * info.index), + animated: true, + }); + + setTimeout(() => { + const retryIndex = displayMessages.findIndex(msg => String(msg.id) === targetId); + if (retryIndex >= 0) { + flatListRef.current?.scrollToIndex({ + index: retryIndex, + animated: true, + viewPosition: 0.5, + }); + } + }, 120); + }} /> )} {showEdgeLoadingIndicator && ( diff --git a/src/screens/message/components/ChatScreen/MessageBubble.tsx b/src/screens/message/components/ChatScreen/MessageBubble.tsx index 945460d..b4feaa0 100644 --- a/src/screens/message/components/ChatScreen/MessageBubble.tsx +++ b/src/screens/message/components/ChatScreen/MessageBubble.tsx @@ -57,6 +57,7 @@ export const MessageBubble: React.FC = ({ shouldShowTime, onImagePress, onReply, + onReplyPress, }) => { const bubbleRef = useRef(null); const pressPositionRef = useRef({ x: 0, y: 0 }); @@ -306,6 +307,7 @@ export const MessageBubble: React.FC = ({ isMe ? styles.myBubble : styles.theirBubble, hasReply && segmentStyles.replyBubble, isPureImageMessage && segmentStyles.pureImageBubble, + isSelected && (isMe ? styles.mySelectedBubble : styles.theirSelectedBubble), ]}> = ({ replyMessage={getReplyMessage()} getSenderInfo={getSenderInfo} onAtPress={() => undefined} - onReplyPress={() => undefined} + onReplyPress={onReplyPress} onImagePress={(url) => { // 查找点击的图片索引 const clickIndex = imageSegments.findIndex(img => img.url === url); diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts index e414537..2fc3385 100644 --- a/src/screens/message/components/ChatScreen/types.ts +++ b/src/screens/message/components/ChatScreen/types.ts @@ -86,6 +86,8 @@ export interface MessageBubbleProps { onImagePress?: (images: { id: string; url: string; thumbnail_url?: string; width?: number; height?: number }[], index: number) => void; // 滑动回复回调 onReply?: (message: GroupMessage) => void; + // 点击回复预览回调(定位到原消息) + onReplyPress?: (messageId: string) => void; } // 输入框 Props diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 75458b4..8062eec 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -124,6 +124,8 @@ export const useChatScreen = () => { const isProgrammaticScrollRef = useRef(false); const suppressAutoFollowRef = useRef(false); const isBrowsingHistoryRef = useRef(false); + const hasShownMessageListRef = useRef(false); + const historyLoadingLockUntilRef = useRef(0); // 回复消息状态 const [replyingTo, setReplyingTo] = useState(null); @@ -205,9 +207,16 @@ export const useChatScreen = () => { // 加载态语义修正: // isLoadingMessages 在分页加载历史时也会短暂为 true。 // 若直接驱动 ChatScreen 的 loading,会导致消息列表被卸载重挂载,触发“回到底部”。 - // 这里只在“首屏且尚无消息”时展示 loading 占位。 + // 这里只在“首屏且尚未展示过消息列表”时展示 loading 占位。 useEffect(() => { - setLoading(isLoadingMessages && messageManagerMessages.length === 0); + if (messageManagerMessages.length > 0) { + hasShownMessageListRef.current = true; + } + const shouldShowInitialLoading = + !hasShownMessageListRef.current && + isLoadingMessages && + messageManagerMessages.length === 0; + setLoading(shouldShowInitialLoading); }, [isLoadingMessages, messageManagerMessages.length]); // 【改造】同步 hasMore 状态 @@ -281,10 +290,16 @@ export const useChatScreen = () => { enterMarkedKeyRef.current = ''; suppressAutoFollowRef.current = false; isBrowsingHistoryRef.current = false; + hasShownMessageListRef.current = false; + historyLoadingLockUntilRef.current = 0; }, [conversationId]); + const isHistoryLoadingLocked = useCallback(() => { + return Date.now() < historyLoadingLockUntilRef.current; + }, []); + const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => { - if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current)) return; + if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked())) return; // inverted 列表下,最新消息端对应 offset=0 isProgrammaticScrollRef.current = true; flatListRef.current?.scrollToOffset({ @@ -294,7 +309,7 @@ export const useChatScreen = () => { setTimeout(() => { isProgrammaticScrollRef.current = false; }, animated ? 220 : 32); - }, [flatListRef, scrollPositionRef, messages.length]); + }, [flatListRef, isHistoryLoadingLocked]); const isNearBottom = useCallback(() => { const scrollY = scrollPositionRef.current.scrollY || 0; @@ -443,6 +458,8 @@ export const useChatScreen = () => { // 历史加载期间禁止“新消息自动跟随到底” suppressAutoFollowRef.current = true; + isBrowsingHistoryRef.current = true; + historyLoadingLockUntilRef.current = Date.now() + 3000; // 保存加载前的滚动位置和内容高度 const scrollYBefore = scrollPositionRef.current.scrollY; @@ -464,6 +481,8 @@ export const useChatScreen = () => { console.error('加载历史消息失败:', error); } finally { setLoadingMore(false); + // 加载结束后继续保留短暂锁窗,避免布局结算阶段误触自动回底 + historyLoadingLockUntilRef.current = Date.now() + 800; } }, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]); @@ -473,9 +492,10 @@ export const useChatScreen = () => { }, []); const handleReachLatestEdge = useCallback(() => { + if (isHistoryLoadingLocked()) return; suppressAutoFollowRef.current = false; isBrowsingHistoryRef.current = false; - }, []); + }, [isHistoryLoadingLocked]); const setBrowsingHistory = useCallback((browsing: boolean) => { isBrowsingHistoryRef.current = browsing; @@ -1221,6 +1241,7 @@ export const useChatScreen = () => { longPressMenuVisible, selectedMessage, selectedMessageId, + setSelectedMessageId, menuPosition, isGroupChat, groupInfo, diff --git a/src/screens/profile/ProfileScreen.tsx b/src/screens/profile/ProfileScreen.tsx index b100df0..1a1475d 100644 --- a/src/screens/profile/ProfileScreen.tsx +++ b/src/screens/profile/ProfileScreen.tsx @@ -1,523 +1,21 @@ /** - * 个人主页 ProfileScreen - 美化版(响应式适配) + * 个人主页 ProfileScreen * 胡萝卜BBS - 当前用户个人主页 - * 采用现代卡片式设计,优化视觉层次和交互体验 - * 支持桌面端双栏布局 + * 使用统一的 UserProfileScreen 组件,mode='self' */ -import React, { useState, useCallback, useMemo } from 'react'; -import { - View, - StyleSheet, - RefreshControl, - Animated, - ScrollView, -} from 'react-native'; -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import React from 'react'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; -import { colors, spacing } from '../../theme'; -import { Post } from '../../types'; -import { useAuthStore, useUserStore } from '../../stores'; -import { postService } from '../../services'; -import { UserProfileHeader, PostCard, TabBar } from '../../components/business'; -import { PostCardAction } from '../../components/business/PostCard'; -import { Loading, EmptyState, Text } from '../../components/common'; -import { ResponsiveContainer } from '../../components/common'; -import { useResponsive } from '../../hooks'; -import { ProfileStackParamList, HomeStackParamList, RootStackParamList } from '../../navigation/types'; +import UserProfileScreen from './UserProfileScreen'; +import { ProfileStackParamList } from '../../navigation/types'; -type NavigationProp = NativeStackNavigationProp; -type HomeNavigationProp = NativeStackNavigationProp; -type RootNavigationProp = NativeStackNavigationProp; - -const TABS = ['帖子', '收藏']; -const TAB_ICONS = ['file-document-outline', 'bookmark-outline']; +type ProfileNavigationProp = NativeStackNavigationProp; export const ProfileScreen: React.FC = () => { - const navigation = useNavigation(); - const insets = useSafeAreaInsets(); - // 在大屏幕模式下不使用 Bottom Tab Navigator,所以 try-catch 处理 - let tabBarHeight = 0; - try { - tabBarHeight = useBottomTabBarHeight(); - } catch (e) { - // 大屏幕模式下不在 Bottom Tab Navigator 中,会抛出错误,忽略即可 - tabBarHeight = 0; - } - const homeNavigation = useNavigation(); - // 使用 any 类型来访问根导航 - const rootNavigation = useNavigation(); - const { currentUser, updateUser, fetchCurrentUser } = useAuthStore(); - const { followUser, unfollowUser, posts: storePosts } = useUserStore(); + const profileNavigation = useNavigation(); - // 响应式布局 - const { isDesktop, isTablet, width } = useResponsive(); - - // 页面滚动底部安全间距,避免内容被底部 TabBar 遮挡 - // TabBar 悬浮:高度 64 + 浮动间距 12*2 = 88 - const scrollBottomInset = useMemo(() => { - if (isDesktop) return spacing.lg; - const FLOATING_TAB_BAR_HEIGHT = 64 + 24; // TabBar高度 + 上下浮动间距 - return FLOATING_TAB_BAR_HEIGHT + insets.bottom + spacing.md; - }, [isDesktop, insets.bottom]); - - const [activeTab, setActiveTab] = useState(0); - const [posts, setPosts] = useState([]); - const [favorites, setFavorites] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const scrollY = new Animated.Value(0); - - // 跳转到关注列表 - const handleFollowingPress = useCallback(() => { - if (currentUser) { - (rootNavigation as any).navigate('FollowList', { userId: currentUser.id, type: 'following' }); - } - }, [currentUser, rootNavigation]); - - // 跳转到粉丝列表 - const handleFollowersPress = useCallback(() => { - if (currentUser) { - (rootNavigation as any).navigate('FollowList', { userId: currentUser.id, type: 'followers' }); - } - }, [currentUser, rootNavigation]); - - // 加载用户帖子 - const loadUserPosts = useCallback(async () => { - if (currentUser) { - try { - const response = await postService.getUserPosts(currentUser.id); - setPosts(response.list); - } catch (error) { - console.error('获取用户帖子失败:', error); - } - } - setLoading(false); - }, [currentUser]); - - // 加载用户收藏 - const loadUserFavorites = useCallback(async () => { - if (currentUser) { - try { - const response = await postService.getUserFavorites(currentUser.id); - setFavorites(response.list); - } catch (error) { - console.error('获取用户收藏失败:', error); - } - } - setLoading(false); - }, [currentUser]); - - // 监听 tab 切换,只在数据为空时加载对应数据 - React.useEffect(() => { - if (activeTab === 0 && posts.length === 0) { - loadUserPosts(); - } else if (activeTab === 1 && favorites.length === 0) { - loadUserFavorites(); - } - }, [activeTab, loadUserPosts, loadUserFavorites, posts.length, favorites.length]); - - // 初始加载 - React.useEffect(() => { - loadUserPosts(); - }, [loadUserPosts]); - - // 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新) - React.useEffect(() => { - // 同步帖子列表状态 - if (posts.length > 0) { - let hasChanges = false; - const updatedPosts = posts.map(localPost => { - const storePost = storePosts.find(sp => sp.id === localPost.id); - if (storePost && ( - storePost.is_liked !== localPost.is_liked || - storePost.is_favorited !== localPost.is_favorited || - storePost.likes_count !== localPost.likes_count || - storePost.favorites_count !== localPost.favorites_count - )) { - hasChanges = true; - return { - ...localPost, - is_liked: storePost.is_liked, - is_favorited: storePost.is_favorited, - likes_count: storePost.likes_count, - favorites_count: storePost.favorites_count, - }; - } - return localPost; - }); - - if (hasChanges) { - setPosts(updatedPosts); - } - } - - // 同步收藏列表状态 - if (favorites.length > 0) { - let hasChanges = false; - const updatedFavorites = favorites.map(localPost => { - const storePost = storePosts.find(sp => sp.id === localPost.id); - if (storePost && ( - storePost.is_liked !== localPost.is_liked || - storePost.is_favorited !== localPost.is_favorited || - storePost.likes_count !== localPost.likes_count || - storePost.favorites_count !== localPost.favorites_count - )) { - hasChanges = true; - return { - ...localPost, - is_liked: storePost.is_liked, - is_favorited: storePost.is_favorited, - likes_count: storePost.likes_count, - favorites_count: storePost.favorites_count, - }; - } - return localPost; - }); - - if (hasChanges) { - setFavorites(updatedFavorites); - } - } - }, [storePosts]); - - // 下拉刷新 - const onRefresh = useCallback(async () => { - setRefreshing(true); - try { - // 刷新用户信息 - await fetchCurrentUser(); - // 刷新帖子列表 - await loadUserPosts(); - } catch (error) { - console.error('刷新失败:', error); - } finally { - setRefreshing(false); - } - }, [fetchCurrentUser, loadUserPosts]); - - // 跳转到设置页 - const handleSettings = useCallback(() => { - navigation.navigate('Settings'); - }, [navigation]); - - // 跳转到编辑资料页 - const handleEditProfile = useCallback(() => { - navigation.navigate('EditProfile'); - }, [navigation]); - - // 关注/取消关注 - const handleFollow = useCallback(() => { - if (!currentUser) return; - if (currentUser.is_following) { - unfollowUser(currentUser.id); - } else { - followUser(currentUser.id); - } - }, [currentUser, unfollowUser, followUser]); - - // 跳转到帖子详情 - const handlePostPress = useCallback((postId: string, scrollToComments: boolean = false) => { - homeNavigation.navigate('PostDetail', { postId, scrollToComments }); - }, [homeNavigation]); - - // 跳转到用户主页(当前用户) - const handleUserPress = useCallback((userId: string) => { - // 个人主页点击自己的头像不跳转 - }, []); - - // 删除帖子 - const handleDeletePost = useCallback(async (postId: string) => { - try { - const success = await postService.deletePost(postId); - if (success) { - // 从帖子列表中移除 - setPosts(prev => prev.filter(p => p.id !== postId)); - // 也从收藏列表中移除(如果存在) - setFavorites(prev => prev.filter(p => p.id !== postId)); - } else { - console.error('删除帖子失败'); - } - } catch (error) { - console.error('删除帖子失败:', error); - throw error; - } - }, []); - - // 统一的 PostCard Action 处理 - const handlePostAction = useCallback((post: Post, action: PostCardAction) => { - switch (action.type) { - case 'press': - handlePostPress(post.id); - break; - case 'userPress': - if (post.author) { - handleUserPress(post.author.id); - } - break; - case 'like': - case 'unlike': - post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id); - break; - case 'comment': - handlePostPress(post.id, true); - break; - case 'bookmark': - case 'unbookmark': - post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id); - break; - case 'share': - // 暂不处理分享 - break; - case 'delete': - handleDeletePost(post.id); - break; - } - }, [handlePostPress, handleUserPress, handleDeletePost]); - - // 渲染内容 - const renderContent = useCallback(() => { - if (loading) return ; - - if (activeTab === 0) { - // 帖子 - if (posts.length === 0) { - return ( - - ); - } - - return ( - - {posts.map((post, index) => { - const isPostAuthor = currentUser?.id === post.author?.id; - return ( - - handlePostAction(post, action)} - isPostAuthor={isPostAuthor} - /> - - ); - })} - - ); - } - - if (activeTab === 1) { - // 收藏 - if (favorites.length === 0) { - return ( - - ); - } - - return ( - - {favorites.map((post, index) => { - const isPostAuthor = currentUser?.id === post.author?.id; - return ( - - handlePostPress(post.id)} - onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}} - onLike={() => post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id)} - onComment={() => handlePostPress(post.id, true)} - onBookmark={() => post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id)} - onShare={() => {}} - onDelete={() => handleDeletePost(post.id)} - isPostAuthor={isPostAuthor} - /> - - ); - })} - - ); - } - - return null; - }, [loading, activeTab, posts, favorites, currentUser?.id, handlePostPress, handleUserPress, handleDeletePost]); - - // 渲染用户信息头部 - 不随 tab 变化,使用 useMemo 缓存 - const renderUserHeader = useMemo(() => ( - - ), [currentUser, handleFollow, handleSettings, handleEditProfile, handleFollowingPress, handleFollowersPress]); - - // 渲染 TabBar 和内容 - const renderTabBarAndContent = useMemo(() => ( - <> - - - - - {renderContent()} - - - ), [activeTab, renderContent]); - - if (!currentUser) { - return ( - - - - ); - } - - // 桌面端使用双栏布局 - if (isDesktop || isTablet) { - return ( - - - - {/* 左侧:用户信息 */} - - - } - > - {renderUserHeader} - - - - {/* 右侧:帖子列表 */} - - - {renderTabBarAndContent} - - - - - - ); - } - - // 移动端使用单栏布局 - return ( - - - } - contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]} - > - {/* 用户信息头部 - 固定在顶部,不受 tab 切换影响 */} - {renderUserHeader} - - {/* TabBar - 分离出来,切换 tab 不会影响上面的用户信息 */} - {renderTabBarAndContent} - - - ); + return ; }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - scrollContent: { - flexGrow: 1, - }, - // 桌面端双栏布局 - desktopContainer: { - flex: 1, - flexDirection: 'row', - gap: spacing.lg, - padding: spacing.lg, - }, - desktopSidebar: { - width: 380, - flexShrink: 0, - }, - desktopContent: { - flex: 1, - minWidth: 0, - }, - desktopScrollContent: { - flexGrow: 1, - }, - tabBarContainer: { - marginTop: spacing.xs, - marginBottom: 2, - }, - contentContainer: { - flex: 1, - minHeight: 350, - paddingTop: spacing.xs, - }, - postsContainer: { - paddingHorizontal: spacing.md, - paddingTop: spacing.sm, - }, - postWrapper: { - marginBottom: spacing.md, - backgroundColor: colors.background.paper, - borderRadius: 16, - overflow: 'hidden', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.06, - shadowRadius: 8, - elevation: 2, - }, - lastPost: { - marginBottom: spacing['2xl'], - }, -}); - -export default ProfileScreen; +export default ProfileScreen; \ No newline at end of file diff --git a/src/screens/profile/UserProfileScreen.tsx b/src/screens/profile/UserProfileScreen.tsx new file mode 100644 index 0000000..b567d82 --- /dev/null +++ b/src/screens/profile/UserProfileScreen.tsx @@ -0,0 +1,235 @@ +/** + * 统一的用户主页组件 + * 胡萝卜BBS - 支持两种模式:self(当前用户)和 other(其他用户) + * 采用现代卡片式设计,优化视觉层次和交互体验 + * 支持桌面端双栏布局 + */ + +import React, { useCallback, useMemo } from 'react'; +import { + View, + RefreshControl, + ScrollView, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { colors } from '../../theme'; +import { Post } from '../../types'; +import { PostCard, TabBar, UserProfileHeader } from '../../components/business'; +import { Loading, EmptyState, ResponsiveContainer } from '../../components/common'; +import { useResponsive } from '../../hooks'; +import { ProfileStackParamList } from '../../navigation/types'; +import { useUserProfile, ProfileMode, TABS, TAB_ICONS, sharedStyles } from './useUserProfile'; + +interface UserProfileScreenProps { + mode: ProfileMode; + userId?: string; // 仅 other 模式需要 + profileNavigation?: NativeStackNavigationProp; // 仅 self 模式需要 +} + +export const UserProfileScreen: React.FC = ({ mode, userId, profileNavigation }) => { + const { isDesktop, isTablet } = useResponsive(); + + const { + user, + posts, + favorites, + loading, + refreshing, + activeTab, + setActiveTab, + scrollBottomInset, + onRefresh, + handleFollow, + handlePostAction, + handleFollowingPress, + handleFollowersPress, + handleMessage, + handleMore, + handleSettings, + handleEditProfile, + isCurrentUser, + currentUser, + } = useUserProfile({ mode, userId, isDesktop, isTablet, profileNavigation }); + + // 渲染帖子列表 + const renderPostList = useCallback((postList: Post[], emptyTitle: string, emptyDesc: string) => { + if (postList.length === 0) { + return ( + + ); + } + + return ( + + {postList.map((post, index) => { + const isPostAuthor = currentUser?.id === post.author?.id; + return ( + + handlePostAction(post, action)} + isPostAuthor={isPostAuthor} + /> + + ); + })} + + ); + }, [currentUser?.id, handlePostAction, activeTab]); + + // 渲染内容 + const renderContent = useCallback(() => { + if (loading) return ; + + if (activeTab === 0) { + return renderPostList( + posts, + mode === 'self' ? '还没有帖子' : '这个用户还没有发布任何帖子', + mode === 'self' ? '分享你的想法,发布第一条帖子吧' : '' + ); + } + + if (activeTab === 1) { + return renderPostList( + favorites, + mode === 'self' ? '还没有收藏' : '这个用户还没有收藏任何帖子', + mode === 'self' ? '发现喜欢的内容,点击收藏按钮保存' : '' + ); + } + + return null; + }, [loading, activeTab, posts, favorites, mode, renderPostList]); + + // 渲染用户信息头部 + const renderUserHeader = useMemo(() => { + if (!user) return null; + + return ( + + ); + }, [user, isCurrentUser, handleFollow, handleSettings, handleEditProfile, handleMessage, handleMore, handleFollowingPress, handleFollowersPress]); + + // 渲染 TabBar 和内容 + const renderTabBarAndContent = useMemo(() => ( + <> + + + + + {renderContent()} + + + ), [activeTab, renderContent]); + + // 未登录/用户不存在状态 + if (mode === 'self' && !currentUser) { + return ( + + + + ); + } + + if (mode === 'other' && !user && !loading) { + return ( + + + + ); + } + + // 桌面端使用双栏布局 + if (isDesktop || isTablet) { + return ( + + + + {/* 左侧:用户信息 */} + + + } + > + {renderUserHeader} + + + + {/* 右侧:帖子列表 */} + + + {renderTabBarAndContent} + + + + + + ); + } + + // 移动端使用单栏布局 + return ( + + + } + contentContainerStyle={[sharedStyles.scrollContent, { paddingBottom: scrollBottomInset }]} + > + {renderUserHeader} + {renderTabBarAndContent} + + + ); +}; + +export default UserProfileScreen; \ No newline at end of file diff --git a/src/screens/profile/UserScreen.tsx b/src/screens/profile/UserScreen.tsx index ffd5d17..358e450 100644 --- a/src/screens/profile/UserScreen.tsx +++ b/src/screens/profile/UserScreen.tsx @@ -1,578 +1,29 @@ /** - * 用户主页 UserScreen(响应式适配) + * 用户主页 UserScreen * 胡萝卜BBS - 查看其他用户资料 - * 支持桌面端双栏布局 + * 使用统一的 UserProfileScreen 组件,mode='other' */ -import React, { useState, useEffect, useCallback } from 'react'; -import { - View, - FlatList, - StyleSheet, - RefreshControl, - ScrollView, - Alert, -} from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { colors, spacing } from '../../theme'; -import { Post, User } from '../../types'; -import { useUserStore } from '../../stores'; +import React from 'react'; +import { useRoute, RouteProp } from '@react-navigation/native'; +import UserProfileScreen from './UserProfileScreen'; +import { HomeStackParamList } from '../../navigation/types'; import { useCurrentUser } from '../../stores/authStore'; -import { authService, postService, messageService } from '../../services'; -import { userManager } from '../../stores/userManager'; -import { UserProfileHeader, PostCard, TabBar } from '../../components/business'; -import { PostCardAction } from '../../components/business/PostCard'; -import { Loading, EmptyState, ResponsiveContainer } from '../../components/common'; -import { useResponsive } from '../../hooks'; -import { HomeStackParamList, RootStackParamList } from '../../navigation/types'; -type NavigationProp = NativeStackNavigationProp; type UserRouteProp = RouteProp; -const TABS = ['帖子', '收藏']; -const TAB_ICONS = ['file-document-outline', 'bookmark-outline']; - export const UserScreen: React.FC = () => { - const navigation = useNavigation(); const route = useRoute(); const userId = route.params?.userId || ''; - - // 使用 any 类型来访问根导航 - const rootNavigation = useNavigation>(); - - const { followUser, unfollowUser, posts: storePosts } = useUserStore(); const currentUser = useCurrentUser(); + const isSelfProfile = !!currentUser?.id && currentUser.id === userId; - // 响应式布局 - const { isDesktop, isTablet } = useResponsive(); - - const [user, setUser] = useState(null); - const [posts, setPosts] = useState([]); - const [favorites, setFavorites] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [activeTab, setActiveTab] = useState(0); - const [isBlocked, setIsBlocked] = useState(false); - - // 加载用户信息 - const loadUserData = useCallback(async (forceRefresh = false) => { - if (!userId) { - setLoading(false); - return; - } - - try { - // 强制从服务器获取最新数据,确保关注状态是最新的 - const userData = await userManager.getUserById(userId, forceRefresh); - setUser(userData || null); - const blockStatus = await authService.getBlockStatus(userId); - setIsBlocked(blockStatus); - - const response = await postService.getUserPosts(userId); - setPosts(response.list); - } catch (error) { - console.error('加载用户数据失败:', error); - } - setLoading(false); - }, [userId]); - - // 加载用户收藏 - const loadUserFavorites = useCallback(async () => { - if (!userId) return; - try { - const response = await postService.getUserFavorites(userId); - setFavorites(response.list); - } catch (error) { - console.error('获取用户收藏失败:', error); - } - }, [userId]); - - // 监听 tab 切换 - useEffect(() => { - if (activeTab === 1) { - loadUserFavorites(); - } - }, [activeTab, loadUserFavorites]); - - useEffect(() => { - // 首次加载时强制刷新,确保关注状态是最新的 - loadUserData(true); - }, [loadUserData]); - - // 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新) - useEffect(() => { - // 同步帖子列表状态 - if (posts.length > 0) { - let hasChanges = false; - const updatedPosts = posts.map(localPost => { - const storePost = storePosts.find(sp => sp.id === localPost.id); - if (storePost && ( - storePost.is_liked !== localPost.is_liked || - storePost.is_favorited !== localPost.is_favorited || - storePost.likes_count !== localPost.likes_count || - storePost.favorites_count !== localPost.favorites_count - )) { - hasChanges = true; - return { - ...localPost, - is_liked: storePost.is_liked, - is_favorited: storePost.is_favorited, - likes_count: storePost.likes_count, - favorites_count: storePost.favorites_count, - }; - } - return localPost; - }); - - if (hasChanges) { - setPosts(updatedPosts); - } - } - - // 同步收藏列表状态 - if (favorites.length > 0) { - let hasChanges = false; - const updatedFavorites = favorites.map(localPost => { - const storePost = storePosts.find(sp => sp.id === localPost.id); - if (storePost && ( - storePost.is_liked !== localPost.is_liked || - storePost.is_favorited !== localPost.is_favorited || - storePost.likes_count !== localPost.likes_count || - storePost.favorites_count !== localPost.favorites_count - )) { - hasChanges = true; - return { - ...localPost, - is_liked: storePost.is_liked, - is_favorited: storePost.is_favorited, - likes_count: storePost.likes_count, - favorites_count: storePost.favorites_count, - }; - } - return localPost; - }); - - if (hasChanges) { - setFavorites(updatedFavorites); - } - } - }, [storePosts]); - - // 下拉刷新 - const onRefresh = useCallback(() => { - setRefreshing(true); - loadUserData(true); - setRefreshing(false); - }, [loadUserData]); - - // 关注/取消关注 - const handleFollow = () => { - if (!user) return; - if (user.is_following) { - unfollowUser(user.id); - setUser({ ...user, is_following: false, followers_count: user.followers_count - 1 }); - } else { - followUser(user.id); - setUser({ ...user, is_following: true, followers_count: user.followers_count + 1 }); - } - }; - - // 跳转到关注列表 - const handleFollowingPress = () => { - (rootNavigation as any).navigate('FollowList', { userId, type: 'following' }); - }; - - // 跳转到粉丝列表 - const handleFollowersPress = () => { - (rootNavigation as any).navigate('FollowList', { userId, type: 'followers' }); - }; - - // 跳转到帖子详情 - const handlePostPress = (postId: string, scrollToComments: boolean = false) => { - navigation.navigate('PostDetail', { postId, scrollToComments }); - }; - - // 跳转到用户主页(这里不做处理) - const handleUserPress = (postUserId: string) => { - if (postUserId !== userId) { - navigation.push('UserProfile', { userId: postUserId }); - } - }; - - // 删除帖子 - const handleDeletePost = async (postId: string) => { - try { - const success = await postService.deletePost(postId); - if (success) { - // 从帖子列表中移除 - setPosts(prev => prev.filter(p => p.id !== postId)); - // 也从收藏列表中移除(如果存在) - setFavorites(prev => prev.filter(p => p.id !== postId)); - } else { - console.error('删除帖子失败'); - } - } catch (error) { - console.error('删除帖子失败:', error); - throw error; - } - }; - - // 统一处理 PostCard 的所有操作 - const handlePostAction = (post: Post, action: PostCardAction) => { - switch (action.type) { - case 'press': - handlePostPress(post.id); - break; - case 'userPress': - if (post.author) handleUserPress(post.author.id); - break; - case 'like': - postService.likePost(post.id); - break; - case 'unlike': - postService.unlikePost(post.id); - break; - case 'comment': - handlePostPress(post.id, true); - break; - case 'bookmark': - postService.favoritePost(post.id); - break; - case 'unbookmark': - postService.unfavoritePost(post.id); - break; - case 'share': - // 暂不处理 - break; - case 'delete': - handleDeletePost(post.id); - break; - } - }; - - // 跳转到聊天界面 - const handleMessage = async () => { - if (!user) return; - - try { - // 前端只提供对方的用户ID,会话ID由后端生成 - const conversation = await messageService.createConversation(user.id); - if (conversation) { - // 跳转到聊天界面 - 使用 rootNavigation 确保在正确的导航栈中 - (rootNavigation as any).navigate('Chat', { - conversationId: conversation.id.toString(), - userId: user.id - }); - } - } catch (error) { - console.error('创建会话失败:', error); - } - }; - - // 处理更多按钮点击 - const handleMore = () => { - if (!user) return; - - Alert.alert( - '更多操作', - undefined, - [ - { text: '取消', style: 'cancel' }, - { - text: isBlocked ? '取消拉黑' : '拉黑用户', - style: 'destructive', - onPress: () => { - Alert.alert( - isBlocked ? '确认取消拉黑' : '确认拉黑', - isBlocked - ? '取消拉黑后,对方可以重新与你建立关系。' - : '拉黑后,对方将无法给你发送私聊消息,且会互相移除关注关系。', - [ - { text: '取消', style: 'cancel' }, - { - text: '确定', - style: 'destructive', - onPress: async () => { - const ok = isBlocked - ? await authService.unblockUser(user.id) - : await authService.blockUser(user.id); - if (!ok) { - Alert.alert('失败', isBlocked ? '取消拉黑失败,请稍后重试' : '拉黑失败,请稍后重试'); - return; - } - setUser(prev => - prev - ? { - ...prev, - is_following: false, - is_following_me: false, - } - : prev - ); - setIsBlocked(!isBlocked); - Alert.alert('成功', isBlocked ? '已取消拉黑' : '已拉黑该用户'); - }, - }, - ] - ); - }, - }, - ] - ); - }; - - // 渲染内容 - const renderContent = () => { - if (loading) return ; - - if (activeTab === 0) { - // 帖子 - if (posts.length === 0) { - return ( - - ); - } - - return ( - - {posts.map((post, index) => { - const isPostAuthor = currentUser?.id === post.author?.id; - return ( - - handlePostAction(post, action)} - isPostAuthor={isPostAuthor} - /> - - ); - })} - - ); - } - - if (activeTab === 1) { - // 收藏 - if (favorites.length === 0) { - return ( - - ); - } - - return ( - - {favorites.map((post, index) => { - const isPostAuthor = currentUser?.id === post.author?.id; - return ( - - handlePostAction(post, action)} - isPostAuthor={isPostAuthor} - /> - - ); - })} - - ); - } - - return null; - }; - - // 渲染用户信息头部 - const renderUserHeader = () => { - if (!user) return null; - return ( - - ); - }; - - // 渲染 TabBar 和内容 - const renderTabBarAndContent = () => ( - <> - - - - - {renderContent()} - - - ); - - if (loading) { - return ; - } - - if (!user) { - return ( - - - - ); - } - - // 桌面端使用双栏布局 - if (isDesktop || isTablet) { - return ( - - - - {/* 左侧:用户信息 */} - - - } - > - {renderUserHeader()} - - - - {/* 右侧:帖子列表 */} - - - {renderTabBarAndContent()} - - - - - - ); - } - - // 移动端使用单栏布局 return ( - - ( - - {renderUserHeader()} - - - - - {renderContent()} - - - )} - keyExtractor={item => item.key} - showsVerticalScrollIndicator={false} - refreshControl={ - - } - /> - + ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - // 桌面端双栏布局 - desktopContainer: { - flex: 1, - flexDirection: 'row', - gap: spacing.lg, - padding: spacing.lg, - }, - desktopSidebar: { - width: 380, - flexShrink: 0, - }, - desktopContent: { - flex: 1, - minWidth: 0, - }, - desktopScrollContent: { - flexGrow: 1, - }, - tabBarContainer: { - marginTop: spacing.sm, - marginBottom: spacing.xs, - }, - contentContainer: { - flex: 1, - minHeight: 350, - paddingTop: spacing.sm, - }, - postsContainer: { - paddingHorizontal: spacing.md, - paddingTop: spacing.sm, - }, - postWrapper: { - marginBottom: spacing.md, - backgroundColor: colors.background.paper, - borderRadius: 16, - overflow: 'hidden', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.06, - shadowRadius: 8, - elevation: 2, - }, - lastPost: { - marginBottom: spacing['2xl'], - }, -}); - export default UserScreen; diff --git a/src/screens/profile/index.ts b/src/screens/profile/index.ts index ed6ffb5..a59ad38 100644 --- a/src/screens/profile/index.ts +++ b/src/screens/profile/index.ts @@ -2,6 +2,10 @@ * 个人中心模块导出 */ +// 统一的用户主页组件 +export { UserProfileScreen } from './UserProfileScreen'; + +// 兼容旧组件(内部使用 UserProfileScreen) export { ProfileScreen } from './ProfileScreen'; export { SettingsScreen } from './SettingsScreen'; export { EditProfileScreen } from './EditProfileScreen'; @@ -10,3 +14,7 @@ export { default as FollowListScreen } from './FollowListScreen'; export { NotificationSettingsScreen } from './NotificationSettingsScreen'; export { BlockedUsersScreen } from './BlockedUsersScreen'; export { AccountSecurityScreen } from './AccountSecurityScreen'; + +// 导出 Hook 供需要自定义的场景使用 +export { useUserProfile, sharedStyles, TABS, TAB_ICONS } from './useUserProfile'; +export type { ProfileMode, UseUserProfileOptions, UseUserProfileReturn } from './useUserProfile'; diff --git a/src/screens/profile/useUserProfile.ts b/src/screens/profile/useUserProfile.ts new file mode 100644 index 0000000..bacc757 --- /dev/null +++ b/src/screens/profile/useUserProfile.ts @@ -0,0 +1,504 @@ +/** + * 用户主页共享逻辑 Hook + * 提取 ProfileScreen 和 UserScreen 的共同逻辑 + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { colors, spacing } from '../../theme'; +import { Post, User } from '../../types'; +import { useUserStore } from '../../stores'; +import { useCurrentUser } from '../../stores/authStore'; +import { postService, authService, messageService } from '../../services'; +import { userManager } from '../../stores/userManager'; +import { HomeStackParamList, RootStackParamList, ProfileStackParamList } from '../../navigation/types'; +import { PostCardAction } from '../../components/business/PostCard'; + +export type ProfileMode = 'self' | 'other'; + +export interface UseUserProfileOptions { + mode: ProfileMode; + userId?: string; // 仅 other 模式需要 + isDesktop?: boolean; + isTablet?: boolean; + profileNavigation?: NativeStackNavigationProp; // 仅 self 模式需要 +} + +export interface UseUserProfileReturn { + // 用户数据 + user: User | null; + posts: Post[]; + favorites: Post[]; + loading: boolean; + refreshing: boolean; + isBlocked: boolean; + + // Tab 相关 + activeTab: number; + setActiveTab: (tab: number) => void; + + // 布局相关 + scrollBottomInset: number; + + // 操作 + onRefresh: () => Promise; + handleFollow: () => void; + handlePostPress: (postId: string, scrollToComments?: boolean) => void; + handleUserPress: (postUserId: string) => void; + handleDeletePost: (postId: string) => Promise; + handleFollowingPress: () => void; + handleFollowersPress: () => void; + handlePostAction: (post: Post, action: PostCardAction) => void; + + // 仅 other 模式 + handleMessage?: () => Promise; + handleMore?: () => void; + + // 仅 self 模式 + handleSettings?: () => void; + handleEditProfile?: () => void; + + // 判断 + isCurrentUser: boolean; + currentUser: User | null; +} + +export const useUserProfile = (options: UseUserProfileOptions): UseUserProfileReturn => { + const { mode, userId, isDesktop = false, profileNavigation } = options; + + const insets = useSafeAreaInsets(); + const homeNavigation = useNavigation>(); + const rootNavigation = useNavigation>(); + + const { followUser, unfollowUser, posts: storePosts } = useUserStore(); + const currentUser = useCurrentUser(); + + // 状态 + const [user, setUser] = useState(null); + const [posts, setPosts] = useState([]); + const [favorites, setFavorites] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [activeTab, setActiveTab] = useState(0); + const [isBlocked, setIsBlocked] = useState(false); + + // 获取其他用户数据(other 模式) + const loadOtherUserData = useCallback(async (forceRefresh = false) => { + if (mode !== 'other' || !userId) { + setLoading(false); + return null; + } + + try { + const userData = await userManager.getUserById(userId, forceRefresh); + setUser(userData || null); + const blockStatus = await authService.getBlockStatus(userId); + setIsBlocked(blockStatus); + + const response = await postService.getUserPosts(userId); + setPosts(response.list); + + return userData; + } catch (error) { + console.error('加载用户数据失败:', error); + return null; + } finally { + setLoading(false); + } + }, [mode, userId]); + + // 加载当前用户的帖子(self 模式) + const loadSelfPosts = useCallback(async () => { + if (mode !== 'self' || !currentUser) { + setLoading(false); + return; + } + + try { + const response = await postService.getUserPosts(currentUser.id); + setPosts(response.list); + } catch (error) { + console.error('获取用户帖子失败:', error); + } + setLoading(false); + }, [mode, currentUser]); + + // 加载收藏 + const loadFavorites = useCallback(async () => { + const targetUserId = mode === 'self' ? currentUser?.id : userId; + if (!targetUserId) return; + + try { + const response = await postService.getUserFavorites(targetUserId); + setFavorites(response.list); + } catch (error) { + console.error('获取用户收藏失败:', error); + } + }, [mode, currentUser?.id, userId]); + + // 初始化加载 + useEffect(() => { + if (mode === 'self') { + loadSelfPosts(); + } else { + loadOtherUserData(true); + } + }, [mode, loadSelfPosts, loadOtherUserData]); + + // Tab 切换加载收藏 + useEffect(() => { + if (activeTab === 1 && favorites.length === 0) { + loadFavorites(); + } + }, [activeTab, favorites.length, loadFavorites]); + + // 同步 store 中的帖子状态 + useEffect(() => { + if (posts.length > 0) { + let hasChanges = false; + const updatedPosts = posts.map(localPost => { + const storePost = storePosts.find(sp => sp.id === localPost.id); + if (storePost && ( + storePost.is_liked !== localPost.is_liked || + storePost.is_favorited !== localPost.is_favorited || + storePost.likes_count !== localPost.likes_count || + storePost.favorites_count !== localPost.favorites_count + )) { + hasChanges = true; + return { + ...localPost, + is_liked: storePost.is_liked, + is_favorited: storePost.is_favorited, + likes_count: storePost.likes_count, + favorites_count: storePost.favorites_count, + }; + } + return localPost; + }); + + if (hasChanges) { + setPosts(updatedPosts); + } + } + + if (favorites.length > 0) { + let hasChanges = false; + const updatedFavorites = favorites.map(localPost => { + const storePost = storePosts.find(sp => sp.id === localPost.id); + if (storePost && ( + storePost.is_liked !== localPost.is_liked || + storePost.is_favorited !== localPost.is_favorited || + storePost.likes_count !== localPost.likes_count || + storePost.favorites_count !== localPost.favorites_count + )) { + hasChanges = true; + return { + ...localPost, + is_liked: storePost.is_liked, + is_favorited: storePost.is_favorited, + likes_count: storePost.likes_count, + favorites_count: storePost.favorites_count, + }; + } + return localPost; + }); + + if (hasChanges) { + setFavorites(updatedFavorites); + } + } + }, [storePosts]); + + // 下拉刷新 + const onRefresh = useCallback(async () => { + setRefreshing(true); + try { + if (mode === 'self') { + const { useAuthStore } = await import('../../stores'); + const { fetchCurrentUser } = useAuthStore.getState(); + await fetchCurrentUser(); + await loadSelfPosts(); + } else { + await loadOtherUserData(true); + } + } catch (error) { + console.error('刷新失败:', error); + } finally { + setRefreshing(false); + } + }, [mode, loadSelfPosts, loadOtherUserData]); + + // 关注/取消关注 + const handleFollow = useCallback(() => { + const targetUser = mode === 'self' ? currentUser : user; + if (!targetUser) return; + + if (targetUser.is_following) { + unfollowUser(targetUser.id); + if (mode === 'other') { + setUser({ ...targetUser, is_following: false, followers_count: targetUser.followers_count - 1 }); + } + } else { + followUser(targetUser.id); + if (mode === 'other') { + setUser({ ...targetUser, is_following: true, followers_count: targetUser.followers_count + 1 }); + } + } + }, [mode, currentUser, user, unfollowUser, followUser]); + + // 跳转到关注列表 + const handleFollowingPress = useCallback(() => { + const targetUserId = mode === 'self' ? currentUser?.id : userId; + if (targetUserId) { + (rootNavigation as any).navigate('FollowList', { userId: targetUserId, type: 'following' }); + } + }, [mode, currentUser?.id, userId, rootNavigation]); + + // 跳转到粉丝列表 + const handleFollowersPress = useCallback(() => { + const targetUserId = mode === 'self' ? currentUser?.id : userId; + if (targetUserId) { + (rootNavigation as any).navigate('FollowList', { userId: targetUserId, type: 'followers' }); + } + }, [mode, currentUser?.id, userId, rootNavigation]); + + // 跳转到帖子详情 + const handlePostPress = useCallback((postId: string, scrollToComments: boolean = false) => { + homeNavigation.navigate('PostDetail', { postId, scrollToComments }); + }, [homeNavigation]); + + // 跳转到用户主页 + const handleUserPress = useCallback((postUserId: string) => { + if (mode === 'self') { + return; + } + if (postUserId !== userId) { + homeNavigation.push('UserProfile', { userId: postUserId }); + } + }, [mode, userId, homeNavigation]); + + // 删除帖子 + const handleDeletePost = useCallback(async (postId: string) => { + try { + const success = await postService.deletePost(postId); + if (success) { + setPosts(prev => prev.filter(p => p.id !== postId)); + setFavorites(prev => prev.filter(p => p.id !== postId)); + } else { + console.error('删除帖子失败'); + } + } catch (error) { + console.error('删除帖子失败:', error); + throw error; + } + }, []); + + // 统一的 PostCard Action 处理 + const handlePostAction = useCallback((post: Post, action: PostCardAction) => { + switch (action.type) { + case 'press': + handlePostPress(post.id); + break; + case 'userPress': + if (post.author) { + handleUserPress(post.author.id); + } + break; + case 'like': + case 'unlike': + post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id); + break; + case 'comment': + handlePostPress(post.id, true); + break; + case 'bookmark': + case 'unbookmark': + post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id); + break; + case 'share': + break; + case 'delete': + handleDeletePost(post.id); + break; + } + }, [handlePostPress, handleUserPress, handleDeletePost]); + + // 发消息(仅 other 模式) + const handleMessage = useCallback(async () => { + if (!user) return; + + try { + const conversation = await messageService.createConversation(user.id); + if (conversation) { + (rootNavigation as any).navigate('Chat', { + conversationId: conversation.id.toString(), + userId: user.id + }); + } + } catch (error) { + console.error('创建会话失败:', error); + } + }, [user, rootNavigation]); + + // 更多操作(仅 other 模式) + const handleMore = useCallback(() => { + if (!user) return; + + const { Alert } = require('react-native'); + Alert.alert( + '更多操作', + undefined, + [ + { text: '取消', style: 'cancel' }, + { + text: isBlocked ? '取消拉黑' : '拉黑用户', + style: 'destructive', + onPress: () => { + Alert.alert( + isBlocked ? '确认取消拉黑' : '确认拉黑', + isBlocked + ? '取消拉黑后,对方可以重新与你建立关系。' + : '拉黑后,对方将无法给你发送私聊消息,且会互相移除关注关系。', + [ + { text: '取消', style: 'cancel' }, + { + text: '确定', + style: 'destructive', + onPress: async () => { + const ok = isBlocked + ? await authService.unblockUser(user.id) + : await authService.blockUser(user.id); + if (!ok) { + Alert.alert('失败', isBlocked ? '取消拉黑失败,请稍后重试' : '拉黑失败,请稍后重试'); + return; + } + setUser(prev => + prev + ? { ...prev, is_following: false, is_following_me: false } + : prev + ); + setIsBlocked(!isBlocked); + Alert.alert('成功', isBlocked ? '已取消拉黑' : '已拉黑该用户'); + }, + }, + ] + ); + }, + }, + ] + ); + }, [user, isBlocked]); + + // 设置页跳转(仅 self 模式) + const handleSettings = useCallback(() => { + if (profileNavigation) { + profileNavigation.navigate('Settings'); + } + }, [profileNavigation]); + + // 编辑资料页跳转(仅 self 模式) + const handleEditProfile = useCallback(() => { + if (profileNavigation) { + profileNavigation.navigate('EditProfile'); + } + }, [profileNavigation]); + + // 计算滚动底部间距 + const scrollBottomInset = useMemo(() => { + if (isDesktop) return spacing.lg; + const FLOATING_TAB_BAR_HEIGHT = 64 + 24; + return FLOATING_TAB_BAR_HEIGHT + insets.bottom + spacing.md; + }, [isDesktop, insets.bottom]); + + // 获取显示的用户 + const displayUser = mode === 'self' ? currentUser : user; + + return { + user: displayUser, + posts, + favorites, + loading, + refreshing, + isBlocked, + activeTab, + setActiveTab, + scrollBottomInset, + onRefresh, + handleFollow, + handlePostPress, + handleUserPress, + handleDeletePost, + handleFollowingPress, + handleFollowersPress, + handlePostAction, + handleMessage: mode === 'other' ? handleMessage : undefined, + handleMore: mode === 'other' ? handleMore : undefined, + handleSettings: mode === 'self' && profileNavigation ? handleSettings : undefined, + handleEditProfile: mode === 'self' && profileNavigation ? handleEditProfile : undefined, + isCurrentUser: mode === 'self', + currentUser, + }; +}; + +// 共享的样式 +import { StyleSheet } from 'react-native'; + +export const sharedStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + scrollContent: { + flexGrow: 1, + }, + desktopContainer: { + flex: 1, + flexDirection: 'row', + gap: spacing.lg, + padding: spacing.lg, + }, + desktopSidebar: { + width: 380, + flexShrink: 0, + }, + desktopContent: { + flex: 1, + minWidth: 0, + }, + desktopScrollContent: { + flexGrow: 1, + }, + tabBarContainer: { + marginTop: spacing.xs, + marginBottom: 2, + }, + contentContainer: { + flex: 1, + minHeight: 350, + paddingTop: spacing.xs, + }, + postsContainer: { + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + }, + postWrapper: { + marginBottom: spacing.md, + backgroundColor: colors.background.paper, + borderRadius: 16, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.06, + shadowRadius: 8, + elevation: 2, + }, + lastPost: { + marginBottom: spacing['2xl'], + }, +}); + +// Tab 配置 +export const TABS = ['帖子', '收藏']; +export const TAB_ICONS = ['file-document-outline', 'bookmark-outline']; diff --git a/src/services/sseService.ts b/src/services/sseService.ts index 867c7d1..438bd62 100644 --- a/src/services/sseService.ts +++ b/src/services/sseService.ts @@ -172,22 +172,28 @@ interface SSEEnvelope { class SSEService { private source: EventSource | null = null; private isConnecting = false; + private connected = false; private reconnectAttempts = 0; private maxReconnectAttempts = 20; private reconnectDelay = 3000; private reconnectTimer: NodeJS.Timeout | null = null; + private connectionWatchdogTimer: NodeJS.Timeout | null = null; + private readonly CONNECTION_IDLE_TIMEOUT_MS = 70000; private messageHandlers: Map = new Map(); private connectionHandlers: ConnectionHandler[] = []; private disconnectionHandlers: ConnectionHandler[] = []; private appStateSubscription: any = null; private lastAppState: AppStateStatus = 'active'; private lastEventId = ''; + private lastActivityAt = 0; + private shouldRun = false; private toSSEUrl(): string { return `${SSE_URL}?last_event_id=${encodeURIComponent(this.lastEventId)}`; } async connect(): Promise { + this.shouldRun = true; if (this.isConnecting || this.isConnected()) return true; this.isConnecting = true; try { @@ -201,18 +207,31 @@ class SSEService { headers: { Authorization: `Bearer ${token}`, }, + // Use one reconnect strategy only (manual), avoid library auto-reconnect fighting with us. + pollingInterval: 0, + // Be explicit to prevent platform-level short idle timeout behavior. + timeout: this.CONNECTION_IDLE_TIMEOUT_MS, }); this.source.addEventListener('open', () => { this.isConnecting = false; + this.connected = true; this.reconnectAttempts = 0; + this.markActivity(); + this.startConnectionWatchdog(); this.connectionHandlers.forEach(h => h()); }); this.source.addEventListener('error', () => { - this.isConnecting = false; - this.disconnectionHandlers.forEach(h => h()); - this.scheduleReconnect(); + this.handleDisconnected(); + }); + + this.source.addEventListener('done' as any, () => { + this.handleDisconnected(); + }); + + this.source.addEventListener('close' as any, () => { + this.handleDisconnected(); }); const events = ['chat_message', 'message_read', 'typing', 'system_notification', 'group_notice', 'message_recall', 'heartbeat']; @@ -229,6 +248,7 @@ class SSEService { } private handleIncoming(eventName: string, evt: any): void { + this.markActivity(); const rawData = typeof evt?.data === 'string' ? evt.data : '{}'; const lastEventId = evt?.lastEventId; if (lastEventId) { @@ -375,10 +395,14 @@ class SSEService { } disconnect(): void { + this.shouldRun = false; + this.connected = false; + this.isConnecting = false; if (this.source) { this.source.close(); this.source = null; } + this.stopConnectionWatchdog(); this.stopReconnect(); } @@ -399,7 +423,7 @@ class SSEService { } isConnected(): boolean { - return this.source != null; + return this.connected; } on(type: T, handler: MessageHandler>): () => void { @@ -453,6 +477,45 @@ class SSEService { } this.disconnect(); } + + private markActivity(): void { + this.lastActivityAt = Date.now(); + } + + private startConnectionWatchdog(): void { + this.stopConnectionWatchdog(); + this.connectionWatchdogTimer = setInterval(() => { + // During active app usage, if no SSE activity for too long, reconnect proactively. + if (!this.connected || this.lastActivityAt <= 0) return; + if (Date.now() - this.lastActivityAt > this.CONNECTION_IDLE_TIMEOUT_MS) { + this.handleDisconnected(); + } + }, 10000); + } + + private stopConnectionWatchdog(): void { + if (this.connectionWatchdogTimer) { + clearInterval(this.connectionWatchdogTimer); + this.connectionWatchdogTimer = null; + } + } + + private handleDisconnected(): void { + const wasActive = this.connected || this.source != null || this.isConnecting; + this.isConnecting = false; + this.connected = false; + if (this.source) { + this.source.close(); + this.source = null; + } + this.stopConnectionWatchdog(); + if (wasActive) { + this.disconnectionHandlers.forEach(h => h()); + } + if (this.shouldRun) { + this.scheduleReconnect(); + } + } } export const sseService = new SSEService(); diff --git a/src/stores/messageManager.ts b/src/stores/messageManager.ts index e95c4d9..70e88c5 100644 --- a/src/stores/messageManager.ts +++ b/src/stores/messageManager.ts @@ -165,6 +165,8 @@ class MessageManager { /** 正在加载会话列表下一页 */ private loadingMoreConversations = false; + /** 聊天页活跃期间延迟的会话列表刷新 */ + private deferredConversationRefresh = false; /** 远端会话列表(游标或页码,由注入/remoteListKind 决定) */ private readonly remoteConversationListSource: IConversationListPagedSource; @@ -274,6 +276,32 @@ class MessageManager { }); } + private shouldDeferConversationRefresh(source: string, forceRefresh: boolean): boolean { + if (!this.state.currentConversationId) return false; + if (!forceRefresh) return false; + // 列表域后台刷新来源:聊天活跃期间一律延后执行 + return source === 'sse-reconnect' || source === 'prefetch' || source === 'hooks-initial-refresh'; + } + + async requestConversationListRefresh( + source: string, + options?: { force?: boolean; allowDefer?: boolean } + ): Promise { + const forceRefresh = options?.force ?? true; + const allowDefer = options?.allowDefer ?? true; + if (allowDefer && this.shouldDeferConversationRefresh(source, forceRefresh)) { + this.deferredConversationRefresh = true; + if (__DEV__) { + console.log('[MessageManager] defer conversation refresh request', { + source, + activeConversationId: this.state.currentConversationId, + }); + } + return; + } + await this.fetchConversations(forceRefresh, source); + } + /** * 按 message_id 合并消息并按 seq 排序。 * 后者覆盖前者,可用来吸收更完整的同 ID 消息体(例如服务端回包/SSE)。 @@ -430,7 +458,7 @@ class MessageManager { this.initSSEListeners(); // 3. 加载会话列表 - await this.fetchConversations(); + await this.fetchConversations(false, 'initialize'); // 4. 加载未读数 await this.fetchUnreadCount(); @@ -539,9 +567,16 @@ class MessageManager { const now = Date.now(); if (now - this.lastReconnectSyncAt > 1500) { this.lastReconnectSyncAt = now; - this.fetchConversations(true).catch(error => { - console.error('[MessageManager] 连接后同步会话失败:', error); - }); + // 聊天页活跃时不强刷会话列表,避免触发不必要的 UI 抖动/重算 + if (!this.state.currentConversationId) { + this.requestConversationListRefresh('sse-reconnect', { force: true, allowDefer: true }).catch(error => { + console.error('[MessageManager] 连接后同步会话失败:', error); + }); + } else { + this.fetchUnreadCount().catch(error => { + console.error('[MessageManager] 连接后同步未读数失败:', error); + }); + } if (this.state.currentConversationId) { this.fetchMessages(this.state.currentConversationId).catch(error => { console.error('[MessageManager] 连接后同步当前会话消息失败:', error); @@ -1146,11 +1181,30 @@ class MessageManager { /** * 获取会话列表(首屏/下拉刷新:优先网络游标;可回退 SQLite,对 UI 透明) */ - async fetchConversations(forceRefresh = false): Promise { + async fetchConversations(forceRefresh = false, source: string = 'unknown'): Promise { if (this.state.isLoadingConversations && !forceRefresh) { return; } + if (this.shouldDeferConversationRefresh(source, forceRefresh)) { + this.deferredConversationRefresh = true; + if (__DEV__) { + console.log('[MessageManager] defer fetchConversations', { + source, + activeConversationId: this.state.currentConversationId, + }); + } + return; + } + + if (__DEV__) { + console.log('[MessageManager] fetchConversations', { + source, + forceRefresh, + activeConversationId: this.state.currentConversationId, + }); + } + // 非强制刷新且内存为空:先用本地源暖机,便于离线/弱网先展示列表 if (!forceRefresh && this.state.conversations.size === 0) { const warmed = await this.hydrateConversationsFromLocalSource(); @@ -1304,21 +1358,29 @@ class MessageManager { const mergeMessages = (base: MessageResponse[], incoming: MessageResponse[]): MessageResponse[] => { return this.mergeMessagesById(base, incoming); }; + const existingMessagesAtStart = this.state.messagesMap.get(conversationId) || []; + const hasInMemoryMessages = existingMessagesAtStart.length > 0; + let baselineMaxSeq = hasInMemoryMessages + ? existingMessagesAtStart.reduce((max, m) => Math.max(max, m.seq || 0), 0) + : 0; // 1. 先从本地数据库加载(确保立即有数据展示) if (!afterSeq) { let localMessages: any[] = []; let localMaxSeq = 0; - try { - localMessages = await getMessagesByConversation(conversationId, 20); - localMaxSeq = await getMaxSeq(conversationId); - } catch (error) { - // 架构原则:本地存储异常不阻断在线同步 - console.warn('[MessageManager] 读取本地消息失败,回退到服务端同步:', error); + if (!hasInMemoryMessages) { + try { + localMessages = await getMessagesByConversation(conversationId, 20); + localMaxSeq = await getMaxSeq(conversationId); + baselineMaxSeq = localMaxSeq; + } catch (error) { + // 架构原则:本地存储异常不阻断在线同步 + console.warn('[MessageManager] 读取本地消息失败,回退到服务端同步:', error); + } } - if (localMessages.length > 0) { + if (!hasInMemoryMessages && localMessages.length > 0) { // 转换格式 const formattedMessages: MessageResponse[] = localMessages.map(m => ({ id: m.id, @@ -1343,7 +1405,7 @@ class MessageManager { }); // 异步填充 sender 信息 this.enrichMessagesWithSenderInfo(conversationId, formattedMessages); - } else { + } else if (!hasInMemoryMessages) { // 冷启动兜底:先下发空列表事件,避免 ChatScreen 长时间停留在 loading this.state.messagesMap.set(conversationId, []); this.notifySubscribers({ @@ -1434,8 +1496,8 @@ class MessageManager { // 2.2 再基于 localMaxSeq 做增量补齐(防止快照窗口不足导致漏更老的新消息) const snapshotMaxSeq = snapshotMessages.reduce((max, m) => Math.max(max, m.seq || 0), 0); - if (snapshotMaxSeq > localMaxSeq) { - const incrementalResp = await messageService.getMessages(conversationId, localMaxSeq); + if (snapshotMaxSeq > baselineMaxSeq) { + const incrementalResp = await messageService.getMessages(conversationId, baselineMaxSeq); const newMessages = incrementalResp?.messages || []; if (newMessages.length > 0) { const existingMessages = this.state.messagesMap.get(conversationId) || []; @@ -2226,6 +2288,12 @@ class MessageManager { setActiveConversation(conversationId: string | null): void { const normalizedConversationId = conversationId ? this.normalizeConversationId(conversationId) : null; this.state.currentConversationId = normalizedConversationId; + if (!normalizedConversationId && this.deferredConversationRefresh) { + this.deferredConversationRefresh = false; + this.requestConversationListRefresh('deferred-after-chat', { force: true, allowDefer: false }).catch(error => { + console.error('[MessageManager] 延迟会话刷新失败:', error); + }); + } } /** diff --git a/src/stores/messageManagerHooks.ts b/src/stores/messageManagerHooks.ts index 3f65986..9f455ea 100644 --- a/src/stores/messageManagerHooks.ts +++ b/src/stores/messageManagerHooks.ts @@ -55,7 +55,10 @@ export function useConversations(): UseConversationsReturn { // 冷启动兜底:延迟做一次强制刷新,避免首次因时序问题拿到空列表 const coldStartSyncTimer = setTimeout(() => { - messageManager.fetchConversations(true).catch(error => { + messageManager.requestConversationListRefresh('hooks-initial-refresh', { + force: true, + allowDefer: true, + }).catch(error => { console.error('[useConversations] 冷启动强制刷新失败:', error); }); }, 1200); @@ -67,7 +70,10 @@ export function useConversations(): UseConversationsReturn { }, []); const refresh = useCallback(async () => { - await messageManager.fetchConversations(true); + await messageManager.requestConversationListRefresh('hooks-manual-refresh', { + force: true, + allowDefer: false, + }); }, []); const loadMore = useCallback(async () => { @@ -530,7 +536,10 @@ export function useMessageListRefresh(): UseMessageListRefreshReturn { setIsRefreshing(true); try { - await messageManager.fetchConversations(true); + await messageManager.requestConversationListRefresh('hooks-load-more-fallback', { + force: true, + allowDefer: false, + }); } catch (error) { console.error('[useMessageListRefresh] 刷新失败:', error); } finally { From 48339384d2285b2a6702a0f305bcb4a1f9e5df28 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Tue, 24 Mar 2026 05:18:22 +0800 Subject: [PATCH 22/36] refactor(ImageGallery, HomeScreen, PostService, UserStore): streamline post type handling and improve tab navigation - Updated ImageGallery to manage loading states more effectively and prevent unnecessary updates. - Refactored HomeScreen to remove the 'recommend' post type, simplifying the post type options. - Adjusted PostService and UserStore to eliminate references to 'recommend', ensuring consistency across the application. - Enhanced tab navigation in SimpleMobileTabNavigator to optimize rendering and manage mounted tabs more efficiently. - Improved error handling and loading states in various components for better user experience. --- src/components/common/ImageGallery.tsx | 16 +++++-- .../interfaces/IPostRepository.ts | 4 +- src/hooks/usePrefetch.ts | 6 +-- src/navigation/SimpleMobileTabNavigator.tsx | 47 +++++++++++++++++-- src/screens/home/HomeScreen.tsx | 11 ++--- src/services/postService.ts | 7 +-- src/stores/postManager.ts | 2 +- src/stores/userStore.ts | 9 ++-- src/types/dto.ts | 4 +- 9 files changed, 72 insertions(+), 34 deletions(-) diff --git a/src/components/common/ImageGallery.tsx b/src/components/common/ImageGallery.tsx index 8e4aaf3..ad71b5c 100644 --- a/src/components/common/ImageGallery.tsx +++ b/src/components/common/ImageGallery.tsx @@ -86,7 +86,9 @@ export const ImageGallery: React.FC = ({ }) => { const closeTimerRef = useRef | null>(null); const isClosingRef = useRef(false); + const currentIndexRef = useRef(initialIndex); const [currentIndex, setCurrentIndex] = useState(initialIndex); + currentIndexRef.current = currentIndex; const [showControls, setShowControls] = useState(true); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); @@ -152,16 +154,19 @@ export const ImageGallery: React.FC = ({ }; }, []); - // 图片变化时重置加载状态和缩放 + // 图片变化时重置缩放(加载态在 updateIndex / 打开弹窗时同步设置,避免晚一帧仍显示上一张) useEffect(() => { - setLoading(true); - setError(false); resetZoom(); }, [currentImage?.id, resetZoom]); const updateIndex = useCallback( (newIndex: number) => { const clampedIndex = Math.max(0, Math.min(validImages.length - 1, newIndex)); + if (clampedIndex === currentIndexRef.current) { + return; + } + setLoading(true); + setError(false); setCurrentIndex(clampedIndex); onIndexChange?.(clampedIndex); }, @@ -412,7 +417,8 @@ export const ImageGallery: React.FC = ({ contentFit="contain" cachePolicy="disk" priority="high" - recyclingKey={currentImage.id} + recyclingKey={currentImage.url} + transition={null} allowDownscaling onLoadStart={() => setLoading(true)} onLoad={() => { @@ -556,11 +562,13 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', zIndex: 5, + backgroundColor: '#000', }, errorContainer: { ...StyleSheet.absoluteFillObject, justifyContent: 'center', alignItems: 'center', + backgroundColor: '#000', }, errorText: { color: '#999', diff --git a/src/data/repositories/interfaces/IPostRepository.ts b/src/data/repositories/interfaces/IPostRepository.ts index c1dc72f..eb81a14 100644 --- a/src/data/repositories/interfaces/IPostRepository.ts +++ b/src/data/repositories/interfaces/IPostRepository.ts @@ -17,8 +17,8 @@ export interface GetPostsParams { pageSize?: number; /** 游标 - 用于无限滚动加载 */ cursor?: string; - /** 帖子类型筛选(可选):recommend, follow, hot, latest */ - post_type?: 'recommend' | 'follow' | 'hot' | 'latest'; + /** 帖子类型筛选(可选):follow, hot, latest */ + post_type?: 'follow' | 'hot' | 'latest'; /** 社区ID过滤 */ communityId?: string; /** 作者ID过滤 */ diff --git a/src/hooks/usePrefetch.ts b/src/hooks/usePrefetch.ts index ffe0556..539d65b 100644 --- a/src/hooks/usePrefetch.ts +++ b/src/hooks/usePrefetch.ts @@ -134,7 +134,7 @@ const prefetchService = new PrefetchService(); * 预取帖子数据 * @param types 帖子类型数组 */ -function prefetchPosts(types: string[] = ['recommend', 'hot']): void { +function prefetchPosts(types: string[] = ['latest', 'hot']): void { types.forEach((type, index) => { prefetchService.schedule({ key: `posts:${type}:1`, @@ -230,7 +230,7 @@ function prefetchOnAppLaunch(): void { prefetchConversations(); // 中优先级:帖子列表 - prefetchPosts(['recommend']); + prefetchPosts(['latest']); } /** @@ -257,7 +257,7 @@ function prefetchMessageScreen(): void { * 进入首页时预取 */ function prefetchHomeScreen(): void { - prefetchPosts(['recommend', 'hot', 'latest']); + prefetchPosts(['latest', 'hot', 'follow']); } // ==================== React Hook ==================== diff --git a/src/navigation/SimpleMobileTabNavigator.tsx b/src/navigation/SimpleMobileTabNavigator.tsx index 6d81c2f..da32915 100644 --- a/src/navigation/SimpleMobileTabNavigator.tsx +++ b/src/navigation/SimpleMobileTabNavigator.tsx @@ -178,6 +178,12 @@ export function SimpleMobileTabNavigator() { const insets = useSafeAreaInsets(); const messageUnreadCount = useTotalUnreadCount(); const [activeTab, setActiveTab] = useState('HomeTab'); + const [mountedTabs, setMountedTabs] = useState>({ + HomeTab: true, + MessageTab: false, + ScheduleTab: false, + ProfileTab: false, + }); // 初始化 MessageManager useEffect(() => { @@ -186,12 +192,21 @@ export function SimpleMobileTabNavigator() { // 处理 Tab 切换 const handleTabPress = useCallback((tabName: TabName) => { + setMountedTabs((prev) => { + if (prev[tabName]) { + return prev; + } + return { + ...prev, + [tabName]: true, + }; + }); setActiveTab(tabName); }, []); - // 渲染当前 Tab 的内容 - const renderTabContent = () => { - switch (activeTab) { + // 根据 Tab 名称渲染对应内容 + const renderTabView = (tabName: TabName) => { + switch (tabName) { case 'HomeTab': return ; case 'MessageTab': @@ -276,7 +291,24 @@ export function SimpleMobileTabNavigator() { {/* 内容区域 */} - {renderTabContent()} + {(Object.keys(mountedTabs) as TabName[]).map((tabName) => { + if (!mountedTabs[tabName]) { + return null; + } + const isActive = activeTab === tabName; + return ( + + {renderTabView(tabName)} + + ); + })} {/* Tab Bar */} @@ -334,6 +366,13 @@ const styles = StyleSheet.create({ }, content: { flex: 1, + position: 'relative', + }, + tabScreen: { + ...StyleSheet.absoluteFillObject, + }, + tabScreenHidden: { + display: 'none', }, bottomSpacer: { backgroundColor: 'transparent', diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 7d7d067..7410251 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -41,8 +41,8 @@ import { navigationService } from '../../infrastructure/navigation/navigationSer type NavigationProp = NativeStackNavigationProp & NativeStackNavigationProp; -const TABS = ['推荐', '关注', '热门', '最新']; -const TAB_ICONS = ['compass-outline', 'account-heart-outline', 'fire', 'clock-outline']; +const TABS = ['最新', '关注', '热门']; +const TAB_ICONS = ['clock-outline', 'account-heart-outline', 'fire']; const DEFAULT_PAGE_SIZE = 20; const SWIPE_TRANSLATION_THRESHOLD = 40; const SWIPE_COOLDOWN_MS = 300; @@ -51,7 +51,7 @@ const MOBILE_TAB_FLOATING_MARGIN = 12; const MOBILE_FAB_GAP = 12; type ViewMode = 'list' | 'grid'; -type PostType = 'recommend' | 'follow' | 'hot' | 'latest'; +type PostType = 'follow' | 'hot' | 'latest'; export const HomeScreen: React.FC = () => { const navigation = useNavigation(); @@ -106,11 +106,10 @@ export const HomeScreen: React.FC = () => { // 获取当前 tab 对应的帖子类型 const getPostType = useCallback((): PostType => { switch (activeIndex) { - case 0: return 'recommend'; + case 0: return 'latest'; case 1: return 'follow'; case 2: return 'hot'; - case 3: return 'latest'; - default: return 'recommend'; + default: return 'latest'; } }, [activeIndex]); diff --git a/src/services/postService.ts b/src/services/postService.ts index 9445c60..71a3430 100644 --- a/src/services/postService.ts +++ b/src/services/postService.ts @@ -63,11 +63,6 @@ class PostService { return response.data; } - // 获取推荐帖子 - async getRecommendedPosts(page = 1, pageSize = 20): Promise> { - return this.getPosts(page, pageSize, 'recommend'); - } - // 获取关注帖子 async getFollowingPosts(page = 1, pageSize = 20): Promise> { return this.getPosts(page, pageSize, 'follow'); @@ -282,7 +277,7 @@ class PostService { /** * 获取帖子列表(游标分页) * GET /api/v1/posts - * @param params 游标分页请求参数(包含 post_type 可选:recommend, follow, hot, latest) + * @param params 游标分页请求参数(包含 post_type 可选:follow, hot, latest) */ async getPostsCursor( params: CursorPaginationRequest = {} diff --git a/src/stores/postManager.ts b/src/stores/postManager.ts index 9dc51e5..35338dd 100644 --- a/src/stores/postManager.ts +++ b/src/stores/postManager.ts @@ -63,7 +63,7 @@ class PostManager extends CacheBus { } async getPosts( - type = 'recommend', + type = 'hot', page = 1, pageSize = 20, forceRefresh = false diff --git a/src/stores/userStore.ts b/src/stores/userStore.ts index d5e9131..17f1985 100644 --- a/src/stores/userStore.ts +++ b/src/stores/userStore.ts @@ -34,7 +34,7 @@ interface UserState { // 操作 fetchUser: (userId: string) => Promise; fetchUserPosts: (userId: string, page?: number) => Promise; - fetchPosts: (type: 'recommend' | 'follow' | 'hot' | 'latest', page?: number) => Promise>; + fetchPosts: (type: 'follow' | 'hot' | 'latest', page?: number) => Promise>; fetchNotifications: (type?: string, page?: number) => Promise; markNotificationAsRead: (notificationId: string) => Promise; markAllNotificationsAsRead: () => Promise; @@ -108,16 +108,13 @@ export const useUserStore = create((set, get) => { }, // 获取帖子列表(首页) - fetchPosts: async (type: 'recommend' | 'follow' | 'hot' | 'latest', page = 1) => { + fetchPosts: async (type: 'follow' | 'hot' | 'latest', page = 1) => { set({ isLoadingPosts: true }); try { let response; switch (type) { - case 'recommend': - response = await postService.getRecommendedPosts(page); - break; case 'follow': response = await postService.getFollowingPosts(page); break; @@ -332,7 +329,7 @@ export const useUserStore = create((set, get) => { // 刷新所有数据 refreshAll: async () => { await Promise.all([ - get().fetchPosts('recommend', 1), + get().fetchPosts('latest', 1), get().fetchNotificationBadge(), ]); }, diff --git a/src/types/dto.ts b/src/types/dto.ts index bf610e5..713f45c 100644 --- a/src/types/dto.ts +++ b/src/types/dto.ts @@ -482,8 +482,8 @@ export interface CursorPaginationRequest { direction?: 'forward' | 'backward'; /** 每页数量(默认 20,最大 100) */ page_size?: number; - /** 帖子类型筛选(可选):recommend, follow, hot, latest */ - post_type?: 'recommend' | 'follow' | 'hot' | 'latest'; + /** 帖子类型筛选(可选):follow, hot, latest */ + post_type?: 'follow' | 'hot' | 'latest'; } /** From f9f4e73747a25dba0aca1a679c3151db9ac9cf34 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Tue, 24 Mar 2026 05:21:46 +0800 Subject: [PATCH 23/36] feat(ProcessPostUseCase, HomeScreen, PostDetailScreen, PostService): implement share count synchronization after post sharing - Added applyShareCountUpdate method in ProcessPostUseCase to synchronize share counts across post lists and details. - Updated sharePost method in PostService to return the latest shares_count from the server. - Modified share handling in HomeScreen and PostDetailScreen to utilize the new share count synchronization logic after a successful share operation. - Enhanced error handling for share operations to improve user feedback. --- src/core/usecases/ProcessPostUseCase.ts | 29 +++++++++++++++++++++++++ src/screens/home/HomeScreen.tsx | 5 ++++- src/screens/home/PostDetailScreen.tsx | 10 ++++++++- src/services/postService.ts | 20 ++++++++++++----- 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/core/usecases/ProcessPostUseCase.ts b/src/core/usecases/ProcessPostUseCase.ts index fb709f7..bb42fa4 100644 --- a/src/core/usecases/ProcessPostUseCase.ts +++ b/src/core/usecases/ProcessPostUseCase.ts @@ -333,6 +333,35 @@ class ProcessPostUseCase { }); } + /** + * 分享计数上报成功后,同步各列表与详情中的分享数 + */ + applyShareCountUpdate(postId: string, sharesCount: number): void { + this.postsState.forEach((state, key) => { + const index = state.posts.findIndex((p) => p.id === postId); + if (index !== -1) { + const newPosts = [...state.posts]; + const cur = newPosts[index]; + newPosts[index] = { + ...cur, + sharesCount, + shares_count: sharesCount, + }; + this.updatePostsState(key, { posts: newPosts }); + } + }); + const detail = this.getPostDetailState(postId); + if (detail.post) { + this.updatePostDetailState(postId, { + post: { + ...detail.post, + sharesCount, + shares_count: sharesCount, + }, + }); + } + } + // ==================== 帖子列表操作 ==================== /** diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 7410251..1221925 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -336,7 +336,10 @@ export const HomeScreen: React.FC = () => { const handleShare = async (post: Post) => { if (!post?.id) return; try { - await postService.sharePost(post.id); + const res = await postService.sharePost(post.id, { channel: 'copy_link' }); + if (res.ok && res.shares_count != null) { + processPostUseCase.applyShareCountUpdate(post.id, res.shares_count); + } } catch (shareError) { console.error('上报分享次数失败:', shareError); } diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index 520f4a0..56e24ff 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -514,7 +514,15 @@ export const PostDetailScreen: React.FC = () => { const handleShare = useCallback(async () => { if (!post?.id) return; try { - await postService.sharePost(post.id); + const res = await postService.sharePost(post.id, { channel: 'copy_link' }); + if (res.ok && res.shares_count != null) { + setPost((prev) => + prev + ? { ...prev, shares_count: res.shares_count!, sharesCount: res.shares_count! } + : null + ); + processPostUseCase.applyShareCountUpdate(post.id, res.shares_count); + } } catch (error) { console.error('上报分享次数失败:', error); } diff --git a/src/services/postService.ts b/src/services/postService.ts index 71a3430..9989122 100644 --- a/src/services/postService.ts +++ b/src/services/postService.ts @@ -250,14 +250,24 @@ class PostService { } } - // 分享帖子 - async sharePost(postId: string): Promise { + /** 上报分享成功;返回服务端最新 shares_count 供界面同步 */ + async sharePost( + postId: string, + body?: { channel?: string } + ): Promise<{ ok: boolean; shares_count?: number }> { try { - const response = await api.post(`/posts/${postId}/share`); - return response.code === 0; + const response = await api.post<{ success: boolean; shares_count: number }>( + `/posts/${postId}/share`, + body && body.channel ? { channel: body.channel } : undefined + ); + if (response.code !== 0) { + return { ok: false }; + } + const n = response.data?.shares_count; + return { ok: true, shares_count: typeof n === 'number' ? n : undefined }; } catch (error) { console.error('分享帖子失败:', error); - return false; + return { ok: false }; } } From b91e78c92179e1af3bcabb21ac88a245dcfaabf5 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Tue, 24 Mar 2026 05:52:24 +0800 Subject: [PATCH 24/36] refactor(PostCard, PostCardGrid, HomeScreen): enhance PostCard component and remove PostCardGrid - Introduced a new PostCardRelativeTime component for dynamic time display, improving user experience with real-time updates. - Refactored PostCard to utilize memoization for performance optimization and prevent unnecessary re-renders. - Removed the PostCardGrid component to streamline the codebase, consolidating functionality within the PostCard component. - Updated HomeScreen to leverage the new PostCard features, ensuring consistent post rendering and interaction handling. --- src/components/business/PostCard/PostCard.tsx | 97 ++++- .../business/PostCard/PostCardGrid.tsx | 115 ----- .../business/PostCard/components/index.ts | 9 +- .../business/PostCard/hooks/index.ts | 7 +- src/core/entities/Post.ts | 23 +- src/navigation/RootNavigator.tsx | 5 +- src/navigation/SimpleMobileTabNavigator.tsx | 127 +++--- src/screens/home/HomeScreen.tsx | 22 +- src/screens/message/MessageListScreen.tsx | 355 ++-------------- .../components/ConversationListRow.tsx | 392 ++++++++++++++++++ src/screens/profile/BlockedUsersScreen.tsx | 9 +- src/screens/profile/UserProfileScreen.tsx | 5 +- src/screens/profile/useUserProfile.ts | 4 +- 13 files changed, 623 insertions(+), 547 deletions(-) delete mode 100644 src/components/business/PostCard/PostCardGrid.tsx create mode 100644 src/screens/message/components/ConversationListRow.tsx diff --git a/src/components/business/PostCard/PostCard.tsx b/src/components/business/PostCard/PostCard.tsx index 47254be..ec77ca5 100644 --- a/src/components/business/PostCard/PostCard.tsx +++ b/src/components/business/PostCard/PostCard.tsx @@ -12,8 +12,8 @@ * /> */ -import React, { useMemo, useState } from 'react'; -import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native'; +import React, { useMemo, useState, memo, useEffect } from 'react'; +import { View, TouchableOpacity, StyleSheet, Alert, TextStyle } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { PostCardProps, PostCardAction } from './types'; import { ImageGridItem, SmartImage } from '../../common'; @@ -23,12 +23,95 @@ import { colors, spacing, borderRadius, fontSizes } from '../../../theme'; import { getPreviewImageUrl } from '../../../utils/imageHelper'; import PostImages from './components/PostImages'; import { useResponsive } from '../../../hooks/useResponsive'; +import { formatPostCreatedAtString } from '../../../core/entities/Post'; + +/** 列表相对时间:独立定时刷新,避免外层 PostCard.memo 挡住「分钟前」更新 */ +const PostCardRelativeTime: React.FC<{ createdAt?: string | null; style?: TextStyle | TextStyle[] }> = ({ + createdAt, + style: timeStyle, +}) => { + const [label, setLabel] = useState(() => formatPostCreatedAtString(createdAt)); + + useEffect(() => { + setLabel(formatPostCreatedAtString(createdAt)); + const tick = () => setLabel(formatPostCreatedAtString(createdAt)); + const id = setInterval(tick, 60_000); + return () => clearInterval(id); + }, [createdAt]); + + return ( + + {label} + + ); +}; + +function imagesSignature( + images: PostCardProps['post']['images'] | undefined +): string { + if (!images?.length) return ''; + return images.map((i) => i.id).join('\u001f'); +} + +function authorSignature(author: PostCardProps['post']['author']): string { + if (!author) return ''; + return [author.id, author.nickname ?? '', author.avatar ?? ''].join('\u001f'); +} + +function topCommentSignature(tc: PostCardProps['post']['top_comment']): string { + if (!tc) return ''; + return [tc.id, tc.content ?? '', tc.author?.id ?? ''].join('\u001f'); +} + +function featuresComparable(f: PostCardProps['features']): string { + if (f == null) return ''; + if (typeof f === 'string') return f; + return JSON.stringify(f); +} + +/** + * 自定义比较:忽略 onAction 引用(父组件应用 postId + ref 解析最新 post,避免 Tab 切换时全列表因回调重建而重绘)。 + * index 未参与 UI,不比较以免列表重排时多余更新。 + */ +function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolean { + if (prev.variant !== next.variant) return false; + if (prev.isPostAuthor !== next.isPostAuthor) return false; + if (prev.isAuthor !== next.isAuthor) return false; + if (prev.style !== next.style) return false; + if (featuresComparable(prev.features) !== featuresComparable(next.features)) return false; + + const a = prev.post; + const b = next.post; + if (a === b) return true; + + if (a.id !== b.id) return false; + if (a.title !== b.title) return false; + if (a.content !== b.content) return false; + if ((a.likes_count ?? 0) !== (b.likes_count ?? 0)) return false; + if ((a.comments_count ?? 0) !== (b.comments_count ?? 0)) return false; + if ((a.favorites_count ?? 0) !== (b.favorites_count ?? 0)) return false; + if ((a.shares_count ?? 0) !== (b.shares_count ?? 0)) return false; + if ((a.views_count ?? 0) !== (b.views_count ?? 0)) return false; + if (!!a.is_pinned !== !!b.is_pinned) return false; + if (!!a.is_locked !== !!b.is_locked) return false; + if (!!a.is_vote !== !!b.is_vote) return false; + if (a.created_at !== b.created_at) return false; + if (a.updated_at !== b.updated_at) return false; + if (a.content_edited_at !== b.content_edited_at) return false; + if (!!a.is_liked !== !!b.is_liked) return false; + if (!!a.is_favorited !== !!b.is_favorited) return false; + if (authorSignature(a.author) !== authorSignature(b.author)) return false; + if (imagesSignature(a.images) !== imagesSignature(b.images)) return false; + if (topCommentSignature(a.top_comment) !== topCommentSignature(b.top_comment)) return false; + + return true; +} /** * PostCard 主组件 * 仅支持新 API */ -const PostCard: React.FC = (normalizedProps) => { +const PostCardInner: React.FC = (normalizedProps) => { const { post, onAction, @@ -247,9 +330,7 @@ const PostCard: React.FC = (normalizedProps) => { {author?.nickname || '匿名用户'} {!showGrid && ( - - {post.created_at ? new Date(post.created_at).toLocaleDateString() : ''} - + {post.is_pinned && ( @@ -334,6 +415,10 @@ const PostCard: React.FC = (normalizedProps) => { ); }; +PostCardInner.displayName = 'PostCard'; + +const PostCard = memo(PostCardInner, arePostCardPropsEqual); + const styles = StyleSheet.create({ card: { backgroundColor: colors.background.paper, diff --git a/src/components/business/PostCard/PostCardGrid.tsx b/src/components/business/PostCard/PostCardGrid.tsx deleted file mode 100644 index 93306d2..0000000 --- a/src/components/business/PostCard/PostCardGrid.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/** - * PostCardGrid 容器组件 - * 网格模式(小红书风格)的帖子卡片容器 - */ - -import React from 'react'; -import { TouchableOpacity, StyleSheet, StyleProp, ViewStyle, GestureResponderEvent } from 'react-native'; -import { colors, borderRadius } from '../../../theme'; -import { useResponsive } from '../../../hooks/useResponsive'; -import { PostCardGridProps } from './types'; -import { usePostCardActions } from './hooks/usePostCardActions'; -import { usePostGridStyles } from './hooks/usePostCardStyles'; -import { PostGridCover, PostGridInfo } from './components'; -import { convertToImageGridItems, getConsistentAspectRatio } from './utils'; - -const PostCardGrid: React.FC = ({ - post, - onAction, - features, - style, -}) => { - const { isDesktop } = useResponsive(); - const gridStyles = usePostGridStyles(); - - const { - handlePress, - handleUserPress, - handleImagePress, - } = usePostCardActions(onAction); - - // 防御性检查:确保 author 存在 - const author = post.author ?? { - id: '', - nickname: '匿名用户', - avatar: '', - username: '', - cover_url: '', - bio: '', - website: '', - location: '', - posts_count: 0, - followers_count: 0, - following_count: 0, - created_at: '', - }; - - // 获取封面图(第一张图) - const coverImage = post.images && post.images.length > 0 ? post.images[0] : null; - - // 根据帖子 ID 生成一致的宽高比 - const aspectRatio = getConsistentAspectRatio(post.id); - - // 样式计算 - const containerStyle: StyleProp = [ - styles.container, - !coverImage && styles.containerNoImage, - isDesktop && styles.containerDesktop, - ]; - - const handleContainerPress = () => { - handlePress(); - }; - - const handleImagePressLocal = () => { - if (post.images && post.images.length > 0) { - handleImagePress(convertToImageGridItems(post.images), 0); - } - }; - - const handleUserPressLocal = () => { - handleUserPress(); - }; - - return ( - - {/* 封面 */} - - - {/* 标题和信息 */} - - - ); -}; - -const styles = StyleSheet.create({ - container: { - backgroundColor: '#FFF', - overflow: 'hidden', - borderRadius: 8, - }, - containerDesktop: { - borderRadius: 12, - }, - containerNoImage: { - minHeight: 200, - justifyContent: 'space-between', - }, -}); - -export default PostCardGrid; \ No newline at end of file diff --git a/src/components/business/PostCard/components/index.ts b/src/components/business/PostCard/components/index.ts index 98f9fc3..68a7cd6 100644 --- a/src/components/business/PostCard/components/index.ts +++ b/src/components/business/PostCard/components/index.ts @@ -1,12 +1,5 @@ /** - * PostCard 子组件导出 + * PostCard 子组件导出(仅导出仓库内已存在的实现) */ -export { default as PostHeader } from './PostHeader'; -export { default as PostTitle } from './PostTitle'; -export { default as PostContent } from './PostContent'; export { default as PostImages } from './PostImages'; -export { default as PostActions } from './PostActions'; -export { default as PostTopComment } from './PostTopComment'; -export { default as PostGridCover } from './PostGridCover'; -export { default as PostGridInfo } from './PostGridInfo'; \ No newline at end of file diff --git a/src/components/business/PostCard/hooks/index.ts b/src/components/business/PostCard/hooks/index.ts index 0025b4e..55cf8d1 100644 --- a/src/components/business/PostCard/hooks/index.ts +++ b/src/components/business/PostCard/hooks/index.ts @@ -1,8 +1,5 @@ /** - * PostCard Hooks 导出 + * PostCard Hooks(预留入口;实现文件尚未加入仓库时勿导出,避免 TS 无法解析) */ -export { usePostCardActions } from './usePostCardActions'; -export { usePostCardFeatures } from './usePostCardFeatures'; -export { usePostCardStyles, usePostGridStyles } from './usePostCardStyles'; -export type { PostCardStylesConfig, PostGridStylesConfig } from './usePostCardStyles'; +export {}; diff --git a/src/core/entities/Post.ts b/src/core/entities/Post.ts index f52cad9..90e2946 100644 --- a/src/core/entities/Post.ts +++ b/src/core/entities/Post.ts @@ -248,12 +248,18 @@ export const getPostTotalEngagement = (post: Post): number => { }; /** - * 格式化帖子创建时间为相对时间描述 + * 根据创建时间 ISO 字符串格式化为列表用相对时间(与历史 formatPostTime 行为一致) */ -export const formatPostTime = (post: Post): string => { - const createdAt = new Date(post.createdAt); +export const formatPostCreatedAtString = (createdAt: string | undefined | null): string => { + if (!createdAt) return ''; + const createdAtDate = new Date(createdAt); + if (Number.isNaN(createdAtDate.getTime())) return ''; + const now = new Date(); - const diffMs = now.getTime() - createdAt.getTime(); + const diffMs = now.getTime() - createdAtDate.getTime(); + if (diffMs < 0) { + return createdAtDate.toLocaleDateString('zh-CN'); + } const diffSeconds = Math.floor(diffMs / 1000); const diffMinutes = Math.floor(diffSeconds / 60); const diffHours = Math.floor(diffMinutes / 60); @@ -271,5 +277,12 @@ export const formatPostTime = (post: Post): string => { if (diffDays < 7) { return `${diffDays}天前`; } - return createdAt.toLocaleDateString('zh-CN'); + return createdAtDate.toLocaleDateString('zh-CN'); +}; + +/** + * 格式化帖子创建时间为相对时间描述 + */ +export const formatPostTime = (post: Post): string => { + return formatPostCreatedAtString(post.createdAt); }; \ No newline at end of file diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx index 05d072a..8ae0620 100644 --- a/src/navigation/RootNavigator.tsx +++ b/src/navigation/RootNavigator.tsx @@ -249,7 +249,10 @@ export function RootNavigator({ isAuthenticated, isInitializing }: RootNavigator > {isAuthenticated ? ( <> - + {() => isMobile ? ( diff --git a/src/navigation/SimpleMobileTabNavigator.tsx b/src/navigation/SimpleMobileTabNavigator.tsx index da32915..f4720ad 100644 --- a/src/navigation/SimpleMobileTabNavigator.tsx +++ b/src/navigation/SimpleMobileTabNavigator.tsx @@ -1,18 +1,18 @@ /** - * 简单的移动端 Tab Navigator + * 简单的移动端 Tab Navigator(自定义 Tab 栏) * - * 不使用 React Navigation 的 Tab Navigator,而是使用一个简单的自定义实现 - * 这样可以完全避免 React Navigation 状态恢复的问题 + * 策略(在 EnsureSingleNavigator 限制下尽量省加载): + * - 首页、消息:屏幕本身不带根级 NativeStack,首次进入后保留挂载,仅 display 隐藏 → 来回切换快。 + * - 课表、我的:各含一个 NativeStack,同一时刻只挂载当前选中的一个,避免重复注册 Navigator。 + * + * 其它可叠加的优化(按需再做,不放在本文件里): + * - 数据:TanStack Query 调 staleTime、列表 prefetch、占位骨架 + * - 时机:InteractionManager.runAfterInteractions 再拉非首屏接口 + * - 渲染:React.memo 重列表项、避免 Tab 切换时整树不必要 setState */ import React, { useEffect, useState, useCallback } from 'react'; -import { - View, - StyleSheet, - TouchableOpacity, - Text, - Dimensions, -} from 'react-native'; +import { View, StyleSheet, TouchableOpacity, Text } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; @@ -22,13 +22,9 @@ import { useTotalUnreadCount } from '../stores'; import { messageManager } from '../stores'; // ==================== 导入屏幕组件 ==================== -import { HomeScreen, SearchScreen } from '../screens/home'; +import { HomeScreen } from '../screens/home'; import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule'; -import { - MessageListScreen, - NotificationsScreen, - PrivateChatInfoScreen, -} from '../screens/message'; +import { MessageListScreen } from '../screens/message'; import { ProfileScreen, SettingsScreen, EditProfileScreen, NotificationSettingsScreen, BlockedUsersScreen, AccountSecurityScreen } from '../screens/profile'; // ==================== 类型定义 ==================== @@ -178,11 +174,10 @@ export function SimpleMobileTabNavigator() { const insets = useSafeAreaInsets(); const messageUnreadCount = useTotalUnreadCount(); const [activeTab, setActiveTab] = useState('HomeTab'); - const [mountedTabs, setMountedTabs] = useState>({ + /** 无嵌套 Stack 的 Tab:保留实例,避免每次切回都整页重挂载 */ + const [plainTabEverShown, setPlainTabEverShown] = useState({ HomeTab: true, MessageTab: false, - ScheduleTab: false, - ProfileTab: false, }); // 初始化 MessageManager @@ -190,36 +185,13 @@ export function SimpleMobileTabNavigator() { messageManager.initialize(); }, []); - // 处理 Tab 切换 const handleTabPress = useCallback((tabName: TabName) => { - setMountedTabs((prev) => { - if (prev[tabName]) { - return prev; - } - return { - ...prev, - [tabName]: true, - }; - }); + if (tabName === 'HomeTab' || tabName === 'MessageTab') { + setPlainTabEverShown((prev) => ({ ...prev, [tabName]: true })); + } setActiveTab(tabName); }, []); - // 根据 Tab 名称渲染对应内容 - const renderTabView = (tabName: TabName) => { - switch (tabName) { - case 'HomeTab': - return ; - case 'MessageTab': - return ; - case 'ScheduleTab': - return ; - case 'ProfileTab': - return ; - default: - return ; - } - }; - // 渲染 Tab Bar 图标 const renderTabIcon = (tabName: TabName, isActive: boolean) => { const iconColor = isActive ? colors.primary.main : colors.text.secondary; @@ -284,31 +256,43 @@ export function SimpleMobileTabNavigator() { ); }; - // 计算底部安全距离 - const bottomSafeArea = TAB_BAR_HEIGHT + TAB_BAR_FLOATING_MARGIN * 2 + insets.bottom; - return ( - {/* 内容区域 */} - {(Object.keys(mountedTabs) as TabName[]).map((tabName) => { - if (!mountedTabs[tabName]) { - return null; - } - const isActive = activeTab === tabName; - return ( - - {renderTabView(tabName)} - - ); - })} + {plainTabEverShown.HomeTab && ( + + + + )} + {plainTabEverShown.MessageTab && ( + + + + )} + {activeTab === 'ScheduleTab' && ( + + + + )} + {activeTab === 'ProfileTab' && ( + + + + )} {/* Tab Bar */} @@ -361,9 +345,6 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: colors.background.default, }, - stackContainer: { - flex: 1, - }, content: { flex: 1, position: 'relative', @@ -374,9 +355,6 @@ const styles = StyleSheet.create({ tabScreenHidden: { display: 'none', }, - bottomSpacer: { - backgroundColor: 'transparent', - }, tabBar: { position: 'absolute', left: TAB_BAR_MARGIN, @@ -390,6 +368,9 @@ const styles = StyleSheet.create({ justifyContent: 'space-around', paddingHorizontal: 8, ...shadows.lg, + // 叠在全屏内容之上(shadows.lg 含 elevation,需最后覆盖) + zIndex: 100, + elevation: 100, }, tabItem: { flex: 1, diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 1221925..8ace932 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -202,6 +202,10 @@ export const HomeScreen: React.FC = () => { }); }, [posts, postsMap]); + /** 按 id 取当前列表中的最新 post,配合 PostCard memo 忽略 onAction 引用 */ + const postByIdRef = useRef>(new Map()); + postByIdRef.current = new Map(displayPosts.map((p) => [p.id, p])); + // 根据屏幕尺寸确定网格列数 const gridColumns = useMemo(() => { if (isWideScreen || width >= 1440) return 4; @@ -406,6 +410,16 @@ export const HomeScreen: React.FC = () => { } }; + const handlePostActionRef = useRef(handlePostAction); + handlePostActionRef.current = handlePostAction; + + const stableOnPostAction = useCallback((postId: string, action: PostCardAction) => { + const post = postByIdRef.current.get(postId); + if (post) { + handlePostActionRef.current(post, action); + } + }, []); + // 跳转到发帖页面(使用 Modal 方式) const handleCreatePost = () => { setShowCreatePost(true); @@ -430,12 +444,12 @@ export const HomeScreen: React.FC = () => { ]}> handlePostAction(item, action)} + onAction={(action) => stableOnPostAction(item.id, action)} isPostAuthor={isPostAuthor} /> ); - }, [currentUser?.id, handlePostAction, isMobile, listItemWidth, responsiveGap]); + }, [currentUser?.id, stableOnPostAction, isMobile, listItemWidth, responsiveGap]); // 估算帖子在瀑布流中的高度(用于均匀分配) const estimatePostHeight = (post: Post, columnWidth: number): number => { @@ -514,7 +528,7 @@ export const HomeScreen: React.FC = () => { handlePostAction(post, action)} + onAction={(action) => stableOnPostAction(post.id, action)} isPostAuthor={isPostAuthor} /> @@ -575,7 +589,7 @@ export const HomeScreen: React.FC = () => { key={post.id} post={post} variant={viewMode === 'grid' ? 'grid' : 'list'} - onAction={(action) => handlePostAction(post, action)} + onAction={(action) => stableOnPostAction(post.id, action)} isPostAuthor={isPostAuthor} /> ); diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index e128038..1414d72 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -31,7 +31,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, fontSizes, shadows, borderRadius } from '../../theme'; -import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments, extractTextFromSegmentsAsync, MessageSegment } from '../../types/dto'; +import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments } from '../../types/dto'; import { authService } from '../../services'; import { useUserStore, useAuthStore } from '../../stores'; // 【新架构】使用MessageManager hooks(会话列表数据源自 MessageManager 游标同步) @@ -46,9 +46,9 @@ import { import { Avatar, Text, EmptyState, ResponsiveContainer } from '../../components/common'; import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive'; import { RootStackParamList, MessageStackParamList } from '../../navigation/types'; -import { getUserCache } from '../../services/database'; // 导入 EmbeddedChat 组件用于桌面端双栏布局 import { EmbeddedChat } from './components/EmbeddedChat'; +import { ConversationListRow } from './components/ConversationListRow'; // 导入 NotificationsScreen 用于在内部显示 import { NotificationsScreen } from './NotificationsScreen'; // 导入扫码组件 @@ -70,80 +70,6 @@ interface SearchResultItem { matchedMessages?: MessageResponse[]; } -// 动画值 -const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); -const MAX_CONVERSATION_NAME_LENGTH = 10; - -const truncateDisplayName = (name: string, maxLength: number = MAX_CONVERSATION_NAME_LENGTH): string => { - if (!name) return ''; - if (name.length <= maxLength) return name; - return `${name.slice(0, maxLength)}...`; -}; - -/** - * 异步消息预览组件 - */ -const AsyncMessagePreview: React.FC<{ - segments?: MessageSegment[]; - status?: string; - isGroupChat?: boolean; - senderName?: string; -}> = ({ segments, status, isGroupChat, senderName }) => { - const [displayText, setDisplayText] = useState(''); - const isMountedRef = useRef(true); - - useEffect(() => { - isMountedRef.current = true; - - const loadPreview = async () => { - if (status === 'recalled') { - if (isMountedRef.current) { - setDisplayText('消息已撤回'); - } - return; - } - - const initialText = extractTextFromSegments(segments); - if (isMountedRef.current) { - setDisplayText(initialText); - } - - const hasAtWithoutNickname = segments?.some( - s => s.type === 'at' && s.data.user_id !== 'all' && !s.data.nickname - ); - - if (hasAtWithoutNickname) { - try { - const asyncText = await extractTextFromSegmentsAsync( - segments, - getUserCache, - authService.getUserById.bind(authService) - ); - if (isMountedRef.current && asyncText !== initialText) { - setDisplayText(asyncText); - } - } catch (error) { - console.warn('[AsyncMessagePreview] 获取用户名失败:', error); - } - } - }; - - loadPreview(); - - return () => { - isMountedRef.current = false; - }; - }, [segments, status]); - - if (!displayText) return null; - - if (isGroupChat && senderName) { - return <>{senderName}: {displayText}; - } - - return <>{displayText}; -}; - /** * MessageListScreen - 纯渲染组件 * 所有数据通过useMessageList hook获取 @@ -301,8 +227,8 @@ export const MessageListScreen: React.FC = () => { setRefreshing(false); }, [refresh]); - // 格式化时间 - const formatTime = (dateString: string): string => { + // 格式化时间(稳定引用,配合会话行 memo) + const formatTime = useCallback((dateString: string): string => { try { const date = new Date(dateString); const now = new Date(); @@ -327,7 +253,7 @@ export const MessageListScreen: React.FC = () => { } catch { return ''; } - }; + }, []); // 跳转到聊天页 const handleConversationPress = (conversation: ConversationResponse, index: number) => { @@ -380,6 +306,9 @@ export const MessageListScreen: React.FC = () => { }); }; + const handleConversationPressRef = useRef(handleConversationPress); + handleConversationPressRef.current = handleConversationPress; + // 创建群聊 const handleCreateGroup = () => { (navigation as any).navigate('CreateGroup'); @@ -533,136 +462,37 @@ export const MessageListScreen: React.FC = () => { ...conversationList, ]; + const listConvRef = useRef(new Map()); + listConvRef.current = new Map(listData.map((c) => [String(c.id), c])); + + const stableConversationPress = useCallback((conversationId: string, index: number) => { + const c = listConvRef.current.get(conversationId); + if (c) { + handleConversationPressRef.current(c, index); + } + }, []); + // 总未读数 const totalUnread = totalUnreadCount + systemUnreadCount; - // 渲染会话项 - const renderConversation = ({ item, index }: { item: ConversationResponse; index: number }) => { - const isSystemChannel = item.id === SYSTEM_MESSAGE_CHANNEL_ID; - const isGroupChat = !!(item.type === 'group' && item.group); - - // 获取显示名称和头像 - let displayNameRaw: string; - let displayName: string; - let displayAvatar: string | null = null; - - if (isSystemChannel) { - displayNameRaw = '系统通知'; - } else if (isGroupChat && item.group) { - displayNameRaw = item.group.name || '群聊'; - displayAvatar = item.group.avatar && item.group.avatar.trim() !== '' ? item.group.avatar : null; - } else { - displayNameRaw = item.participants?.[0]?.nickname || '未知用户'; - displayAvatar = item.participants?.[0]?.avatar || null; - } - displayName = truncateDisplayName(displayNameRaw); - - const getSenderName = (): string | undefined => { - if (isGroupChat && item.last_message?.sender) { - return item.last_message.sender.nickname || item.last_message.sender.username; - } - return undefined; - }; - - // 是否选中(用于桌面端双栏布局高亮) - const isSelected = selectedConversation?.id === item.id; - - return ( - handleConversationPress(item, index)} - activeOpacity={0.7} - > - - {isSystemChannel ? ( - - - - ) : isGroupChat ? ( - - {displayAvatar ? ( - - ) : ( - - - - )} - - ) : ( - - )} - - - - - - {isSystemChannel && ( - - 官方 - - )} - {isGroupChat && ( - - )} - {displayName} - {item.is_pinned && !isSystemChannel && ( - - )} - {isGroupChat && item.member_count !== undefined && ( - ({item.member_count}) - )} - - - {formatTime(item.last_message_at || item.updated_at)} - - - - 0 ? [styles.messageText, styles.unreadMessageText] : styles.messageText} - numberOfLines={1} - > - {!item.last_message ? ( - '暂无消息' - ) : ( - - )} - - {item.unread_count > 0 && ( - - - {item.unread_count > 99 ? '99+' : item.unread_count} - - - )} - {__DEV__ && ( - - [DEBUG: {item.unread_count || 0}] - - )} - - - - ); - }; + // 渲染会话项(行级 memo + 按 id 解析最新会话,避免 Tab 切换时整表无效重绘) + const renderConversation = useCallback( + ({ item, index }: { item: ConversationResponse; index: number }) => { + const isSelected = selectedConversation?.id === item.id; + return ( + stableConversationPress(String(item.id), index)} + /> + ); + }, + [selectedConversation?.id, scaleAnims, formatTime, stableConversationPress] + ); // 渲染空状态 const renderEmpty = () => ( @@ -1187,119 +1017,6 @@ const styles = StyleSheet.create({ flexGrow: 1, backgroundColor: '#FAFAFA', }, - conversationItem: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.md, - paddingVertical: 14, - backgroundColor: '#FFF', - marginHorizontal: spacing.md, - marginTop: spacing.sm, - borderRadius: 12, - ...shadows.sm, - }, - conversationItemSelected: { - backgroundColor: colors.primary.light + '20', - borderColor: colors.primary.main, - borderWidth: 1, - }, - avatarContainer: { - position: 'relative', - }, - systemAvatar: { - width: 50, - height: 50, - borderRadius: 12, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.primary.main, - }, - conversationContent: { - flex: 1, - marginLeft: spacing.md, - }, - conversationHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 4, - }, - nameRow: { - flexDirection: 'row', - alignItems: 'center', - }, - officialBadge: { - backgroundColor: colors.primary.main, - borderRadius: 4, - paddingHorizontal: 6, - paddingVertical: 2, - marginRight: spacing.xs, - }, - officialBadgeText: { - color: '#FFF', - fontSize: 10, - fontWeight: '600', - }, - userName: { - fontWeight: '600', - color: '#333', - fontSize: 16, - }, - groupIcon: { - marginRight: 4, - }, - memberCount: { - fontSize: 12, - color: '#999', - marginLeft: 2, - }, - pinnedIcon: { - marginLeft: 6, - }, - groupAvatar: { - width: 50, - height: 50, - }, - groupAvatarPlaceholder: { - width: 50, - height: 50, - borderRadius: 12, - backgroundColor: colors.primary.main, - alignItems: 'center', - justifyContent: 'center', - }, - timeText: { - color: '#999', - fontSize: 12, - }, - messageRow: { - flexDirection: 'row', - alignItems: 'center', - }, - messageText: { - flex: 1, - color: '#888', - fontSize: 14, - }, - unreadMessageText: { - color: '#333', - fontWeight: '500', - }, - unreadBadge: { - minWidth: 20, - height: 20, - borderRadius: 10, - backgroundColor: colors.primary.main, - alignItems: 'center', - justifyContent: 'center', - marginLeft: spacing.sm, - paddingHorizontal: 6, - }, - unreadBadgeText: { - color: '#FFF', - fontSize: 12, - fontWeight: '600', - }, loadingContainer: { flex: 1, justifyContent: 'center', diff --git a/src/screens/message/components/ConversationListRow.tsx b/src/screens/message/components/ConversationListRow.tsx new file mode 100644 index 0000000..139f0dc --- /dev/null +++ b/src/screens/message/components/ConversationListRow.tsx @@ -0,0 +1,392 @@ +/** + * 会话列表行:React.memo + 稳定比较,减少 Tab 切换等场景下的无关重渲染。 + * onPress 引用不参与比较,请配合父组件 ref + 按 id 查最新会话使用。 + */ + +import React, { memo, useEffect, useRef, useState } from 'react'; +import { Animated, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { colors, spacing, shadows } from '../../../theme'; +import { + ConversationResponse, + extractTextFromSegments, + extractTextFromSegmentsAsync, + MessageSegment, +} from '../../../types/dto'; +import { authService } from '../../../services'; +import { getUserCache } from '../../../services/database'; +import { Avatar, Text } from '../../../components/common'; + +const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); +const MAX_CONVERSATION_NAME_LENGTH = 10; + +const truncateDisplayName = (name: string, maxLength: number = MAX_CONVERSATION_NAME_LENGTH): string => { + if (!name) return ''; + if (name.length <= maxLength) return name; + return `${name.slice(0, maxLength)}...`; +}; + +/** + * 异步消息预览(与会话行同文件,仅供列表使用) + */ +const AsyncMessagePreview: React.FC<{ + segments?: MessageSegment[]; + status?: string; + isGroupChat?: boolean; + senderName?: string; +}> = ({ segments, status, isGroupChat, senderName }) => { + const [displayText, setDisplayText] = useState(''); + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + + const loadPreview = async () => { + if (status === 'recalled') { + if (isMountedRef.current) { + setDisplayText('消息已撤回'); + } + return; + } + + const initialText = extractTextFromSegments(segments); + if (isMountedRef.current) { + setDisplayText(initialText); + } + + const hasAtWithoutNickname = segments?.some( + (s) => s.type === 'at' && s.data.user_id !== 'all' && !s.data.nickname + ); + + if (hasAtWithoutNickname) { + try { + const asyncText = await extractTextFromSegmentsAsync( + segments, + getUserCache, + authService.getUserById.bind(authService) + ); + if (isMountedRef.current && asyncText !== initialText) { + setDisplayText(asyncText); + } + } catch (error) { + console.warn('[AsyncMessagePreview] 获取用户名失败:', error); + } + } + }; + + loadPreview(); + + return () => { + isMountedRef.current = false; + }; + }, [segments, status]); + + if (!displayText) return null; + + if (isGroupChat && senderName) { + return ( + <> + {senderName}: {displayText} + + ); + } + + return <>{displayText}; +}; + +export interface ConversationListRowProps { + item: ConversationResponse; + index: number; + scale: Animated.Value | number; + isSelected: boolean; + onPress: () => void; + formatTime: (dateString: string) => string; + systemChannelId: string; +} + +function conversationVisualSignature(item: ConversationResponse): string { + const lm = item.last_message; + const lmSender = lm?.sender; + const lmPart = lm + ? [ + lm.id, + String(lm.seq), + lm.status, + String(lm.segments?.length ?? 0), + extractTextFromSegments(lm.segments), + lmSender?.nickname ?? '', + lmSender?.username ?? '', + ].join('|') + : ''; + const g = item.group; + const gPart = g ? [String(g.id), g.name, g.avatar ?? ''].join('|') : ''; + const p0 = item.participants?.[0]; + const pPart = p0 ? [p0.id, p0.nickname ?? '', p0.avatar ?? ''].join('|') : ''; + return [ + String(item.id), + item.type, + item.is_pinned ? '1' : '0', + String(item.unread_count), + item.last_message_at, + item.updated_at, + String(item.last_seq), + String(item.member_count ?? ''), + lmPart, + gPart, + pPart, + ].join('\u001f'); +} + +function areConversationRowEqual( + prev: ConversationListRowProps, + next: ConversationListRowProps +): boolean { + if (prev.isSelected !== next.isSelected) return false; + if (prev.index !== next.index) return false; + if (prev.scale !== next.scale) return false; + if (prev.systemChannelId !== next.systemChannelId) return false; + if (conversationVisualSignature(prev.item) !== conversationVisualSignature(next.item)) return false; + // onPress、formatTime 故意不比:父组件用 ref 保证行为最新 + return true; +} + +const ConversationListRowInner: React.FC = ({ + item, + scale, + isSelected, + onPress, + formatTime, + systemChannelId, +}) => { + const isSystemChannel = item.id === systemChannelId; + const isGroupChat = !!(item.type === 'group' && item.group); + + let displayNameRaw: string; + let displayName: string; + let displayAvatar: string | null = null; + + if (isSystemChannel) { + displayNameRaw = '系统通知'; + } else if (isGroupChat && item.group) { + displayNameRaw = item.group.name || '群聊'; + displayAvatar = item.group.avatar && item.group.avatar.trim() !== '' ? item.group.avatar : null; + } else { + displayNameRaw = item.participants?.[0]?.nickname || '未知用户'; + displayAvatar = item.participants?.[0]?.avatar || null; + } + displayName = truncateDisplayName(displayNameRaw); + + const getSenderName = (): string | undefined => { + if (isGroupChat && item.last_message?.sender) { + return item.last_message.sender.nickname || item.last_message.sender.username; + } + return undefined; + }; + + return ( + + + {isSystemChannel ? ( + + + + ) : isGroupChat ? ( + + {displayAvatar ? ( + + ) : ( + + + + )} + + ) : ( + + )} + + + + + + {isSystemChannel && ( + + 官方 + + )} + {isGroupChat && ( + + )} + {displayName} + {item.is_pinned && !isSystemChannel && ( + + )} + {isGroupChat && item.member_count !== undefined && ( + ({item.member_count}) + )} + + {formatTime(item.last_message_at || item.updated_at)} + + + 0 ? [styles.messageText, styles.unreadMessageText] : styles.messageText} + numberOfLines={1} + > + {!item.last_message ? ( + '暂无消息' + ) : ( + + )} + + {item.unread_count > 0 && ( + + + {item.unread_count > 99 ? '99+' : item.unread_count} + + + )} + {__DEV__ && ( + [DEBUG: {item.unread_count || 0}] + )} + + + + ); +}; + +ConversationListRowInner.displayName = 'ConversationListRow'; + +export const ConversationListRow = memo(ConversationListRowInner, areConversationRowEqual); + +const styles = StyleSheet.create({ + conversationItem: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: 14, + backgroundColor: '#FFF', + marginHorizontal: spacing.md, + marginTop: spacing.sm, + borderRadius: 12, + ...shadows.sm, + }, + conversationItemSelected: { + backgroundColor: colors.primary.light + '20', + borderColor: colors.primary.main, + borderWidth: 1, + }, + avatarContainer: { + position: 'relative', + }, + systemAvatar: { + width: 50, + height: 50, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.primary.main, + }, + conversationContent: { + flex: 1, + marginLeft: spacing.md, + }, + conversationHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 4, + }, + nameRow: { + flexDirection: 'row', + alignItems: 'center', + }, + officialBadge: { + backgroundColor: colors.primary.main, + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + marginRight: spacing.xs, + }, + officialBadgeText: { + color: '#FFF', + fontSize: 10, + fontWeight: '600', + }, + userName: { + fontWeight: '600', + color: '#333', + fontSize: 16, + }, + groupIcon: { + marginRight: 4, + }, + memberCount: { + fontSize: 12, + color: '#999', + marginLeft: 2, + }, + pinnedIcon: { + marginLeft: 6, + }, + groupAvatar: { + width: 50, + height: 50, + }, + groupAvatarPlaceholder: { + width: 50, + height: 50, + borderRadius: 12, + backgroundColor: colors.primary.main, + alignItems: 'center', + justifyContent: 'center', + }, + timeText: { + color: '#999', + fontSize: 12, + }, + messageRow: { + flexDirection: 'row', + alignItems: 'center', + }, + messageText: { + flex: 1, + color: '#888', + fontSize: 14, + }, + unreadMessageText: { + color: '#333', + fontWeight: '500', + }, + unreadBadge: { + minWidth: 20, + height: 20, + borderRadius: 10, + backgroundColor: colors.primary.main, + alignItems: 'center', + justifyContent: 'center', + marginLeft: spacing.sm, + paddingHorizontal: 6, + }, + unreadBadgeText: { + color: '#FFF', + fontSize: 12, + fontWeight: '600', + }, +}); diff --git a/src/screens/profile/BlockedUsersScreen.tsx b/src/screens/profile/BlockedUsersScreen.tsx index c91117b..dc40c81 100644 --- a/src/screens/profile/BlockedUsersScreen.tsx +++ b/src/screens/profile/BlockedUsersScreen.tsx @@ -9,19 +9,14 @@ import { Alert, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { navigationService } from '../../infrastructure/navigation/navigationService'; import { Avatar, Button, EmptyState, ResponsiveContainer, Text } from '../../components/common'; import { authService } from '../../services'; import { colors, spacing, borderRadius, shadows } from '../../theme'; import { User } from '../../types'; -import { RootStackParamList } from '../../navigation/types'; import { useResponsive } from '../../hooks'; -type NavigationProp = NativeStackNavigationProp; - export const BlockedUsersScreen: React.FC = () => { - const navigation = useNavigation(); const { isMobile } = useResponsive(); const insets = useSafeAreaInsets(); const [users, setUsers] = useState([]); @@ -83,7 +78,7 @@ export const BlockedUsersScreen: React.FC = () => { navigation.navigate('UserProfile', { userId: item.id })} + onPress={() => navigationService.navigate('UserProfile', { userId: item.id })} > diff --git a/src/screens/profile/UserProfileScreen.tsx b/src/screens/profile/UserProfileScreen.tsx index b567d82..8c73347 100644 --- a/src/screens/profile/UserProfileScreen.tsx +++ b/src/screens/profile/UserProfileScreen.tsx @@ -12,7 +12,7 @@ import { ScrollView, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { NavigationProp } from '@react-navigation/native'; import { colors } from '../../theme'; import { Post } from '../../types'; import { PostCard, TabBar, UserProfileHeader } from '../../components/business'; @@ -24,7 +24,8 @@ import { useUserProfile, ProfileMode, TABS, TAB_ICONS, sharedStyles } from './us interface UserProfileScreenProps { mode: ProfileMode; userId?: string; // 仅 other 模式需要 - profileNavigation?: NativeStackNavigationProp; // 仅 self 模式需要 + /** 使用 NavigationProp 避免与具体 screen 的 NativeStackNavigationProp 在 setParams 上不兼容 */ + profileNavigation?: NavigationProp; // 仅 self 模式需要 } export const UserProfileScreen: React.FC = ({ mode, userId, profileNavigation }) => { diff --git a/src/screens/profile/useUserProfile.ts b/src/screens/profile/useUserProfile.ts index bacc757..0fddf6d 100644 --- a/src/screens/profile/useUserProfile.ts +++ b/src/screens/profile/useUserProfile.ts @@ -4,7 +4,7 @@ */ import { useState, useEffect, useCallback, useMemo } from 'react'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { colors, spacing } from '../../theme'; @@ -23,7 +23,7 @@ export interface UseUserProfileOptions { userId?: string; // 仅 other 模式需要 isDesktop?: boolean; isTablet?: boolean; - profileNavigation?: NativeStackNavigationProp; // 仅 self 模式需要 + profileNavigation?: NavigationProp; // 仅 self 模式需要 } export interface UseUserProfileReturn { From 2ddb9cadd8277b36736387586923f92cd1eaa7bf Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Tue, 24 Mar 2026 14:21:31 +0800 Subject: [PATCH 25/36] refactor(App, navigation): migrate to Expo Router and clean up navigation structure - Updated App entry point to utilize Expo Router, simplifying the navigation setup. - Removed legacy navigation components and services to streamline the codebase. - Adjusted package.json to reflect the new entry point and updated dependencies for compatibility. - Enhanced overall application structure by consolidating navigation logic and improving maintainability. --- App.tsx | 136 +---- app/(app)/(tabs)/_layout.tsx | 96 +++ app/(app)/(tabs)/home/_layout.tsx | 5 + app/(app)/(tabs)/home/index.tsx | 5 + app/(app)/(tabs)/home/search.tsx | 5 + app/(app)/(tabs)/messages/_layout.tsx | 5 + app/(app)/(tabs)/messages/index.tsx | 5 + app/(app)/(tabs)/messages/notifications.tsx | 5 + app/(app)/(tabs)/profile/_layout.tsx | 36 ++ app/(app)/(tabs)/profile/account-security.tsx | 5 + app/(app)/(tabs)/profile/blocked-users.tsx | 5 + app/(app)/(tabs)/profile/bookmarks.tsx | 5 + app/(app)/(tabs)/profile/edit-profile.tsx | 5 + app/(app)/(tabs)/profile/index.tsx | 5 + app/(app)/(tabs)/profile/my-posts.tsx | 5 + .../(tabs)/profile/notification-settings.tsx | 5 + app/(app)/(tabs)/profile/settings.tsx | 5 + app/(app)/(tabs)/schedule/_layout.tsx | 17 + app/(app)/(tabs)/schedule/course.tsx | 5 + app/(app)/(tabs)/schedule/index.tsx | 5 + app/(app)/_layout.tsx | 19 + app/(app)/_layout.web.tsx | 28 + app/(app)/chat/[conversationId].tsx | 5 + app/(app)/chat/private-info.tsx | 5 + app/(app)/group/[groupId]/index.tsx | 5 + app/(app)/group/[groupId]/members.tsx | 5 + app/(app)/group/create.tsx | 5 + app/(app)/group/invite.tsx | 5 + app/(app)/group/join.tsx | 5 + app/(app)/group/request.tsx | 5 + app/(app)/posts/create.tsx | 5 + app/(app)/qrcode/login/[sessionId].tsx | 5 + app/(app)/users/[userId]/[type].tsx | 5 + app/(auth)/_layout.tsx | 5 + app/(auth)/forgot-password.tsx | 5 + app/(auth)/login.tsx | 5 + app/(auth)/register.tsx | 5 + app/_layout.tsx | 173 ++++++ app/index.tsx | 11 + app/post/[postId].tsx | 5 + app/user/[userId].tsx | 5 + index.ts | 9 +- package-lock.json | 15 +- package.json | 5 +- .../AppDesktopShell.tsx} | 142 ++--- src/app-navigation/AppRouteStack.tsx | 8 + src/components/business/CommentItem.tsx | 54 +- src/components/business/QRCodeScanner.tsx | 11 +- src/components/business/UserProfileHeader.tsx | 8 +- src/components/common/AppBackButton.tsx | 54 ++ src/components/common/ImageGallery.tsx | 59 +- src/components/common/index.ts | 1 + .../navigation/hooks/useNavigationState.ts | 60 -- .../navigation/navigationService.ts | 110 ---- src/infrastructure/navigation/types.ts | 58 -- src/navigation/AuthNavigator.tsx | 25 - src/navigation/HomeNavigator.tsx | 49 -- src/navigation/MainNavigator.tsx | 101 ---- src/navigation/MessageNavigator.tsx | 60 -- .../MobileTabNavigatorWithDelay.tsx | 555 ------------------ src/navigation/ProfileNavigator.tsx | 101 ---- src/navigation/RootNavigator.tsx | 288 --------- src/navigation/ScheduleNavigator.tsx | 42 -- src/navigation/SimpleMobileTabNavigator.tsx | 420 ------------- src/navigation/TabNavigator.tsx | 155 ----- src/navigation/hrefs.ts | 153 +++++ src/navigation/index.ts | 29 +- src/navigation/paramUtils.ts | 11 + src/navigation/types.ts | 135 ----- src/screens/auth/ForgotPasswordScreen.tsx | 12 +- src/screens/auth/LoginScreen.tsx | 23 +- src/screens/auth/QRCodeConfirmScreen.tsx | 32 +- src/screens/auth/RegisterScreen.tsx | 18 +- src/screens/create/CreatePostScreen.tsx | 39 +- src/screens/home/HomeScreen.tsx | 32 +- src/screens/home/PostDetailScreen.tsx | 158 ++--- src/screens/home/SearchScreen.tsx | 19 +- src/screens/message/ChatScreen.tsx | 41 +- src/screens/message/CreateGroupScreen.tsx | 10 +- src/screens/message/GroupInfoScreen.tsx | 33 +- .../message/GroupInviteDetailScreen.tsx | 53 +- src/screens/message/GroupMembersScreen.tsx | 17 +- .../message/GroupRequestDetailScreen.tsx | 53 +- src/screens/message/JoinGroupScreen.tsx | 18 +- src/screens/message/MessageListScreen.tsx | 61 +- src/screens/message/NotificationsScreen.tsx | 27 +- src/screens/message/PrivateChatInfoScreen.tsx | 30 +- .../components/ChatScreen/ChatHeader.tsx | 16 +- .../components/ChatScreen/MessageBubble.tsx | 24 +- .../components/ChatScreen/SegmentRenderer.tsx | 58 +- .../components/ChatScreen/bubbleStyles.ts | 4 +- .../message/components/ChatScreen/styles.ts | 36 +- .../components/ChatScreen/useChatScreen.ts | 99 ++-- .../message/components/EmbeddedChat.tsx | 38 +- src/screens/profile/BlockedUsersScreen.tsx | 6 +- src/screens/profile/FollowListScreen.tsx | 24 +- src/screens/profile/ProfileScreen.tsx | 9 +- src/screens/profile/SettingsScreen.tsx | 19 +- src/screens/profile/UserProfileScreen.tsx | 8 +- src/screens/profile/UserScreen.tsx | 9 +- src/screens/profile/useUserProfile.ts | 85 +-- src/screens/schedule/CourseDetailScreen.tsx | 35 +- src/screens/schedule/ScheduleScreen.tsx | 20 +- src/services/backgroundService.ts | 133 +---- src/services/groupService.ts | 169 ++++-- src/services/messageVibrationService.ts | 88 +++ src/services/sseService.ts | 30 +- src/stores/groupManager.ts | 8 +- src/stores/groupMemberListSources.ts | 10 +- src/stores/routePayloadCache.ts | 33 ++ src/types/dto.ts | 7 +- 111 files changed, 1829 insertions(+), 3214 deletions(-) create mode 100644 app/(app)/(tabs)/_layout.tsx create mode 100644 app/(app)/(tabs)/home/_layout.tsx create mode 100644 app/(app)/(tabs)/home/index.tsx create mode 100644 app/(app)/(tabs)/home/search.tsx create mode 100644 app/(app)/(tabs)/messages/_layout.tsx create mode 100644 app/(app)/(tabs)/messages/index.tsx create mode 100644 app/(app)/(tabs)/messages/notifications.tsx create mode 100644 app/(app)/(tabs)/profile/_layout.tsx create mode 100644 app/(app)/(tabs)/profile/account-security.tsx create mode 100644 app/(app)/(tabs)/profile/blocked-users.tsx create mode 100644 app/(app)/(tabs)/profile/bookmarks.tsx create mode 100644 app/(app)/(tabs)/profile/edit-profile.tsx create mode 100644 app/(app)/(tabs)/profile/index.tsx create mode 100644 app/(app)/(tabs)/profile/my-posts.tsx create mode 100644 app/(app)/(tabs)/profile/notification-settings.tsx create mode 100644 app/(app)/(tabs)/profile/settings.tsx create mode 100644 app/(app)/(tabs)/schedule/_layout.tsx create mode 100644 app/(app)/(tabs)/schedule/course.tsx create mode 100644 app/(app)/(tabs)/schedule/index.tsx create mode 100644 app/(app)/_layout.tsx create mode 100644 app/(app)/_layout.web.tsx create mode 100644 app/(app)/chat/[conversationId].tsx create mode 100644 app/(app)/chat/private-info.tsx create mode 100644 app/(app)/group/[groupId]/index.tsx create mode 100644 app/(app)/group/[groupId]/members.tsx create mode 100644 app/(app)/group/create.tsx create mode 100644 app/(app)/group/invite.tsx create mode 100644 app/(app)/group/join.tsx create mode 100644 app/(app)/group/request.tsx create mode 100644 app/(app)/posts/create.tsx create mode 100644 app/(app)/qrcode/login/[sessionId].tsx create mode 100644 app/(app)/users/[userId]/[type].tsx create mode 100644 app/(auth)/_layout.tsx create mode 100644 app/(auth)/forgot-password.tsx create mode 100644 app/(auth)/login.tsx create mode 100644 app/(auth)/register.tsx create mode 100644 app/_layout.tsx create mode 100644 app/index.tsx create mode 100644 app/post/[postId].tsx create mode 100644 app/user/[userId].tsx rename src/{navigation/DesktopNavigator.tsx => app-navigation/AppDesktopShell.tsx} (57%) create mode 100644 src/app-navigation/AppRouteStack.tsx create mode 100644 src/components/common/AppBackButton.tsx delete mode 100644 src/infrastructure/navigation/hooks/useNavigationState.ts delete mode 100644 src/infrastructure/navigation/navigationService.ts delete mode 100644 src/infrastructure/navigation/types.ts delete mode 100644 src/navigation/AuthNavigator.tsx delete mode 100644 src/navigation/HomeNavigator.tsx delete mode 100644 src/navigation/MainNavigator.tsx delete mode 100644 src/navigation/MessageNavigator.tsx delete mode 100644 src/navigation/MobileTabNavigatorWithDelay.tsx delete mode 100644 src/navigation/ProfileNavigator.tsx delete mode 100644 src/navigation/RootNavigator.tsx delete mode 100644 src/navigation/ScheduleNavigator.tsx delete mode 100644 src/navigation/SimpleMobileTabNavigator.tsx delete mode 100644 src/navigation/TabNavigator.tsx create mode 100644 src/navigation/hrefs.ts create mode 100644 src/navigation/paramUtils.ts delete mode 100644 src/navigation/types.ts create mode 100644 src/services/messageVibrationService.ts create mode 100644 src/stores/routePayloadCache.ts diff --git a/App.tsx b/App.tsx index 771c3c4..d511c1d 100644 --- a/App.tsx +++ b/App.tsx @@ -1,137 +1,7 @@ /** - * 萝卜BBS - 主应用入口 - * 配置导航、主题、状态管理等 + * 应用入口已由 Expo Router 接管(package.json main: expo-router/entry)。 + * 全局 Provider 与根布局见 app/_layout.tsx。 */ - -import React, { useEffect, useRef } from 'react'; -import { AppState, AppStateStatus, Platform, StyleSheet } from 'react-native'; -import { StatusBar } from 'expo-status-bar'; -import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import { SafeAreaProvider } from 'react-native-safe-area-context'; -import { PaperProvider } from 'react-native-paper'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import * as Notifications from 'expo-notifications'; - -// 配置通知处理器 - 必须在应用启动时设置 -Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, // 即使在后台也要显示通知 - shouldPlaySound: true, // 播放声音 - shouldSetBadge: true, // 设置角标 - shouldShowBanner: true, // 显示横幅 - shouldShowList: true, // 显示通知列表 - }), -}); - -import MainNavigator from './src/navigation/MainNavigator'; -import { paperTheme } from './src/theme'; -import AppPromptBar from './src/components/common/AppPromptBar'; -import AppDialogHost from './src/components/common/AppDialogHost'; -import { installAlertOverride } from './src/services/alertOverride'; -// 数据库初始化移到 authStore 中,根据用户ID创建用户专属数据库 - -// 创建 QueryClient 实例 -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: 2, - staleTime: 5 * 60 * 1000, // 5分钟 - }, - }, -}); - -installAlertOverride(); - -// 注入全局CSS来移除React Native Web在浏览器中的默认focus outline -if (Platform.OS === 'web') { - const style = document.createElement('style'); - style.textContent = ` - /* 移除TextInput focus时的浏览器默认outline */ - input:focus, textarea:focus, select:focus { - outline: none !important; - outline-width: 0 !important; - box-shadow: none !important; - } - - /* 移除focus-visible时的默认outline */ - input:focus-visible, textarea:focus-visible { - outline: none !important; - } - - /* 移除所有:focus相关的默认样式 */ - *:focus { - outline: none !important; - } - - *:focus-visible { - outline: none !important; - } - `; - document.head.appendChild(style); -} - export default function App() { - const appState = useRef(AppState.currentState); - const notificationResponseListener = useRef(null); - - // 系统通知功能初始化 - useEffect(() => { - const initNotifications = async () => { - const { systemNotificationService } = await import('./src/services/systemNotificationService'); - await systemNotificationService.initialize(); - - // 初始化后台保活服务 - const { initBackgroundService } = await import('./src/services/backgroundService'); - await initBackgroundService(); - - // 监听 App 状态变化 - const subscription = AppState.addEventListener('change', (nextAppState) => { - if ( - appState.current.match(/inactive|background/) && - nextAppState === 'active' - ) { - systemNotificationService.clearBadge(); - } - appState.current = nextAppState; - }); - - // 监听通知点击响应 - notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener( - (response) => { - const data = response.notification.request.content.data; - void data; - } - ); - - // 监听通知到达(用于前台显示时额外处理) - const notificationReceivedSubscription = Notifications.addNotificationReceivedListener( - (notification) => { - void notification; - } - ); - - return () => { - subscription.remove(); - notificationResponseListener.current?.remove(); - notificationReceivedSubscription.remove(); - }; - }; - - initNotifications(); - }, []); - - return ( - - - - - - - - - - - - - ); + return null; } diff --git a/app/(app)/(tabs)/_layout.tsx b/app/(app)/(tabs)/_layout.tsx new file mode 100644 index 0000000..afbc258 --- /dev/null +++ b/app/(app)/(tabs)/_layout.tsx @@ -0,0 +1,96 @@ +import { Platform, useWindowDimensions } from 'react-native'; +import { Tabs } from 'expo-router'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { colors, shadows } from '../../../src/theme'; +import { BREAKPOINTS } from '../../../src/hooks/useResponsive'; +import { useTotalUnreadCount } from '../../../src/stores'; + +const TAB_BAR_HEIGHT = 64; +const TAB_BAR_MARGIN = 14; +const TAB_BAR_FLOATING_MARGIN = 12; + +export default function TabsLayout() { + const { width } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const unreadCount = useTotalUnreadCount(); + const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop; + + return ( + + ( + + ), + }} + /> + 0 ? (unreadCount > 99 ? 99 : unreadCount) : undefined, + tabBarIcon: ({ color, focused }) => ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} diff --git a/app/(app)/(tabs)/home/_layout.tsx b/app/(app)/(tabs)/home/_layout.tsx new file mode 100644 index 0000000..e30c650 --- /dev/null +++ b/app/(app)/(tabs)/home/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function HomeStackLayout() { + return ; +} diff --git a/app/(app)/(tabs)/home/index.tsx b/app/(app)/(tabs)/home/index.tsx new file mode 100644 index 0000000..3b9f7d4 --- /dev/null +++ b/app/(app)/(tabs)/home/index.tsx @@ -0,0 +1,5 @@ +import { HomeScreen } from '../../../../src/screens/home'; + +export default function HomeRoute() { + return ; +} diff --git a/app/(app)/(tabs)/home/search.tsx b/app/(app)/(tabs)/home/search.tsx new file mode 100644 index 0000000..defc617 --- /dev/null +++ b/app/(app)/(tabs)/home/search.tsx @@ -0,0 +1,5 @@ +import { SearchScreen } from '../../../../src/screens/home'; + +export default function HomeSearchRoute() { + return ; +} diff --git a/app/(app)/(tabs)/messages/_layout.tsx b/app/(app)/(tabs)/messages/_layout.tsx new file mode 100644 index 0000000..9e34be5 --- /dev/null +++ b/app/(app)/(tabs)/messages/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function MessagesStackLayout() { + return ; +} diff --git a/app/(app)/(tabs)/messages/index.tsx b/app/(app)/(tabs)/messages/index.tsx new file mode 100644 index 0000000..032f6c9 --- /dev/null +++ b/app/(app)/(tabs)/messages/index.tsx @@ -0,0 +1,5 @@ +import { MessageListScreen } from '../../../../src/screens/message'; + +export default function MessagesRoute() { + return ; +} diff --git a/app/(app)/(tabs)/messages/notifications.tsx b/app/(app)/(tabs)/messages/notifications.tsx new file mode 100644 index 0000000..6a60ecc --- /dev/null +++ b/app/(app)/(tabs)/messages/notifications.tsx @@ -0,0 +1,5 @@ +import { NotificationsScreen } from '../../../../src/screens/message'; + +export default function NotificationsRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/_layout.tsx b/app/(app)/(tabs)/profile/_layout.tsx new file mode 100644 index 0000000..b87d4a1 --- /dev/null +++ b/app/(app)/(tabs)/profile/_layout.tsx @@ -0,0 +1,36 @@ +import { Stack } from 'expo-router'; +import { useRouter } from 'expo-router'; + +import { AppBackButton } from '../../../../src/components/common'; +import { colors } from '../../../../src/theme'; + +const headerOptions = { + headerStyle: { backgroundColor: colors.background.paper }, + headerTintColor: colors.text.primary, + headerTitleStyle: { fontWeight: '600' as const }, + headerShadowVisible: false, + headerBackTitle: '', +}; + +export default function ProfileStackLayout() { + const router = useRouter(); + + return ( + router.back()} />, + }} + > + + + + + + + + + + ); +} diff --git a/app/(app)/(tabs)/profile/account-security.tsx b/app/(app)/(tabs)/profile/account-security.tsx new file mode 100644 index 0000000..2eac261 --- /dev/null +++ b/app/(app)/(tabs)/profile/account-security.tsx @@ -0,0 +1,5 @@ +import { AccountSecurityScreen } from '../../../../src/screens/profile'; + +export default function AccountSecurityRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/blocked-users.tsx b/app/(app)/(tabs)/profile/blocked-users.tsx new file mode 100644 index 0000000..fb23915 --- /dev/null +++ b/app/(app)/(tabs)/profile/blocked-users.tsx @@ -0,0 +1,5 @@ +import { BlockedUsersScreen } from '../../../../src/screens/profile'; + +export default function BlockedUsersRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/bookmarks.tsx b/app/(app)/(tabs)/profile/bookmarks.tsx new file mode 100644 index 0000000..5fa89d9 --- /dev/null +++ b/app/(app)/(tabs)/profile/bookmarks.tsx @@ -0,0 +1,5 @@ +import { ProfileScreen } from '../../../../src/screens/profile'; + +export default function BookmarksRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/edit-profile.tsx b/app/(app)/(tabs)/profile/edit-profile.tsx new file mode 100644 index 0000000..b440978 --- /dev/null +++ b/app/(app)/(tabs)/profile/edit-profile.tsx @@ -0,0 +1,5 @@ +import { EditProfileScreen } from '../../../../src/screens/profile'; + +export default function EditProfileRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/index.tsx b/app/(app)/(tabs)/profile/index.tsx new file mode 100644 index 0000000..7944f71 --- /dev/null +++ b/app/(app)/(tabs)/profile/index.tsx @@ -0,0 +1,5 @@ +import { ProfileScreen } from '../../../../src/screens/profile'; + +export default function ProfileRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/my-posts.tsx b/app/(app)/(tabs)/profile/my-posts.tsx new file mode 100644 index 0000000..eeb11f9 --- /dev/null +++ b/app/(app)/(tabs)/profile/my-posts.tsx @@ -0,0 +1,5 @@ +import { ProfileScreen } from '../../../../src/screens/profile'; + +export default function MyPostsRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/notification-settings.tsx b/app/(app)/(tabs)/profile/notification-settings.tsx new file mode 100644 index 0000000..0907e61 --- /dev/null +++ b/app/(app)/(tabs)/profile/notification-settings.tsx @@ -0,0 +1,5 @@ +import { NotificationSettingsScreen } from '../../../../src/screens/profile'; + +export default function NotificationSettingsRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/settings.tsx b/app/(app)/(tabs)/profile/settings.tsx new file mode 100644 index 0000000..ec2060d --- /dev/null +++ b/app/(app)/(tabs)/profile/settings.tsx @@ -0,0 +1,5 @@ +import { SettingsScreen } from '../../../../src/screens/profile'; + +export default function SettingsRoute() { + return ; +} diff --git a/app/(app)/(tabs)/schedule/_layout.tsx b/app/(app)/(tabs)/schedule/_layout.tsx new file mode 100644 index 0000000..66385c9 --- /dev/null +++ b/app/(app)/(tabs)/schedule/_layout.tsx @@ -0,0 +1,17 @@ +import { Stack } from 'expo-router'; + +export default function ScheduleStackLayout() { + return ( + + + + + ); +} diff --git a/app/(app)/(tabs)/schedule/course.tsx b/app/(app)/(tabs)/schedule/course.tsx new file mode 100644 index 0000000..a03e316 --- /dev/null +++ b/app/(app)/(tabs)/schedule/course.tsx @@ -0,0 +1,5 @@ +import { CourseDetailScreen } from '../../../../src/screens/schedule'; + +export default function CourseDetailRoute() { + return ; +} diff --git a/app/(app)/(tabs)/schedule/index.tsx b/app/(app)/(tabs)/schedule/index.tsx new file mode 100644 index 0000000..120f50f --- /dev/null +++ b/app/(app)/(tabs)/schedule/index.tsx @@ -0,0 +1,5 @@ +import { ScheduleScreen } from '../../../../src/screens/schedule'; + +export default function ScheduleRoute() { + return ; +} diff --git a/app/(app)/_layout.tsx b/app/(app)/_layout.tsx new file mode 100644 index 0000000..00991db --- /dev/null +++ b/app/(app)/_layout.tsx @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; +import { Redirect } from 'expo-router'; + +import { AppRouteStack } from '../../src/app-navigation/AppRouteStack'; +import { messageManager, useAuthStore } from '../../src/stores'; + +export default function AppLayout() { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + useEffect(() => { + messageManager.initialize(); + }, []); + + if (!isAuthenticated) { + return ; + } + + return ; +} diff --git a/app/(app)/_layout.web.tsx b/app/(app)/_layout.web.tsx new file mode 100644 index 0000000..a8e5521 --- /dev/null +++ b/app/(app)/_layout.web.tsx @@ -0,0 +1,28 @@ +import { useEffect } from 'react'; +import { useWindowDimensions } from 'react-native'; +import { Redirect } from 'expo-router'; + +import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell'; +import { AppRouteStack } from '../../src/app-navigation/AppRouteStack'; +import { BREAKPOINTS } from '../../src/hooks/useResponsive'; +import { messageManager, useAuthStore } from '../../src/stores'; + +export default function AppLayoutWeb() { + const { width } = useWindowDimensions(); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const useDesktopShell = width >= BREAKPOINTS.desktop; + + useEffect(() => { + messageManager.initialize(); + }, []); + + if (!isAuthenticated) { + return ; + } + + if (useDesktopShell) { + return ; + } + + return ; +} diff --git a/app/(app)/chat/[conversationId].tsx b/app/(app)/chat/[conversationId].tsx new file mode 100644 index 0000000..f0bee9c --- /dev/null +++ b/app/(app)/chat/[conversationId].tsx @@ -0,0 +1,5 @@ +import { ChatScreen } from '../../../src/screens/message'; + +export default function ChatRoute() { + return ; +} diff --git a/app/(app)/chat/private-info.tsx b/app/(app)/chat/private-info.tsx new file mode 100644 index 0000000..e080eea --- /dev/null +++ b/app/(app)/chat/private-info.tsx @@ -0,0 +1,5 @@ +import { PrivateChatInfoScreen } from '../../../src/screens/message'; + +export default function PrivateChatInfoRoute() { + return ; +} diff --git a/app/(app)/group/[groupId]/index.tsx b/app/(app)/group/[groupId]/index.tsx new file mode 100644 index 0000000..4a54deb --- /dev/null +++ b/app/(app)/group/[groupId]/index.tsx @@ -0,0 +1,5 @@ +import { GroupInfoScreen } from '../../../../src/screens/message'; + +export default function GroupInfoRoute() { + return ; +} diff --git a/app/(app)/group/[groupId]/members.tsx b/app/(app)/group/[groupId]/members.tsx new file mode 100644 index 0000000..f0b2876 --- /dev/null +++ b/app/(app)/group/[groupId]/members.tsx @@ -0,0 +1,5 @@ +import { GroupMembersScreen } from '../../../../src/screens/message'; + +export default function GroupMembersRoute() { + return ; +} diff --git a/app/(app)/group/create.tsx b/app/(app)/group/create.tsx new file mode 100644 index 0000000..2dc7954 --- /dev/null +++ b/app/(app)/group/create.tsx @@ -0,0 +1,5 @@ +import { CreateGroupScreen } from '../../../src/screens/message'; + +export default function CreateGroupRoute() { + return ; +} diff --git a/app/(app)/group/invite.tsx b/app/(app)/group/invite.tsx new file mode 100644 index 0000000..462a0f6 --- /dev/null +++ b/app/(app)/group/invite.tsx @@ -0,0 +1,5 @@ +import GroupInviteDetailScreen from '../../../src/screens/message/GroupInviteDetailScreen'; + +export default function GroupInviteRoute() { + return ; +} diff --git a/app/(app)/group/join.tsx b/app/(app)/group/join.tsx new file mode 100644 index 0000000..7758296 --- /dev/null +++ b/app/(app)/group/join.tsx @@ -0,0 +1,5 @@ +import { JoinGroupScreen } from '../../../src/screens/message'; + +export default function JoinGroupRoute() { + return ; +} diff --git a/app/(app)/group/request.tsx b/app/(app)/group/request.tsx new file mode 100644 index 0000000..67b463d --- /dev/null +++ b/app/(app)/group/request.tsx @@ -0,0 +1,5 @@ +import GroupRequestDetailScreen from '../../../src/screens/message/GroupRequestDetailScreen'; + +export default function GroupRequestRoute() { + return ; +} diff --git a/app/(app)/posts/create.tsx b/app/(app)/posts/create.tsx new file mode 100644 index 0000000..1f11170 --- /dev/null +++ b/app/(app)/posts/create.tsx @@ -0,0 +1,5 @@ +import { CreatePostScreen } from '../../../src/screens/create'; + +export default function CreatePostRoute() { + return ; +} diff --git a/app/(app)/qrcode/login/[sessionId].tsx b/app/(app)/qrcode/login/[sessionId].tsx new file mode 100644 index 0000000..2614da6 --- /dev/null +++ b/app/(app)/qrcode/login/[sessionId].tsx @@ -0,0 +1,5 @@ +import { QRCodeConfirmScreen } from '../../../../src/screens/auth/QRCodeConfirmScreen'; + +export default function QrLoginConfirmRoute() { + return ; +} diff --git a/app/(app)/users/[userId]/[type].tsx b/app/(app)/users/[userId]/[type].tsx new file mode 100644 index 0000000..fd1ab1a --- /dev/null +++ b/app/(app)/users/[userId]/[type].tsx @@ -0,0 +1,5 @@ +import FollowListScreen from '../../../../src/screens/profile/FollowListScreen'; + +export default function FollowListRoute() { + return ; +} diff --git a/app/(auth)/_layout.tsx b/app/(auth)/_layout.tsx new file mode 100644 index 0000000..5c5737d --- /dev/null +++ b/app/(auth)/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function AuthLayout() { + return ; +} diff --git a/app/(auth)/forgot-password.tsx b/app/(auth)/forgot-password.tsx new file mode 100644 index 0000000..a819c45 --- /dev/null +++ b/app/(auth)/forgot-password.tsx @@ -0,0 +1,5 @@ +import { ForgotPasswordScreen } from '../../src/screens/auth'; + +export default function ForgotPasswordRoute() { + return ; +} diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx new file mode 100644 index 0000000..91b576f --- /dev/null +++ b/app/(auth)/login.tsx @@ -0,0 +1,5 @@ +import { LoginScreen } from '../../src/screens/auth'; + +export default function LoginRoute() { + return ; +} diff --git a/app/(auth)/register.tsx b/app/(auth)/register.tsx new file mode 100644 index 0000000..fb04e28 --- /dev/null +++ b/app/(auth)/register.tsx @@ -0,0 +1,5 @@ +import { RegisterScreen } from '../../src/screens/auth'; + +export default function RegisterRoute() { + return ; +} diff --git a/app/_layout.tsx b/app/_layout.tsx new file mode 100644 index 0000000..1cdf676 --- /dev/null +++ b/app/_layout.tsx @@ -0,0 +1,173 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { AppState, AppStateStatus, Platform, View, ActivityIndicator, StyleSheet } from 'react-native'; +import { Stack, useRouter } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { PaperProvider } from 'react-native-paper'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import * as Notifications from 'expo-notifications'; + +import { paperTheme } from '../src/theme'; +import { AppBackButton } from '../src/components/common'; +import AppPromptBar from '../src/components/common/AppPromptBar'; +import AppDialogHost from '../src/components/common/AppDialogHost'; +import { installAlertOverride } from '../src/services/alertOverride'; +import { useAuthStore } from '../src/stores'; +import { colors } from '../src/theme'; + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: true, + shouldShowBanner: true, + shouldShowList: true, + }), +}); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 2, + staleTime: 5 * 60 * 1000, + }, + }, +}); + +installAlertOverride(); + +if (Platform.OS === 'web' && typeof document !== 'undefined') { + const style = document.createElement('style'); + style.textContent = ` + input:focus, textarea:focus, select:focus { + outline: none !important; + outline-width: 0 !important; + box-shadow: none !important; + } + input:focus-visible, textarea:focus-visible { + outline: none !important; + } + *:focus { outline: none !important; } + *:focus-visible { outline: none !important; } + `; + document.head.appendChild(style); +} + +function SessionGate({ children }: { children: React.ReactNode }) { + const [ready, setReady] = useState(false); + const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser); + + useEffect(() => { + fetchCurrentUser().finally(() => setReady(true)); + }, [fetchCurrentUser]); + + if (!ready) { + return ( + + + + ); + } + return <>{children}; +} + +function NotificationBootstrap() { + const appState = useRef(AppState.currentState); + const notificationResponseListener = useRef(null); + + useEffect(() => { + const initNotifications = async () => { + const { systemNotificationService } = await import('../src/services/systemNotificationService'); + await systemNotificationService.initialize(); + const { initBackgroundService } = await import('../src/services/backgroundService'); + await initBackgroundService(); + + const subscription = AppState.addEventListener('change', (nextAppState) => { + if (appState.current.match(/inactive|background/) && nextAppState === 'active') { + systemNotificationService.clearBadge(); + } + appState.current = nextAppState; + }); + + notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener( + (response) => { + void response.notification.request.content.data; + } + ); + + const notificationReceivedSubscription = Notifications.addNotificationReceivedListener( + (notification) => { + void notification; + } + ); + + return () => { + subscription.remove(); + notificationResponseListener.current?.remove(); + notificationReceivedSubscription.remove(); + }; + }; + + initNotifications(); + }, []); + + return null; +} + +export default function RootLayout() { + const router = useRouter(); + + return ( + + + + + + + + + + + + router.back()} />, + }} + /> + router.back()} />, + }} + /> + + + + + + + + + ); +} + +const gateStyles = StyleSheet.create({ + loading: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.background.default, + }, +}); diff --git a/app/index.tsx b/app/index.tsx new file mode 100644 index 0000000..f412813 --- /dev/null +++ b/app/index.tsx @@ -0,0 +1,11 @@ +import { Redirect } from 'expo-router'; + +import { useAuthStore } from '../src/stores'; + +export default function Index() { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + if (isAuthenticated) { + return ; + } + return ; +} diff --git a/app/post/[postId].tsx b/app/post/[postId].tsx new file mode 100644 index 0000000..4ac907f --- /dev/null +++ b/app/post/[postId].tsx @@ -0,0 +1,5 @@ +import { PostDetailScreen } from '../../src/screens/home'; + +export default function PostDetailRoute() { + return ; +} diff --git a/app/user/[userId].tsx b/app/user/[userId].tsx new file mode 100644 index 0000000..12313c2 --- /dev/null +++ b/app/user/[userId].tsx @@ -0,0 +1,5 @@ +import { UserScreen } from '../../src/screens/profile'; + +export default function UserProfileRoute() { + return ; +} diff --git a/index.ts b/index.ts index 1d6e981..5b83418 100644 --- a/index.ts +++ b/index.ts @@ -1,8 +1 @@ -import { registerRootComponent } from 'expo'; - -import App from './App'; - -// registerRootComponent calls AppRegistry.registerComponent('main', () => App); -// It also ensures that whether you load the app in Expo Go or in a native build, -// the environment is set up appropriately -registerRootComponent(App); +import 'expo-router/entry'; diff --git a/package-lock.json b/package-lock.json index 507e99e..076cc9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ "axios": "^1.13.6", "date-fns": "^4.1.0", "expo": "~55.0.4", - "expo-background-fetch": "~55.0.9", + "expo-background-fetch": "~55.0.10", + "expo-background-task": "~55.0.10", "expo-camera": "^55.0.10", "expo-constants": "~55.0.7", "expo-dev-client": "~55.0.10", @@ -5542,6 +5543,18 @@ "expo": "*" } }, + "node_modules/expo-background-task": { + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/expo-background-task/-/expo-background-task-55.0.10.tgz", + "integrity": "sha512-mkwdS8T34P0pSphr08yv9lsQ6pd1pMgFdVq0wd3Ane3h5heZj/il6mes/b8OQDoxtD3mLFj7yKDYOU9dAwSrJA==", + "license": "MIT", + "dependencies": { + "expo-task-manager": "~55.0.10" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-camera": { "version": "55.0.10", "resolved": "https://registry.npmmirror.com/expo-camera/-/expo-camera-55.0.10.tgz", diff --git a/package.json b/package.json index 144167c..cd26ced 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "carrot_bbs", "version": "1.0.1", - "main": "index.ts", + "main": "expo-router/entry", "scripts": { "start": "expo start", "android": "expo run:android", @@ -25,7 +25,8 @@ "axios": "^1.13.6", "date-fns": "^4.1.0", "expo": "~55.0.4", - "expo-background-fetch": "~55.0.9", + "expo-background-fetch": "~55.0.10", + "expo-background-task": "~55.0.10", "expo-camera": "^55.0.10", "expo-constants": "~55.0.7", "expo-dev-client": "~55.0.10", diff --git a/src/navigation/DesktopNavigator.tsx b/src/app-navigation/AppDesktopShell.tsx similarity index 57% rename from src/navigation/DesktopNavigator.tsx rename to src/app-navigation/AppDesktopShell.tsx index e58db7c..a807f98 100644 --- a/src/navigation/DesktopNavigator.tsx +++ b/src/app-navigation/AppDesktopShell.tsx @@ -1,107 +1,91 @@ -/** - * 桌面端导航器 - * 为平板/桌面设备提供侧边栏导航体验 - */ -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { View, StyleSheet, ScrollView, TouchableOpacity, Text, - Animated, Platform, } from 'react-native'; import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { usePathname, useRouter } from 'expo-router'; -import type { TabName, NavItemConfig } from '../infrastructure/navigation/types'; -import { NAVIGATION_CONSTANTS } from '../infrastructure/navigation/types'; import { colors, shadows } from '../theme'; -import { useNavigationState } from '../infrastructure/navigation/hooks/useNavigationState'; +import { useTotalUnreadCount } from '../stores'; +import { AppRouteStack } from './AppRouteStack'; -// 导入屏幕 -import { HomeScreen } from '../screens/home'; -import { ScheduleScreen } from '../screens/schedule'; -import { MessageListScreen } from '../screens/message'; -import { ProfileScreen } from '../screens/profile'; +type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; -// 侧边栏常量 -const { SIDEBAR_WIDTH_DESKTOP, SIDEBAR_WIDTH_TABLET, SIDEBAR_COLLAPSED_WIDTH } = NAVIGATION_CONSTANTS; +const SIDEBAR_WIDTH_DESKTOP = 240; +const SIDEBAR_WIDTH_TABLET = 200; +const SIDEBAR_COLLAPSED_WIDTH = 72; -// 导航项配置 -const NAV_ITEMS: NavItemConfig[] = [ - { name: 'HomeTab', label: '首页', icon: 'home', iconOutline: 'home-outline' }, - { name: 'MessageTab', label: '消息', icon: 'message-text', iconOutline: 'message-text-outline' }, - { name: 'ScheduleTab', label: '课表', icon: 'calendar-today', iconOutline: 'calendar-today' }, - { name: 'ProfileTab', label: '我的', icon: 'account', iconOutline: 'account-outline' }, +const NAV_ITEMS: { name: TabName; label: string; href: string; icon: string; iconOutline: string }[] = [ + { name: 'HomeTab', label: '首页', href: '/home', icon: 'home', iconOutline: 'home-outline' }, + { name: 'MessageTab', label: '消息', href: '/messages', icon: 'message-text', iconOutline: 'message-text-outline' }, + { name: 'ScheduleTab', label: '课表', href: '/schedule', icon: 'calendar-today', iconOutline: 'calendar-today' }, + { name: 'ProfileTab', label: '我的', href: '/profile', icon: 'account', iconOutline: 'account-outline' }, ]; -interface DesktopNavigatorProps { - unreadCount?: number; +function pathToTab(pathname: string): TabName { + if (pathname.startsWith('/messages')) return 'MessageTab'; + if (pathname.startsWith('/schedule')) return 'ScheduleTab'; + if (pathname.startsWith('/profile')) return 'ProfileTab'; + if (pathname.startsWith('/home')) return 'HomeTab'; + return 'HomeTab'; } -export function DesktopNavigator({ unreadCount = 0 }: DesktopNavigatorProps) { +export function AppDesktopShell() { const insets = useSafeAreaInsets(); - const { currentTab, isCollapsed, setCurrentTab, toggleCollapse, setIsReady } = useNavigationState(); + const router = useRouter(); + const pathname = usePathname(); + const unreadCount = useTotalUnreadCount(); + const [isCollapsed, setIsCollapsed] = useState(false); const [isDesktop, setIsDesktop] = useState(false); - // 检测是否是桌面尺寸 useEffect(() => { - const checkDesktop = () => { - const width = window.innerWidth || document.documentElement.clientWidth; - setIsDesktop(width >= SIDEBAR_WIDTH_DESKTOP); + if (Platform.OS !== 'web') return; + const check = () => { + const w = window.innerWidth || document.documentElement.clientWidth; + setIsDesktop(w >= SIDEBAR_WIDTH_DESKTOP); }; - - checkDesktop(); - window.addEventListener('resize', checkDesktop); - setIsReady(true); - - return () => window.removeEventListener('resize', checkDesktop); - }, [setIsReady]); + check(); + window.addEventListener('resize', check); + return () => window.removeEventListener('resize', check); + }, []); - // 计算侧边栏宽度 - const sidebarWidth = isCollapsed ? SIDEBAR_COLLAPSED_WIDTH : (isDesktop ? SIDEBAR_WIDTH_DESKTOP : SIDEBAR_WIDTH_TABLET); + const sidebarWidth = isCollapsed + ? SIDEBAR_COLLAPSED_WIDTH + : isDesktop + ? SIDEBAR_WIDTH_DESKTOP + : SIDEBAR_WIDTH_TABLET; - // 处理 Tab 切换 - const handleTabChange = useCallback((tab: TabName) => { - setCurrentTab(tab); - }, [setCurrentTab]); + const currentTab = pathToTab(pathname || '/home'); - // 渲染当前 Tab 的内容 - const renderTabContent = () => { - switch (currentTab) { - case 'HomeTab': - return ; - case 'MessageTab': - return ; - case 'ScheduleTab': - return ; - case 'ProfileTab': - return ; - default: - return ; - } - }; + const handleTabChange = useCallback( + (href: string) => { + router.replace(href); + }, + [router] + ); return ( - {/* 侧边栏 */} - - {/* Logo 区域 */} + - {!isCollapsed && ( - 胡萝卜BBS - )} + {!isCollapsed && 胡萝卜BBS} - - {/* 导航项 */} {NAV_ITEMS.map((item) => { const isActive = currentTab === item.name; const showBadge = item.name === 'MessageTab' && unreadCount > 0; - return ( handleTabChange(item.name)} + onPress={() => handleTabChange(item.href)} activeOpacity={0.7} > - {showBadge && ( + {showBadge ? ( {unreadCount > 99 ? '99+' : unreadCount} - )} + ) : null} - {!isCollapsed && ( - - {item.label} - - )} + {!isCollapsed ? ( + {item.label} + ) : null} ); })} - - {/* 折叠按钮 */} setIsCollapsed((c) => !c)} activeOpacity={0.7} > - - {/* 主内容区域 */} - {renderTabContent()} + ); diff --git a/src/app-navigation/AppRouteStack.tsx b/src/app-navigation/AppRouteStack.tsx new file mode 100644 index 0000000..5ea6fed --- /dev/null +++ b/src/app-navigation/AppRouteStack.tsx @@ -0,0 +1,8 @@ +import { Stack } from 'expo-router'; + +/** + * 已登录区内栈:由 app/(app) 下文件系统自动注册子路由 + */ +export function AppRouteStack() { + return ; +} diff --git a/src/components/business/CommentItem.tsx b/src/components/business/CommentItem.tsx index a0c5b0d..c42ac2b 100644 --- a/src/components/business/CommentItem.tsx +++ b/src/components/business/CommentItem.tsx @@ -329,18 +329,18 @@ const CommentItem: React.FC = ({ {/* 子评论操作按钮 */} onReplyPress?.(reply)} > - + 回复 {/* 删除按钮 - 子评论作者可见 */} {isSubReplyAuthor && ( handleSubReplyDelete(reply)} disabled={isDeleting} > @@ -349,7 +349,7 @@ const CommentItem: React.FC = ({ size={12} color={colors.text.hint} /> - + {isDeleting ? '删除中' : '删除'} @@ -476,21 +476,26 @@ const CommentItem: React.FC = ({ const styles = StyleSheet.create({ container: { flexDirection: 'row', - paddingVertical: spacing.sm, + paddingTop: spacing.md, + paddingBottom: spacing.xs, paddingHorizontal: spacing.lg, - backgroundColor: colors.background.paper, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.divider, + backgroundColor: 'transparent', }, content: { flex: 1, marginLeft: spacing.sm, + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', - marginBottom: spacing.xs, + marginBottom: spacing.sm, }, userInfo: { flexDirection: 'row', @@ -537,7 +542,9 @@ const styles = StyleSheet.create({ backgroundColor: colors.background.default, paddingHorizontal: spacing.xs, paddingVertical: 2, - borderRadius: borderRadius.sm, + borderRadius: 999, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, }, floorText: { fontSize: fontSizes.xs, @@ -546,7 +553,7 @@ const styles = StyleSheet.create({ marginBottom: spacing.xs, }, commentContent: { - marginBottom: spacing.xs, + marginBottom: spacing.sm, }, text: { lineHeight: 20, @@ -559,17 +566,24 @@ const styles = StyleSheet.create({ actionButton: { flexDirection: 'row', alignItems: 'center', - marginRight: spacing.md, - paddingVertical: spacing.xs, + marginRight: spacing.sm, + paddingHorizontal: spacing.sm, + paddingVertical: 5, + borderRadius: 999, + backgroundColor: colors.background.default, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, }, actionText: { - marginLeft: 2, + marginLeft: 4, fontSize: fontSizes.xs, }, subRepliesContainer: { marginTop: spacing.sm, backgroundColor: colors.background.default, - borderRadius: borderRadius.md, + borderRadius: borderRadius.lg, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, padding: spacing.sm, }, subReplyItem: { @@ -607,6 +621,16 @@ const styles = StyleSheet.create({ alignItems: 'center', marginTop: spacing.xs, }, + subActionButton: { + flexDirection: 'row', + alignItems: 'center', + marginRight: spacing.sm, + paddingVertical: 2, + }, + subActionText: { + marginLeft: 2, + fontSize: fontSizes.xs, + }, }); export default CommentItem; diff --git a/src/components/business/QRCodeScanner.tsx b/src/components/business/QRCodeScanner.tsx index 5a007e7..a83f370 100644 --- a/src/components/business/QRCodeScanner.tsx +++ b/src/components/business/QRCodeScanner.tsx @@ -9,23 +9,20 @@ import { Dimensions, } from 'react-native'; import { CameraView, useCameraPermissions } from 'expo-camera'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { RootStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; const { width, height } = Dimensions.get('window'); -type NavigationProp = NativeStackNavigationProp; - interface QRCodeScannerProps { visible: boolean; onClose: () => void; } export const QRCodeScanner: React.FC = ({ visible, onClose }) => { - const navigation = useNavigation(); + const router = useRouter(); const [permission, requestPermission] = useCameraPermissions(); const [scanned, setScanned] = useState(false); @@ -52,7 +49,7 @@ export const QRCodeScanner: React.FC = ({ visible, onClose } const sessionId = extractSessionId(data); if (sessionId) { // 跳转到确认页面 - navigation.navigate('QRCodeConfirm', { sessionId }); + router.push(hrefs.hrefQrLoginConfirm(sessionId)); } else { Alert.alert('无效的二维码', '无法识别该二维码'); } diff --git a/src/components/business/UserProfileHeader.tsx b/src/components/business/UserProfileHeader.tsx index d680ae0..80d4e20 100644 --- a/src/components/business/UserProfileHeader.tsx +++ b/src/components/business/UserProfileHeader.tsx @@ -236,22 +236,22 @@ const UserProfileHeader: React.FC = ({ {/* 个人信息标签 */} - {user.location && ( + {user.location?.trim() ? ( {user.location} - )} - {user.website && ( + ) : null} + {user.website?.trim() ? ( {user.website.replace(/^https?:\/\//, '')} - )} + ) : null} diff --git a/src/components/common/AppBackButton.tsx b/src/components/common/AppBackButton.tsx new file mode 100644 index 0000000..2e40bc7 --- /dev/null +++ b/src/components/common/AppBackButton.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { TouchableOpacity, StyleProp, ViewStyle, StyleSheet } from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; + +import { borderRadius, colors, spacing } from '../../theme'; + +type AppBackButtonIcon = 'arrow-left' | 'close'; + +interface AppBackButtonProps { + onPress: () => void; + icon?: AppBackButtonIcon; + style?: StyleProp; + iconColor?: string; + backgroundColor?: string; + hitSlop?: number; + testID?: string; +} + +const AppBackButton: React.FC = ({ + onPress, + icon = 'arrow-left', + style, + iconColor = colors.text.primary, + backgroundColor = colors.background.paper, + hitSlop = 8, + testID, +}) => { + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + button: { + width: 36, + height: 36, + borderRadius: borderRadius.full, + alignItems: 'center', + justifyContent: 'center', + marginLeft: spacing.xs, + }, +}); + +export default AppBackButton; diff --git a/src/components/common/ImageGallery.tsx b/src/components/common/ImageGallery.tsx index ad71b5c..53f6451 100644 --- a/src/components/common/ImageGallery.tsx +++ b/src/components/common/ImageGallery.tsx @@ -87,6 +87,7 @@ export const ImageGallery: React.FC = ({ const closeTimerRef = useRef | null>(null); const isClosingRef = useRef(false); const currentIndexRef = useRef(initialIndex); + const pendingSwipeDirectionRef = useRef<-1 | 1 | null>(null); const [currentIndex, setCurrentIndex] = useState(initialIndex); currentIndexRef.current = currentIndex; const [showControls, setShowControls] = useState(true); @@ -173,6 +174,14 @@ export const ImageGallery: React.FC = ({ [validImages.length, onIndexChange] ); + const updateIndexFromSwipe = useCallback( + (newIndex: number, direction: -1 | 1) => { + pendingSwipeDirectionRef.current = direction; + updateIndex(newIndex); + }, + [updateIndex] + ); + const toggleControls = useCallback(() => { setShowControls(prev => !prev); }, []); @@ -189,18 +198,6 @@ export const ImageGallery: React.FC = ({ }, 120); }, [onClose]); - const goToPrev = useCallback(() => { - if (currentIndex > 0) { - updateIndex(currentIndex - 1); - } - }, [currentIndex, updateIndex]); - - const goToNext = useCallback(() => { - if (currentIndex < validImages.length - 1) { - updateIndex(currentIndex + 1); - } - }, [currentIndex, validImages.length, updateIndex]); - // 显示短暂提示 const showToast = useCallback((type: 'success' | 'error') => { setSaveToast(type); @@ -269,6 +266,38 @@ export const ImageGallery: React.FC = ({ // 滑动切换图片相关状态 const swipeTranslateX = useSharedValue(0); + // 滑动切图完成后,让新图从目标方向进入,避免旧图先弹回导致前后图闪烁 + useEffect(() => { + const direction = pendingSwipeDirectionRef.current; + if (direction == null) { + return; + } + pendingSwipeDirectionRef.current = null; + swipeTranslateX.value = -direction * SCREEN_WIDTH; + swipeTranslateX.value = withTiming(0, { duration: 180 }); + }, [currentIndex, swipeTranslateX]); + + const switchWithDirection = useCallback( + (targetIndex: number, direction: -1 | 1) => { + swipeTranslateX.value = withTiming(direction * SCREEN_WIDTH, { duration: 200 }, () => { + runOnJS(updateIndexFromSwipe)(targetIndex, direction); + }); + }, + [swipeTranslateX, updateIndexFromSwipe] + ); + + const goToPrev = useCallback(() => { + if (currentIndex > 0) { + switchWithDirection(currentIndex - 1, 1); + } + }, [currentIndex, switchWithDirection]); + + const goToNext = useCallback(() => { + if (currentIndex < validImages.length - 1) { + switchWithDirection(currentIndex + 1, -1); + } + }, [currentIndex, validImages.length, switchWithDirection]); + // 统一的滑动手势:放大时拖动,未放大时切换图片 const panGesture = Gesture.Pan() .activeOffsetX([-10, 10]) // 水平方向需要移动10pt才激活,避免与点击冲突 @@ -309,14 +338,12 @@ export const ImageGallery: React.FC = ({ if (shouldGoNext && currentIndex < validImages.length - 1) { // 向左滑动,显示下一张 swipeTranslateX.value = withTiming(-SCREEN_WIDTH, { duration: 200 }, () => { - runOnJS(updateIndex)(currentIndex + 1); - swipeTranslateX.value = 0; + runOnJS(updateIndexFromSwipe)(currentIndex + 1, -1); }); } else if (shouldGoPrev && currentIndex > 0) { // 向右滑动,显示上一张 swipeTranslateX.value = withTiming(SCREEN_WIDTH, { duration: 200 }, () => { - runOnJS(updateIndex)(currentIndex - 1); - swipeTranslateX.value = 0; + runOnJS(updateIndexFromSwipe)(currentIndex - 1, 1); }); } else { // 回弹到原位 diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 57bb5e1..6b737c4 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -16,6 +16,7 @@ export { default as ResponsiveContainer } from './ResponsiveContainer'; export { default as ResponsiveGrid } from './ResponsiveGrid'; export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack'; export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout'; +export { default as AppBackButton } from './AppBackButton'; // 图片相关组件 export { default as SmartImage } from './SmartImage'; diff --git a/src/infrastructure/navigation/hooks/useNavigationState.ts b/src/infrastructure/navigation/hooks/useNavigationState.ts deleted file mode 100644 index 2792e37..0000000 --- a/src/infrastructure/navigation/hooks/useNavigationState.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * 导航状态管理 Hook - * 管理导航相关状态,如当前路由、导航历史等 - */ - -import { useState, useCallback, useEffect } from 'react'; -import { useNavigationState as useRNNavigationState, Route } from '@react-navigation/native'; -import type { RootStackParamList } from '../../../navigation/types'; - -type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; - -interface NavigationState { - currentTab: TabName; - isCollapsed: boolean; - isReady: boolean; -} - -interface NavigationActions { - setCurrentTab: (tab: TabName) => void; - toggleCollapse: () => void; - setIsReady: (ready: boolean) => void; -} - -/** - * 导航状态管理 Hook - */ -export function useNavigationState(): NavigationState & NavigationActions { - const [currentTab, setCurrentTab] = useState('HomeTab'); - const [isCollapsed, setIsCollapsed] = useState(false); - const [isReady, setIsReady] = useState(false); - - const toggleCollapse = useCallback(() => { - setIsCollapsed(prev => !prev); - }, []); - - return { - currentTab, - isCollapsed, - isReady, - setCurrentTab, - toggleCollapse, - setIsReady, - }; -} - -/** - * 获取当前路由名称 - */ -export function useCurrentRouteName(): string | null { - const routeName = useRNNavigationState(state => state?.routes[state.index]?.name ?? null); - return routeName; -} - -/** - * 检查是否在指定路由 - */ -export function useIsRoute(routeName: keyof RootStackParamList): boolean { - const currentRoute = useCurrentRouteName(); - return currentRoute === routeName; -} diff --git a/src/infrastructure/navigation/navigationService.ts b/src/infrastructure/navigation/navigationService.ts deleted file mode 100644 index 270f9cd..0000000 --- a/src/infrastructure/navigation/navigationService.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * 导航服务层 - * 提供全局导航功能,解耦组件与导航状态的直接依赖 - */ - -import { NavigationContainerRef, Route } from '@react-navigation/native'; - -class NavigationService { - private navigationRef: NavigationContainerRef | null = null; - private isReady: boolean = false; - - /** - * 设置导航引用 - */ - setNavigationRef(ref: NavigationContainerRef | null): void { - this.navigationRef = ref; - this.isReady = ref !== null; - } - - /** - * 检查导航是否就绪 - */ - isNavigationReady(): boolean { - return this.isReady && this.navigationRef !== null; - } - - /** - * 导航到指定路由 - */ - navigate(routeName: string, params?: any): void { - if (!this.isNavigationReady()) { - console.warn('[NavigationService] Navigation not ready'); - return; - } - this.navigationRef!.navigate(routeName as any, params); - } - - /** - * 返回上一页 - */ - goBack(): void { - if (!this.isNavigationReady()) { - console.warn('[NavigationService] Navigation not ready'); - return; - } - this.navigationRef!.goBack(); - } - - /** - * 获取当前路由 - */ - getCurrentRoute(): Route | null { - if (!this.isNavigationReady()) { - return null; - } - return this.navigationRef!.getCurrentRoute() ?? null; - } - - /** - * 获取当前路由名称 - */ - getCurrentRouteName(): string | null { - const route = this.getCurrentRoute(); - return route?.name ?? null; - } - - /** - * 重置导航到指定路由 - */ - reset(routes: { name: string; params?: any }[], index: number = 0): void { - if (!this.isNavigationReady()) { - console.warn('[NavigationService] Navigation not ready'); - return; - } - this.navigationRef!.reset({ - index, - routes: routes.map(r => ({ name: r.name, params: r.params })), - }); - } - - /** - * 替换当前路由 - */ - replace(routeName: string, params?: any): void { - if (!this.isNavigationReady()) { - console.warn('[NavigationService] Navigation not ready'); - return; - } - this.navigationRef!.dispatch({ - type: 'REPLACE', - payload: { name: routeName, params }, - }); - } - - /** - * 导航到根路由 - */ - navigateToRoot(): void { - this.reset([{ name: 'Main' }]); - } - - /** - * 导航到认证页面 - */ - navigateToAuth(): void { - this.reset([{ name: 'Auth' }]); - } -} - -export const navigationService = new NavigationService(); diff --git a/src/infrastructure/navigation/types.ts b/src/infrastructure/navigation/types.ts deleted file mode 100644 index 065edc6..0000000 --- a/src/infrastructure/navigation/types.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * 导航基础设施类型定义 - */ - -import { NavigationContainerRef, Route } from '@react-navigation/native'; -import type { - RootStackParamList, - MainTabParamList, - HomeStackParamList, - MessageStackParamList, - ScheduleStackParamList, - ProfileStackParamList, - AuthStackParamList, -} from '../../navigation/types'; - -// ==================== 导航服务类型 ==================== -export interface NavigationServiceInterface { - setNavigationRef(ref: NavigationContainerRef | null): void; - isNavigationReady(): boolean; - navigate(routeName: string, params?: any): void; - goBack(): void; - getCurrentRoute(): Route | null; - getCurrentRouteName(): string | null; - reset(routes: { name: string; params?: any }[], index?: number): void; - replace(routeName: string, params?: any): void; - navigateToRoot(): void; - navigateToAuth(): void; -} - -// ==================== Tab 类型 ==================== -export type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; - -export interface NavItemConfig { - name: TabName; - label: string; - icon: string; - iconOutline: string; -} - -// ==================== 导航配置常量 ==================== -export const NAVIGATION_CONSTANTS = { - SIDEBAR_WIDTH_DESKTOP: 240, - SIDEBAR_WIDTH_TABLET: 200, - SIDEBAR_COLLAPSED_WIDTH: 72, - MOBILE_TAB_FLOATING_MARGIN: 12, - LAST_ACTIVE_TAB_KEY: 'last_active_tab', -} as const; - -// ==================== 重新导出导航类型 ==================== -export type { - RootStackParamList, - MainTabParamList, - HomeStackParamList, - MessageStackParamList, - ScheduleStackParamList, - ProfileStackParamList, - AuthStackParamList, -}; diff --git a/src/navigation/AuthNavigator.tsx b/src/navigation/AuthNavigator.tsx deleted file mode 100644 index f1041be..0000000 --- a/src/navigation/AuthNavigator.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/** - * 认证流程导航 - * 处理登录、注册、忘记密码等认证页面 - */ -import React from 'react'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import type { AuthStackParamList } from './types'; - -import { LoginScreen, RegisterScreen, ForgotPasswordScreen } from '../screens/auth'; - -const AuthStack = createNativeStackNavigator(); - -export function AuthNavigator() { - return ( - - - - - - ); -} diff --git a/src/navigation/HomeNavigator.tsx b/src/navigation/HomeNavigator.tsx deleted file mode 100644 index 2589651..0000000 --- a/src/navigation/HomeNavigator.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/** - * 首页 Stack 导航 - * 处理首页相关页面:首页、搜索等 - */ -import React from 'react'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import type { HomeStackParamList } from './types'; - -import { colors } from '../theme'; -import { HomeScreen, SearchScreen } from '../screens/home'; - -const HomeStack = createNativeStackNavigator(); - -export function HomeNavigator() { - return ( - - - - - ); -} diff --git a/src/navigation/MainNavigator.tsx b/src/navigation/MainNavigator.tsx deleted file mode 100644 index 9f864c7..0000000 --- a/src/navigation/MainNavigator.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/** - * 主导航组件(重构版) - * - * 此文件仅保留导航器组装逻辑,所有业务逻辑已解耦至: - * - RootNavigator: 处理根导航和认证状态 - * - DesktopNavigator: 处理桌面端侧边栏导航 - * - SimpleMobileTabNavigator: 处理移动端 Tab 导航 - * - navigationService: 提供全局导航方法 - * - * 原文件 1118 行已精简至约 200 行 - */ - -import React, { useEffect, useState } from 'react'; -import { LinkingOptions } from '@react-navigation/native'; -import { Platform } from 'react-native'; - -import type { RootStackParamList } from './types'; -import { RootNavigator } from './RootNavigator'; - -// 导入认证 Store -import { useAuthStore } from '../stores'; - -// Deep linking 配置 -const linking: LinkingOptions = { - prefixes: ['carrotbbs://'], - config: { - screens: { - Auth: { - screens: { - Login: 'login', - Register: 'register', - ForgotPassword: 'forgot-password', - }, - }, - Main: { - screens: { - HomeTab: { - screens: { - Home: 'home', - Search: 'search', - }, - }, - MessageTab: { - screens: { - MessageList: 'messages', - Notifications: 'notifications', - }, - }, - ProfileTab: { - screens: { - Profile: 'me', - Settings: 'settings', - EditProfile: 'me/edit', - AccountSecurity: 'me/security', - NotificationSettings: 'me/notifications', - BlockedUsers: 'me/blocked', - }, - }, - }, - }, - PostDetail: 'posts/:postId', - UserProfile: 'users/:userId', - CreatePost: 'posts/create', - Chat: 'chat/:conversationId', - FollowList: 'users/:userId/:type', - QRCodeConfirm: 'qrcode/login/:sessionId', - }, - }, -}; - -/** - * 主导航组件 - * - * 职责: - * 1. 初始化认证状态 - * 2. 根据认证状态渲染 RootNavigator - * 3. 配置 Deep Linking - */ -export default function MainNavigator() { - const { isAuthenticated, fetchCurrentUser } = useAuthStore(); - const [isInitializing, setIsInitializing] = useState(true); - - // 初始化认证状态 - useEffect(() => { - const initAuth = async () => { - await fetchCurrentUser(); - setIsInitializing(false); - }; - initAuth(); - }, [fetchCurrentUser]); - - return ( - - ); -} - -// 导出 linking 配置供外部使用 -export { linking }; diff --git a/src/navigation/MessageNavigator.tsx b/src/navigation/MessageNavigator.tsx deleted file mode 100644 index cb343c5..0000000 --- a/src/navigation/MessageNavigator.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * 消息 Stack 导航 - * 处理消息相关页面:消息列表、通知等 - */ -import React from 'react'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import type { MessageStackParamList } from './types'; - -import { colors } from '../theme'; -import { - MessageListScreen, - NotificationsScreen, - PrivateChatInfoScreen, -} from '../screens/message'; - -const MessageStack = createNativeStackNavigator(); - -export function MessageNavigator() { - return ( - - - - - - ); -} diff --git a/src/navigation/MobileTabNavigatorWithDelay.tsx b/src/navigation/MobileTabNavigatorWithDelay.tsx deleted file mode 100644 index 2417596..0000000 --- a/src/navigation/MobileTabNavigatorWithDelay.tsx +++ /dev/null @@ -1,555 +0,0 @@ -/** - * 移动端 Tab Navigator(完全独立版本) - * - * 这个组件完全独立于 MainNavigator.tsx 中的其他导航组件, - * 用于解决从大屏切换到小屏时的白屏问题。 - * - * 问题根源: - * - React Navigation 的 TabNavigator 在初始化时需要完整的 navigation state - * - 从大屏(Sidebar)切换到小屏(BottomTab)时,state 可能还没有准备好 - * - 导致 TabRouter 报错:"Cannot read properties of undefined (reading 'filter')" - * - * 解决方案: - * - 完全独立的组件,不依赖外部 navigation state - * - 使用延迟初始化,确保 React Navigation 内部状态就绪 - * - 错误边界和重试机制 - */ - -import React, { useEffect, useState, useRef, useCallback } from 'react'; -import { - View, - ActivityIndicator, - StyleSheet, - AppState, - Platform, -} from 'react-native'; -import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; - -import { colors, shadows } from '../theme'; -import { useTotalUnreadCount } from '../stores'; -import { messageManager } from '../stores'; - -// ==================== 导入屏幕组件 ==================== -import { HomeScreen, PostDetailScreen, SearchScreen } from '../screens/home'; -import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule'; -import { - MessageListScreen, - ChatScreen, - NotificationsScreen, - CreateGroupScreen, - JoinGroupScreen, - GroupRequestDetailScreen, - GroupInviteDetailScreen, - GroupInfoScreen, - GroupMembersScreen, - PrivateChatInfoScreen -} from '../screens/message'; -import { ProfileScreen, SettingsScreen, EditProfileScreen, NotificationSettingsScreen, BlockedUsersScreen, AccountSecurityScreen } from '../screens/profile'; -import { CreatePostScreen } from '../screens/create'; -import { UserScreen } from '../screens/profile'; -import FollowListScreen from '../screens/profile/FollowListScreen'; - -// ==================== 类型定义 ==================== -export type MainTabParamList = { - HomeTab: undefined; - MessageTab: undefined; - ScheduleTab: undefined; - ProfileTab: undefined; -}; - -export type HomeStackParamList = { - Home: undefined; - Search: undefined; - CreatePost: undefined; -}; - -export type MessageStackParamList = { - MessageList: undefined; - Notifications: undefined; - PrivateChatInfo: { conversationId: string; userId: string }; -}; - -export type ScheduleStackParamList = { - Schedule: undefined; - CourseDetail: { courseId: string }; -}; - -export type ProfileStackParamList = { - Profile: undefined; - Settings: undefined; - EditProfile: undefined; - AccountSecurity: undefined; - MyPosts: undefined; - Bookmarks: undefined; - NotificationSettings: undefined; - BlockedUsers: undefined; -}; - -// ==================== Stack Navigators ==================== -const HomeStack = createNativeStackNavigator(); -const MessageStack = createNativeStackNavigator(); -const ScheduleStack = createNativeStackNavigator(); -const ProfileStack = createNativeStackNavigator(); -const Tab = createBottomTabNavigator(); - -// ==================== 常量 ==================== -const MOBILE_TAB_FLOATING_MARGIN = 12; -const INITIALIZATION_DELAY = 500; // ms,增加延迟时间确保 React Navigation 完全初始化 -const MAX_RETRY_ATTEMPTS = 5; -const RETRY_DELAY = 500; // ms - -// ==================== Stack Navigator 组件 ==================== - -function HomeStackNavigatorComponent() { - return ( - - - - - - ); -} - -function MessageStackNavigatorComponent() { - return ( - - - - - - ); -} - -function ScheduleStackNavigatorComponent() { - return ( - - - - - ); -} - -function ProfileStackNavigatorComponent() { - return ( - - - - - - - - - - - ); -} - -// ==================== 主组件:MobileTabNavigatorWithDelay ==================== - -export function MobileTabNavigatorWithDelay() { - const insets = useSafeAreaInsets(); - const messageUnreadCount = useTotalUnreadCount(); - const [isReady, setIsReady] = useState(false); - const [hasError, setHasError] = useState(false); - const initAttemptRef = useRef(0); - - // 初始化 MessageManager - useEffect(() => { - messageManager.initialize(); - }, []); - - // 延迟初始化 - useEffect(() => { - let timeoutId: NodeJS.Timeout; - let isMounted = true; - - const initializeNavigator = async () => { - try { - initAttemptRef.current += 1; - console.log( - `[MobileTabNavigator] Initialization attempt ${initAttemptRef.current}/${MAX_RETRY_ATTEMPTS}` - ); - - // 延迟初始化 - await new Promise((resolve) => { - timeoutId = setTimeout(resolve, INITIALIZATION_DELAY); - }); - - if (!isMounted) return; - - // 后台检测 - if (Platform.OS !== 'web' && AppState.currentState === 'background') { - console.log('[MobileTabNavigator] App in background, delaying...'); - await new Promise((resolve) => { - timeoutId = setTimeout(resolve, 300); - }); - } - - if (!isMounted) return; - - setIsReady(true); - setHasError(false); - console.log('[MobileTabNavigator] Initialization successful'); - } catch (error) { - console.error('[MobileTabNavigator] Initialization error:', error); - - if (!isMounted) return; - - if (initAttemptRef.current < MAX_RETRY_ATTEMPTS) { - console.log('[MobileTabNavigator] Retrying...'); - timeoutId = setTimeout(initializeNavigator, RETRY_DELAY); - } else { - setHasError(true); - } - } - }; - - initializeNavigator(); - - return () => { - isMounted = false; - if (timeoutId) { - clearTimeout(timeoutId); - } - }; - }, []); - - // 渲染加载状态 - if (!isReady && !hasError) { - return ( - - - - ); - } - - // 渲染错误状态(带重试按钮) - if (hasError) { - return ( - - - - - ); - } - - // 渲染 Tab Navigator - return ( - - ( - - - - ), - }} - /> - 0 - ? messageUnreadCount > 99 - ? '99+' - : messageUnreadCount - : undefined, - tabBarIcon: ({ color, size, focused }) => ( - - - - ), - }} - /> - ( - - - - ), - }} - /> - ( - - - - ), - }} - /> - - ); -} - -// ==================== 样式 ==================== -const styles = StyleSheet.create({ - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: colors.background.default, - }, - errorContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: colors.background.default, - padding: 20, - }, - retryIndicator: { - marginTop: 16, - }, - tabIconContainer: { - alignItems: 'center', - justifyContent: 'center', - width: 38, - height: 30, - borderRadius: 15, - }, - tabIconActive: { - backgroundColor: `${colors.primary.main}20`, - transform: [{ translateY: -1 }], - }, -}); diff --git a/src/navigation/ProfileNavigator.tsx b/src/navigation/ProfileNavigator.tsx deleted file mode 100644 index 0d57373..0000000 --- a/src/navigation/ProfileNavigator.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/** - * 个人中心 Stack 导航 - * 处理个人中心相关页面 - */ -import React from 'react'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import type { ProfileStackParamList } from './types'; - -import { colors } from '../theme'; -import { - ProfileScreen, - SettingsScreen, - EditProfileScreen, - NotificationSettingsScreen, - BlockedUsersScreen, - AccountSecurityScreen, -} from '../screens/profile'; - -const ProfileStack = createNativeStackNavigator(); - -export function ProfileNavigator() { - return ( - - - - - - - - - - - ); -} diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx deleted file mode 100644 index 8ae0620..0000000 --- a/src/navigation/RootNavigator.tsx +++ /dev/null @@ -1,288 +0,0 @@ -/** - * 根导航器 - * 处理整个应用的顶级导航,包括认证状态切换 - */ -import React, { useEffect, useState } from 'react'; -import { View, ActivityIndicator, StyleSheet } from 'react-native'; -import { NavigationContainer } from '@react-navigation/native'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; - -import type { RootStackParamList } from './types'; -import { colors } from '../theme'; -import { navigationService } from '../infrastructure/navigation/navigationService'; - -import { AuthNavigator } from './AuthNavigator'; -import { SimpleMobileTabNavigator } from './SimpleMobileTabNavigator'; -import { DesktopNavigator } from './DesktopNavigator'; - -import { useResponsive } from '../hooks'; -import { useTotalUnreadCount } from '../stores'; - -// 导入全局屏幕组件 -import { PostDetailScreen } from '../screens/home'; -import { UserScreen } from '../screens/profile'; -import FollowListScreen from '../screens/profile/FollowListScreen'; -import { CreatePostScreen } from '../screens/create'; -import { QRCodeConfirmScreen } from '../screens/auth/QRCodeConfirmScreen'; -import { - ChatScreen, - CreateGroupScreen, - JoinGroupScreen, - GroupInfoScreen, - GroupMembersScreen, - GroupRequestDetailScreen, - GroupInviteDetailScreen, - PrivateChatInfoScreen, -} from '../screens/message'; - -const RootStack = createNativeStackNavigator(); - -interface RootNavigatorProps { - isAuthenticated: boolean; - isInitializing: boolean; -} - -// 未认证时可访问的屏幕 - 返回数组而不是 JSX -const getPublicScreens = () => [ - , - , -]; - -// 认证后可访问的屏幕 - 返回数组而不是 JSX -const getAuthenticatedScreens = () => [ - , - , - , - , - , - , - , - , - , - , - , - , - , -]; - -export function RootNavigator({ isAuthenticated, isInitializing }: RootNavigatorProps) { - const { isMobile } = useResponsive(); - const unreadCount = useTotalUnreadCount(); - - // 设置导航引用 - const setNavigationRef = (ref: any) => { - navigationService.setNavigationRef(ref); - }; - - // 加载中显示 - if (isInitializing) { - return ( - - - - ); - } - - return ( - - - {isAuthenticated ? ( - <> - - {() => - isMobile ? ( - - ) : ( - - ) - } - - {getAuthenticatedScreens()} - - ) : ( - <> - - {getPublicScreens()} - - )} - - - ); -} - -const styles = StyleSheet.create({ - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: colors.background.default, - }, -}); diff --git a/src/navigation/ScheduleNavigator.tsx b/src/navigation/ScheduleNavigator.tsx deleted file mode 100644 index 30676aa..0000000 --- a/src/navigation/ScheduleNavigator.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/** - * 课程表 Stack 导航 - * 处理课程表相关页面 - */ -import React from 'react'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import type { ScheduleStackParamList } from './types'; - -import { colors } from '../theme'; -import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule'; - -const ScheduleStack = createNativeStackNavigator(); - -export function ScheduleNavigator() { - return ( - - - - - ); -} diff --git a/src/navigation/SimpleMobileTabNavigator.tsx b/src/navigation/SimpleMobileTabNavigator.tsx deleted file mode 100644 index f4720ad..0000000 --- a/src/navigation/SimpleMobileTabNavigator.tsx +++ /dev/null @@ -1,420 +0,0 @@ -/** - * 简单的移动端 Tab Navigator(自定义 Tab 栏) - * - * 策略(在 EnsureSingleNavigator 限制下尽量省加载): - * - 首页、消息:屏幕本身不带根级 NativeStack,首次进入后保留挂载,仅 display 隐藏 → 来回切换快。 - * - 课表、我的:各含一个 NativeStack,同一时刻只挂载当前选中的一个,避免重复注册 Navigator。 - * - * 其它可叠加的优化(按需再做,不放在本文件里): - * - 数据:TanStack Query 调 staleTime、列表 prefetch、占位骨架 - * - 时机:InteractionManager.runAfterInteractions 再拉非首屏接口 - * - 渲染:React.memo 重列表项、避免 Tab 切换时整树不必要 setState - */ - -import React, { useEffect, useState, useCallback } from 'react'; -import { View, StyleSheet, TouchableOpacity, Text } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; - -import { colors, shadows } from '../theme'; -import { useTotalUnreadCount } from '../stores'; -import { messageManager } from '../stores'; - -// ==================== 导入屏幕组件 ==================== -import { HomeScreen } from '../screens/home'; -import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule'; -import { MessageListScreen } from '../screens/message'; -import { ProfileScreen, SettingsScreen, EditProfileScreen, NotificationSettingsScreen, BlockedUsersScreen, AccountSecurityScreen } from '../screens/profile'; - -// ==================== 类型定义 ==================== -type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; - -// Schedule Stack 类型定义 -export type ScheduleStackParamList = { - Schedule: undefined; - CourseDetail: { course: any; relatedCourses?: any[] }; -}; - -// Profile Stack 类型定义 -export type ProfileStackParamList = { - Profile: undefined; - Settings: undefined; - EditProfile: undefined; - AccountSecurity: undefined; - MyPosts: undefined; - Bookmarks: undefined; - NotificationSettings: undefined; - BlockedUsers: undefined; -}; - -// ==================== Stack Navigators ==================== -const ScheduleStack = createNativeStackNavigator(); -const ProfileStack = createNativeStackNavigator(); - -// ==================== 常量 ==================== -const TAB_BAR_HEIGHT = 64; -const TAB_BAR_MARGIN = 14; -const TAB_BAR_FLOATING_MARGIN = 12; - -// ==================== 组件 ==================== - -/** - * Schedule Stack Navigator 组件 - */ -function ScheduleStackNavigatorComponent() { - return ( - - - - - ); -} - -/** - * Profile Stack Navigator 组件 - */ -function ProfileStackNavigatorComponent() { - return ( - - - - - - - - - - - ); -} - -/** - * 主组件:SimpleMobileTabNavigator - */ -export function SimpleMobileTabNavigator() { - const insets = useSafeAreaInsets(); - const messageUnreadCount = useTotalUnreadCount(); - const [activeTab, setActiveTab] = useState('HomeTab'); - /** 无嵌套 Stack 的 Tab:保留实例,避免每次切回都整页重挂载 */ - const [plainTabEverShown, setPlainTabEverShown] = useState({ - HomeTab: true, - MessageTab: false, - }); - - // 初始化 MessageManager - useEffect(() => { - messageManager.initialize(); - }, []); - - const handleTabPress = useCallback((tabName: TabName) => { - if (tabName === 'HomeTab' || tabName === 'MessageTab') { - setPlainTabEverShown((prev) => ({ ...prev, [tabName]: true })); - } - setActiveTab(tabName); - }, []); - - // 渲染 Tab Bar 图标 - const renderTabIcon = (tabName: TabName, isActive: boolean) => { - const iconColor = isActive ? colors.primary.main : colors.text.secondary; - const iconSize = isActive ? 26 : 24; - - switch (tabName) { - case 'HomeTab': - return ( - - ); - case 'MessageTab': - return ( - - ); - case 'ScheduleTab': - return ( - - ); - case 'ProfileTab': - return ( - - ); - default: - return null; - } - }; - - // 渲染 Tab Bar 标签 - const renderTabLabel = (tabName: TabName, isActive: boolean) => { - const labels: Record = { - HomeTab: '首页', - MessageTab: '消息', - ScheduleTab: '课表', - ProfileTab: '我的', - }; - - return ( - - {labels[tabName]} - - ); - }; - - return ( - - - {plainTabEverShown.HomeTab && ( - - - - )} - {plainTabEverShown.MessageTab && ( - - - - )} - {activeTab === 'ScheduleTab' && ( - - - - )} - {activeTab === 'ProfileTab' && ( - - - - )} - - - {/* Tab Bar */} - - {(['HomeTab', 'MessageTab', 'ScheduleTab', 'ProfileTab'] as TabName[]).map( - (tabName) => { - const isActive = activeTab === tabName; - const showBadge = tabName === 'MessageTab' && messageUnreadCount > 0; - - return ( - handleTabPress(tabName)} - activeOpacity={0.7} - > - - {renderTabIcon(tabName, isActive)} - {showBadge && ( - - - {messageUnreadCount > 99 ? '99+' : messageUnreadCount} - - - )} - - {renderTabLabel(tabName, isActive)} - - ); - } - )} - - - ); -} - -// ==================== 样式 ==================== -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - content: { - flex: 1, - position: 'relative', - }, - tabScreen: { - ...StyleSheet.absoluteFillObject, - }, - tabScreenHidden: { - display: 'none', - }, - tabBar: { - position: 'absolute', - left: TAB_BAR_MARGIN, - right: TAB_BAR_MARGIN, - height: TAB_BAR_HEIGHT, - backgroundColor: colors.background.paper, - borderRadius: 24, - // 移除顶部边框,避免与内容之间出现线条 - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-around', - paddingHorizontal: 8, - ...shadows.lg, - // 叠在全屏内容之上(shadows.lg 含 elevation,需最后覆盖) - zIndex: 100, - elevation: 100, - }, - tabItem: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 6, - borderRadius: 18, - }, - tabItemActive: { - backgroundColor: `${colors.primary.main}15`, - }, - tabIconContainer: { - position: 'relative', - width: 38, - height: 30, - alignItems: 'center', - justifyContent: 'center', - borderRadius: 15, - }, - tabLabel: { - fontSize: 12, - fontWeight: '600', - marginTop: -2, - letterSpacing: 0.2, - color: colors.text.secondary, - }, - tabLabelActive: { - color: colors.primary.main, - }, - badge: { - position: 'absolute', - top: -2, - right: -2, - backgroundColor: colors.error.main, - borderRadius: 10, - minWidth: 18, - height: 18, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 4, - }, - badgeText: { - color: colors.primary.contrast, - fontSize: 10, - fontWeight: '600', - }, -}); diff --git a/src/navigation/TabNavigator.tsx b/src/navigation/TabNavigator.tsx deleted file mode 100644 index 771cd07..0000000 --- a/src/navigation/TabNavigator.tsx +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Tab 导航器 - * 处理底部标签导航(移动端) - */ -import React from 'react'; -import { View } from 'react-native'; -import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; - -import type { MainTabParamList } from './types'; -import { colors, shadows } from '../theme'; - -import { HomeNavigator } from './HomeNavigator'; -import { MessageNavigator } from './MessageNavigator'; -import { ScheduleNavigator } from './ScheduleNavigator'; -import { ProfileNavigator } from './ProfileNavigator'; - -const Tab = createBottomTabNavigator(); - -// 常量 -const MOBILE_TAB_FLOATING_MARGIN = 12; - -interface TabNavigatorProps { - unreadCount: number; -} - -export function TabNavigator({ unreadCount }: TabNavigatorProps) { - const insets = useSafeAreaInsets(); - - return ( - - ( - - - - ), - }} - /> - 0 ? (unreadCount > 99 ? '99+' : unreadCount) : undefined, - tabBarIcon: ({ color, size, focused }) => ( - - - - ), - }} - /> - ( - - - - ), - }} - /> - ( - - - - ), - }} - /> - - ); -} - -import { StyleSheet } from 'react-native'; - -const styles = StyleSheet.create({ - tabIconContainer: { - alignItems: 'center', - justifyContent: 'center', - width: 38, - height: 30, - borderRadius: 15, - }, - tabIconActive: { - backgroundColor: `${colors.primary.main}20`, - transform: [{ translateY: -1 }], - }, -}); diff --git a/src/navigation/hrefs.ts b/src/navigation/hrefs.ts new file mode 100644 index 0000000..55b5000 --- /dev/null +++ b/src/navigation/hrefs.ts @@ -0,0 +1,153 @@ +/** + * Expo Router 路径构建(单一事实来源,避免魔法字符串散落) + */ +import type { SystemMessageResponse } from '../types/dto'; +import { routePayloadCache } from '../stores/routePayloadCache'; + +export function hrefPostDetail(postId: string, scrollToComments?: boolean): string { + const q = scrollToComments ? '?scrollToComments=1' : ''; + return `/post/${encodeURIComponent(postId)}${q}`; +} + +export function hrefUserProfile(userId: string): string { + return `/user/${encodeURIComponent(userId)}`; +} + +export function hrefHome(): string { + return '/home'; +} + +export function hrefHomeSearch(): string { + return '/home/search'; +} + +export function hrefMessages(): string { + return '/messages'; +} + +export function hrefNotifications(): string { + return '/messages/notifications'; +} + +export function hrefSchedule(): string { + return '/schedule'; +} + +export function hrefScheduleCourse(courseId: string): string { + return `/schedule/course?courseId=${encodeURIComponent(courseId)}`; +} + +export function hrefProfileSettings(): string { + return '/profile/settings'; +} + +export function hrefProfileEdit(): string { + return '/profile/edit-profile'; +} + +export function hrefProfileSecurity(): string { + return '/profile/account-security'; +} + +export function hrefProfileNotifications(): string { + return '/profile/notification-settings'; +} + +export function hrefProfileBlocked(): string { + return '/profile/blocked-users'; +} + +export function hrefProfileMyPosts(): string { + return '/profile/my-posts'; +} + +export function hrefProfileBookmarks(): string { + return '/profile/bookmarks'; +} + +export function hrefCreatePost(mode?: 'create' | 'edit', postId?: string): string { + if (mode === 'edit' && postId) { + return `/posts/create?mode=edit&postId=${encodeURIComponent(postId)}`; + } + return '/posts/create'; +} + +export function hrefChat(params: { + conversationId: string; + userId?: string; + isGroupChat?: boolean; + groupId?: string; + groupName?: string; +}): string { + const { conversationId, userId, isGroupChat, groupId, groupName } = params; + const q = new URLSearchParams(); + if (userId) q.set('userId', userId); + if (isGroupChat) q.set('isGroupChat', '1'); + if (groupId != null && groupId !== '') q.set('groupId', String(groupId)); + if (groupName) q.set('groupName', groupName); + const qs = q.toString(); + return `/chat/${encodeURIComponent(conversationId)}${qs ? `?${qs}` : ''}`; +} + +export function hrefFollowList(userId: string, type: 'following' | 'followers'): string { + return `/users/${encodeURIComponent(userId)}/${type}`; +} + +export function hrefGroupCreate(): string { + return '/group/create'; +} + +export function hrefGroupJoin(): string { + return '/group/join'; +} + +export function hrefGroupInfo(groupId: string, conversationId?: string): string { + const base = `/group/${encodeURIComponent(groupId)}`; + if (!conversationId) return base; + return `${base}?conversationId=${encodeURIComponent(conversationId)}`; +} + +export function hrefGroupMembers(groupId: string): string { + return `/group/${encodeURIComponent(groupId)}/members`; +} + +export function hrefGroupRequestDetail(message: SystemMessageResponse): string { + routePayloadCache.stashSystemMessage(message); + return `/group/request?messageId=${encodeURIComponent(message.id)}`; +} + +export function hrefGroupInviteDetail(message: SystemMessageResponse): string { + routePayloadCache.stashSystemMessage(message); + return `/group/invite?messageId=${encodeURIComponent(message.id)}`; +} + +export function hrefPrivateChatInfo(params: { + conversationId: string; + userId: string; + userName?: string; + userAvatar?: string | null; +}): string { + const q = new URLSearchParams({ + conversationId: params.conversationId, + userId: params.userId, + }); + if (params.userName) q.set('userName', params.userName); + if (params.userAvatar != null) q.set('userAvatar', params.userAvatar ?? ''); + return `/chat/private-info?${q.toString()}`; +} + +export function hrefQrLoginConfirm(sessionId: string): string { + return `/qrcode/login/${encodeURIComponent(sessionId)}`; +} + +export function hrefAuthLogin(): string { + return '/login'; +} + +export function hrefAuthRegister(): string { + return '/register'; +} + +export function hrefAuthForgot(): string { + return '/forgot-password'; +} diff --git a/src/navigation/index.ts b/src/navigation/index.ts index 40e4f9c..f59562c 100644 --- a/src/navigation/index.ts +++ b/src/navigation/index.ts @@ -1,29 +1,4 @@ /** - * 导出所有导航组件 + * 导航路径工具(运行时导航请使用 expo-router) */ - -// 类型 -export type { - RootStackParamList, - MainTabParamList, - HomeStackParamList, - MessageStackParamList, - ScheduleStackParamList, - ProfileStackParamList, - AuthStackParamList, - TabName, - NavItemConfig, -} from './types'; - -export { AuthNavigator } from './AuthNavigator'; -export { HomeNavigator } from './HomeNavigator'; -export { MessageNavigator } from './MessageNavigator'; -export { ScheduleNavigator } from './ScheduleNavigator'; -export { ProfileNavigator } from './ProfileNavigator'; -export { TabNavigator } from './TabNavigator'; -export { DesktopNavigator } from './DesktopNavigator'; -export { RootNavigator } from './RootNavigator'; -export { default as MainNavigator } from './MainNavigator'; - -// 移动端简化导航 -export { SimpleMobileTabNavigator } from './SimpleMobileTabNavigator'; +export * from './hrefs'; diff --git a/src/navigation/paramUtils.ts b/src/navigation/paramUtils.ts new file mode 100644 index 0000000..3dfd707 --- /dev/null +++ b/src/navigation/paramUtils.ts @@ -0,0 +1,11 @@ +/** + * Expo Router 的 searchParams、动态段可能是 string | string[] + * 群 ID 等在应用内一律为 string;路由参数禁止 Number() 以免精度丢失 + */ +export function firstRouteParam(value: string | string[] | undefined): string | undefined { + if (value == null) return undefined; + const raw = Array.isArray(value) ? value[0] : value; + if (raw == null) return undefined; + const s = String(raw).trim(); + return s === '' ? undefined : s; +} diff --git a/src/navigation/types.ts b/src/navigation/types.ts deleted file mode 100644 index c8c7862..0000000 --- a/src/navigation/types.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * 导航类型定义 - * 定义所有导航栈的类型参数 - */ - -import { NavigatorScreenParams } from '@react-navigation/native'; -import type { SystemMessageResponse } from '../types/dto'; -import type { Course } from '../types/schedule'; - -// ==================== 主Tab导航参数 ==================== -export type MainTabParamList = { - HomeTab: NavigatorScreenParams; - MessageTab: NavigatorScreenParams; - ScheduleTab: NavigatorScreenParams; - ProfileTab: NavigatorScreenParams; -}; - -// ==================== 首页Stack ==================== -export type HomeStackParamList = { - Home: undefined; - PostDetail: { postId: string; scrollToComments?: boolean }; - Search: undefined; - UserProfile: { userId: string }; - FollowList: { userId: string; type: 'following' | 'followers' }; -}; - -// ==================== 消息Stack ==================== -export type MessageStackParamList = { - MessageList: undefined; - Chat: { - conversationId: string; - userId?: string; - isGroupChat?: boolean; - groupId?: number; - groupName?: string; - }; - Notifications: undefined; - CreateGroup: undefined; - JoinGroup: undefined; - GroupInfo: { groupId: number; conversationId?: string }; - GroupMembers: { groupId: number }; - GroupRequestDetail: { message: SystemMessageResponse }; - GroupInviteDetail: { message: SystemMessageResponse }; - PrivateChatInfo: { - conversationId: string; - userId: string; - userName?: string; - userAvatar?: string | null; - }; -}; - -// ==================== 个人中心Stack ==================== -export type ProfileStackParamList = { - Profile: undefined; - Settings: undefined; - EditProfile: undefined; - AccountSecurity: undefined; - MyPosts: undefined; - Bookmarks: undefined; - NotificationSettings: undefined; - BlockedUsers: undefined; -}; - -// ==================== 课程表Stack ==================== -export type ScheduleStackParamList = { - Schedule: undefined; - CourseDetail: { course: Course; relatedCourses: Course[] }; -}; - -// ==================== 认证Stack ==================== -export type AuthStackParamList = { - Login: undefined; - Register: undefined; - ForgotPassword: undefined; -}; - -// ==================== 根导航 ==================== -export type RootStackParamList = { - Main: NavigatorScreenParams; - Auth: undefined; - PostDetail: { postId: string; scrollToComments?: boolean }; - UserProfile: { userId: string }; - CreatePost: - | undefined - | { - mode?: 'create' | 'edit'; - postId?: string; - }; - Chat: { - conversationId: string; - userId?: string; - isGroupChat?: boolean; - groupId?: number; - groupName?: string; - }; - FollowList: { userId: string; type: 'following' | 'followers' }; - CreateGroup: undefined; - JoinGroup: undefined; - GroupInfo: { groupId: number; conversationId?: string }; - GroupMembers: { groupId: number }; - GroupRequestDetail: { message: SystemMessageResponse }; - GroupInviteDetail: { message: SystemMessageResponse }; - PrivateChatInfo: { - conversationId: string; - userId: string; - userName?: string; - userAvatar?: string | null; - }; - QRCodeConfirm: { sessionId: string }; -}; - -// ==================== 全局类型声明 ==================== -declare global { - namespace ReactNavigation { - interface RootParamList extends RootStackParamList {} - } -} - -// ==================== 屏幕名称类型(用于路由)==================== -export type HomeScreenNames = keyof HomeStackParamList; -export type MessageScreenNames = keyof MessageStackParamList; -export type ProfileScreenNames = keyof ProfileStackParamList; -export type MainTabScreenNames = keyof MainTabParamList; -export type RootScreenNames = keyof RootStackParamList; - -// ==================== Tab 类型 ==================== -export type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; - -// ==================== 导航项配置 ==================== -export interface NavItemConfig { - name: TabName; - label: string; - icon: string; - iconOutline: string; -} diff --git a/src/screens/auth/ForgotPasswordScreen.tsx b/src/screens/auth/ForgotPasswordScreen.tsx index 6d418ab..e870cc1 100644 --- a/src/screens/auth/ForgotPasswordScreen.tsx +++ b/src/screens/auth/ForgotPasswordScreen.tsx @@ -11,19 +11,15 @@ import { ActivityIndicator, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme'; -import { RootStackParamList } from '../../navigation/types'; import { authService, resolveAuthApiError } from '../../services/authService'; import { showPrompt } from '../../services/promptService'; -type ForgotPasswordNavigationProp = NativeStackNavigationProp; - export const ForgotPasswordScreen: React.FC = () => { - const navigation = useNavigation(); + const router = useRouter(); const [email, setEmail] = useState(''); const [verificationCode, setVerificationCode] = useState(''); const [newPassword, setNewPassword] = useState(''); @@ -95,7 +91,7 @@ export const ForgotPasswordScreen: React.FC = () => { }); if (ok) { showPrompt({ title: '重置成功', message: '密码已重置,请重新登录', type: 'success' }); - navigation.goBack(); + router.back(); } } catch (error: any) { showPrompt({ title: '重置失败', message: resolveAuthApiError(error, '请稍后重试'), type: 'error' }); @@ -192,7 +188,7 @@ export const ForgotPasswordScreen: React.FC = () => { {loading ? : 重置密码} - navigation.goBack()}> + router.back()}> 返回登录 diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx index 000e7e6..9f3c3b0 100644 --- a/src/screens/auth/LoginScreen.tsx +++ b/src/screens/auth/LoginScreen.tsx @@ -22,24 +22,22 @@ import { StatusBar, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme'; import { useAuthStore } from '../../stores'; -import { RootStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; import { useResponsive, useResponsiveValue } from '../../hooks'; import { showPrompt } from '../../services/promptService'; -type LoginNavigationProp = NativeStackNavigationProp; - // 分栏布局断点 const SPLIT_BREAKPOINT = 768; export const LoginScreen: React.FC = () => { - const navigation = useNavigation(); + const router = useRouter(); const login = useAuthStore((state) => state.login); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const storeError = useAuthStore((state) => state.error); const setStoreError = useAuthStore((state) => state.setError); @@ -131,6 +129,12 @@ export const LoginScreen: React.FC = () => { } }, [storeError]); + useEffect(() => { + if (isAuthenticated) { + router.replace(hrefs.hrefHome()); + } + }, [isAuthenticated, router]); + const clearError = () => { setErrorMsg(null); setStoreError(null); @@ -158,7 +162,7 @@ export const LoginScreen: React.FC = () => { // 跳转到注册页 const handleGoToRegister = () => { - navigation.navigate('Register' as any); + router.push(hrefs.hrefAuthRegister()); }; // 渲染左侧面板(Logo和品牌信息)- 优化大屏布局 @@ -297,7 +301,10 @@ export const LoginScreen: React.FC = () => { {/* 忘记密码 */} - navigation.navigate('ForgotPassword' as any)}> + router.push(hrefs.hrefAuthForgot())} + > 忘记密码? diff --git a/src/screens/auth/QRCodeConfirmScreen.tsx b/src/screens/auth/QRCodeConfirmScreen.tsx index 6d6ce38..6c25b09 100644 --- a/src/screens/auth/QRCodeConfirmScreen.tsx +++ b/src/screens/auth/QRCodeConfirmScreen.tsx @@ -10,28 +10,24 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { useRoute, useNavigation, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { RootStackParamList } from '../../navigation/types'; +import { useRouter, useLocalSearchParams } from 'expo-router'; import { qrcodeApi } from '../../services/authService'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; - -type QRCodeConfirmRouteProp = RouteProp; -type NavigationProp = NativeStackNavigationProp; +import { AppBackButton } from '../../components/common'; export const QRCodeConfirmScreen: React.FC = () => { - const route = useRoute(); - const navigation = useNavigation(); - const { sessionId } = route.params; + const router = useRouter(); + const { sessionId: sessionIdParam } = useLocalSearchParams<{ sessionId?: string }>(); + const sessionId = sessionIdParam || ''; const [loading, setLoading] = useState(false); const [userInfo, setUserInfo] = useState<{ id: string; nickname: string; avatar: string } | null>(null); const [error, setError] = useState(null); useEffect(() => { - // 扫码 - handleScan(); - }, []); + if (!sessionId) return; + void handleScan(); + }, [sessionId]); const handleScan = async () => { try { @@ -42,7 +38,7 @@ export const QRCodeConfirmScreen: React.FC = () => { const errorMsg = err.response?.data?.message || '扫码失败'; setError(errorMsg); Alert.alert('扫码失败', errorMsg, [ - { text: '确定', onPress: () => navigation.goBack() } + { text: '确定', onPress: () => router.back() } ]); } finally { setLoading(false); @@ -54,7 +50,7 @@ export const QRCodeConfirmScreen: React.FC = () => { setLoading(true); await qrcodeApi.confirm(sessionId); Alert.alert('登录成功', '网页端已登录', [ - { text: '确定', onPress: () => navigation.goBack() } + { text: '确定', onPress: () => router.back() } ]); } catch (err: any) { const errorMsg = err.response?.data?.message || '确认登录失败'; @@ -70,7 +66,7 @@ export const QRCodeConfirmScreen: React.FC = () => { } catch (err) { // 忽略取消错误 } - navigation.goBack(); + router.back(); }; if (loading && !userInfo) { @@ -90,7 +86,7 @@ export const QRCodeConfirmScreen: React.FC = () => { {error} - navigation.goBack()}> + router.back()}> 返回 @@ -101,9 +97,7 @@ export const QRCodeConfirmScreen: React.FC = () => { return ( - - - + 确认登录 diff --git a/src/screens/auth/RegisterScreen.tsx b/src/screens/auth/RegisterScreen.tsx index d6fa740..fc5ff3d 100644 --- a/src/screens/auth/RegisterScreen.tsx +++ b/src/screens/auth/RegisterScreen.tsx @@ -22,25 +22,23 @@ import { StatusBar, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme'; import { authService, resolveAuthApiError } from '../../services/authService'; import { useAuthStore } from '../../stores'; -import { RootStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; import { useResponsive, useResponsiveValue } from '../../hooks'; import { showPrompt } from '../../services/promptService'; -type RegisterNavigationProp = NativeStackNavigationProp; - // 分栏布局断点 const SPLIT_BREAKPOINT = 768; export const RegisterScreen: React.FC = () => { - const navigation = useNavigation(); + const router = useRouter(); const register = useAuthStore((state) => state.register); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); // 响应式布局 const { isLandscape, width } = useResponsive(); @@ -48,6 +46,12 @@ export const RegisterScreen: React.FC = () => { // 是否使用分栏布局 const isSplitLayout = width >= SPLIT_BREAKPOINT; + useEffect(() => { + if (isAuthenticated) { + router.replace(hrefs.hrefHome()); + } + }, [isAuthenticated, router]); + const [username, setUsername] = useState(''); const [nickname, setNickname] = useState(''); const [email, setEmail] = useState(''); @@ -238,7 +242,7 @@ export const RegisterScreen: React.FC = () => { // 跳转到登录页 const handleGoToLogin = () => { - navigation.navigate('Login' as any); + router.push(hrefs.hrefAuthLogin()); }; // 渲染左侧面板(Logo和品牌信息)- 优化大屏布局 diff --git a/src/screens/create/CreatePostScreen.tsx b/src/screens/create/CreatePostScreen.tsx index 17d9297..efc2db1 100644 --- a/src/screens/create/CreatePostScreen.tsx +++ b/src/screens/create/CreatePostScreen.tsx @@ -21,17 +21,17 @@ import { Image, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; -import { Text, Button, ResponsiveContainer } from '../../components/common'; +import { Text, Button, ResponsiveContainer, AppBackButton } from '../../components/common'; import { postService, showPrompt, voteService } from '../../services'; import { ApiError } from '../../services/api'; import { uploadService } from '../../services/uploadService'; import VoteEditor from '../../components/business/VoteEditor'; import { useResponsive, useResponsiveValue } from '../../hooks'; -import { RootStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; // Props 接口 interface CreatePostScreenProps { @@ -80,10 +80,11 @@ const getPublishErrorMessage = (error: unknown): string => { export const CreatePostScreen: React.FC = (props) => { const { onClose } = props; + const router = useRouter(); const navigation = useNavigation(); - const route = useRoute>(); - const isEditMode = route.params?.mode === 'edit' && !!route.params?.postId; - const editPostID = route.params?.postId || ''; + const { mode, postId: postIdParam } = useLocalSearchParams<{ mode?: string; postId?: string }>(); + const isEditMode = mode === 'edit' && !!postIdParam; + const editPostID = postIdParam || ''; // 响应式布局 const { isWideScreen, width } = useResponsive(); @@ -135,12 +136,11 @@ export const CreatePostScreen: React.FC = (props) => { React.useLayoutEffect(() => { navigation.setOptions({ + headerShown: true, title: isEditMode ? '编辑帖子' : '发布帖子', // 当作为 Modal 使用时,显示关闭按钮 headerLeft: onClose ? () => ( - - - + ) : undefined, }); }, [navigation, isEditMode, onClose]); @@ -156,7 +156,7 @@ export const CreatePostScreen: React.FC = (props) => { const existingPost = await postService.getPost(editPostID); if (!existingPost) { Alert.alert('提示', '帖子不存在或已被删除'); - navigation.goBack(); + router.back(); return; } setTitle(existingPost.title || ''); @@ -165,14 +165,14 @@ export const CreatePostScreen: React.FC = (props) => { } catch (error) { console.error('加载待编辑帖子失败:', error); Alert.alert('错误', '加载帖子失败,请稍后重试'); - navigation.goBack(); + router.back(); } finally { setLoadingPost(false); } }; loadPostForEdit(); - }, [isEditMode, editPostID, navigation]); + }, [isEditMode, editPostID, router]); // 选择图片 const handlePickImage = async () => { @@ -381,10 +381,7 @@ export const CreatePostScreen: React.FC = (props) => { message: '投票帖已提交,内容审核中,稍后展示', duration: 2600, }); - navigation.reset({ - index: 0, - routes: [{ name: 'Main' }], - }); + router.replace(hrefs.hrefHome()); } else { if (isEditMode && editPostID) { const updated = await postService.updatePost(editPostID, { @@ -401,10 +398,7 @@ export const CreatePostScreen: React.FC = (props) => { message: '帖子内容已更新', duration: 2200, }); - navigation.reset({ - index: 0, - routes: [{ name: 'Main' }], - }); + router.replace(hrefs.hrefHome()); } else { // 创建普通帖子 await postService.createPost({ @@ -418,10 +412,7 @@ export const CreatePostScreen: React.FC = (props) => { message: '帖子已提交,内容审核中,稍后展示', duration: 2600, }); - navigation.reset({ - index: 0, - routes: [{ name: 'Main' }], - }); + router.replace(hrefs.hrefHome()); } } } catch (error) { diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 8ace932..65320dc 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -19,8 +19,7 @@ import { Modal, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { colors, spacing, borderRadius, shadows } from '../../theme'; @@ -31,15 +30,12 @@ import { postService } from '../../services'; import { PostCard, TabBar, SearchBar } from '../../components/business'; import type { PostCardAction } from '../../components/business/PostCard'; import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common'; -import { HomeStackParamList, RootStackParamList } from '../../navigation/types'; import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive'; import { useDifferentialPosts } from '../../hooks/useDifferentialPosts'; import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase'; import { SearchScreen } from './SearchScreen'; import { CreatePostScreen } from '../create/CreatePostScreen'; -import { navigationService } from '../../infrastructure/navigation/navigationService'; - -type NavigationProp = NativeStackNavigationProp & NativeStackNavigationProp; +import * as hrefs from '../../navigation/hrefs'; const TABS = ['最新', '关注', '热门']; const TAB_ICONS = ['clock-outline', 'account-heart-outline', 'fire']; @@ -54,7 +50,7 @@ type ViewMode = 'list' | 'grid'; type PostType = 'follow' | 'hot' | 'latest'; export const HomeScreen: React.FC = () => { - const navigation = useNavigation(); + const router = useRouter(); const insets = useSafeAreaInsets(); const { posts: storePosts } = useUserStore(); const currentUser = useCurrentUser(); @@ -302,12 +298,12 @@ export const HomeScreen: React.FC = () => { // 跳转到帖子详情 const handlePostPress = (postId: string, scrollToComments: boolean = false) => { - navigationService.navigate('PostDetail', { postId, scrollToComments }); + router.push(hrefs.hrefPostDetail(postId, scrollToComments)); }; // 跳转到用户主页 const handleUserPress = (userId: string) => { - navigationService.navigate('UserProfile', { userId }); + router.push(hrefs.hrefUserProfile(userId)); }; // 点赞帖子 @@ -563,10 +559,12 @@ export const HomeScreen: React.FC = () => { /> } > - {distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))} - {/* 加载更多指示器 */} - {isLoading && displayPosts.length > 0 && ( - + + {distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))} + + {/* 独立底部加载区:避免参与横向列布局导致列宽抖动 */} + {isLoadingMore && displayPosts.length > 0 && ( + )} @@ -808,8 +806,12 @@ const styles = StyleSheet.create({ flex: 1, }, waterfallContainer: { - flexDirection: 'row', + width: '100%', flexGrow: 1, + }, + waterfallColumnsRow: { + width: '100%', + flexDirection: 'row', alignItems: 'flex-start', }, waterfallColumn: { @@ -844,7 +846,7 @@ const styles = StyleSheet.create({ height: 72, borderRadius: 36, }, - loadingMore: { + loadingMoreFooter: { paddingVertical: 20, alignItems: 'center', justifyContent: 'center', diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index 56e24ff..5ae432b 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -23,8 +23,7 @@ import { Clipboard, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useNavigation, useRouter, useLocalSearchParams } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; @@ -35,19 +34,20 @@ import { postService, commentService, uploadService, authService, showPrompt, vo import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase'; import { useCursorPagination } from '../../hooks/useCursorPagination'; import { CommentItem, VoteCard } from '../../components/business'; -import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout } from '../../components/common'; -import { RootStackParamList } from '../../navigation/types'; +import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common'; import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive'; - -type NavigationProp = NativeStackNavigationProp; -type PostDetailRouteProp = RouteProp; +import * as hrefs from '../../navigation/hrefs'; export const PostDetailScreen: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute(); + const navigation = useNavigation(); + const router = useRouter(); const insets = useSafeAreaInsets(); - const postId = route.params?.postId || ''; - const shouldScrollToComments = route.params?.scrollToComments || false; + const { postId: postIdParam, scrollToComments: scrollParam } = useLocalSearchParams<{ + postId?: string; + scrollToComments?: string; + }>(); + const postId = postIdParam || ''; + const shouldScrollToComments = scrollParam === '1' || scrollParam === 'true'; // 使用响应式 hook const { @@ -282,23 +282,18 @@ export const PostDetailScreen: React.FC = () => { const author = post.author; const handleBackPress = () => { - if (navigation.canGoBack()) { - navigation.goBack(); + if (router.canGoBack()) { + router.back(); return; } - navigation.navigate('Main', { - screen: 'HomeTab', - params: { screen: 'Home', params: undefined }, - }); + router.replace(hrefs.hrefHome()); }; navigation.setOptions({ headerBackVisible: false, headerTitleAlign: 'left', headerLeft: () => ( - - - + ), headerTitle: () => ( @@ -641,7 +636,7 @@ export const PostDetailScreen: React.FC = () => { Alert.alert('删除成功', '帖子已删除', [ { text: '确定', - onPress: () => navigation.goBack(), + onPress: () => router.back(), }, ]); } catch (error) { @@ -654,15 +649,12 @@ export const PostDetailScreen: React.FC = () => { }, ], ); - }, [post, isDeleting, currentUser?.id, navigation]); + }, [post, isDeleting, currentUser?.id, router]); const handleEditPost = useCallback(() => { if (!post) return; - navigation.navigate('CreatePost', { - mode: 'edit', - postId: post.id, - }); - }, [navigation, post]); + router.push(hrefs.hrefCreatePost('edit', post.id)); + }, [router, post]); // 点击图片查看大图 const handleImagePress = useCallback((images: ImageGridItem[], index: number) => { @@ -999,7 +991,7 @@ export const PostDetailScreen: React.FC = () => { // 跳转到用户主页 const handleUserPress = (userId: string) => { - navigation.navigate('UserProfile', { userId }); + router.push(hrefs.hrefUserProfile(userId)); }; // 渲染身份标识 @@ -1294,27 +1286,22 @@ export const PostDetailScreen: React.FC = () => { - {/* 评论标题 - 现代化设计 */} - - - - - - - 评论 - - - {post.comments_count} + {/* 评论标题 - 现代化简洁分区头 */} + + + + + 评论区 + + + {post.comments_count} + @@ -1396,9 +1383,9 @@ export const PostDetailScreen: React.FC = () => { @@ -1831,7 +1818,7 @@ const styles = StyleSheet.create({ postMetaInfo: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', + justifyContent: 'flex-start', marginBottom: spacing.sm, }, metaInfoMain: { @@ -1928,38 +1915,42 @@ const styles = StyleSheet.create({ marginLeft: 6, fontWeight: '500', }, - commentTitle: { + commentSectionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.divider, + paddingTop: spacing.lg, + }, + commentSectionTitleBlock: { + flex: 1, + minWidth: 0, + }, + commentSectionTitleRow: { flexDirection: 'row', alignItems: 'center', }, - commentTitleLeft: { - flexDirection: 'row', - alignItems: 'center', - }, - commentTitleIconContainer: { - borderRadius: borderRadius.md, - backgroundColor: colors.primary.main, - justifyContent: 'center', - alignItems: 'center', - marginRight: spacing.sm, - }, - commentTitleText: { - fontWeight: '700', + commentSectionTitle: { + fontWeight: '800', color: colors.text.primary, + letterSpacing: -0.2, }, commentCountBadge: { - backgroundColor: colors.primary.light + '25', - paddingHorizontal: spacing.sm, - paddingVertical: 2, - borderRadius: borderRadius.md, + backgroundColor: colors.background.default, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, + paddingHorizontal: spacing.sm + 2, + paddingVertical: 3, + borderRadius: 999, marginLeft: spacing.sm, minWidth: 24, alignItems: 'center', }, commentCountText: { - fontSize: fontSizes.sm, + fontSize: fontSizes.xs, fontWeight: '700', - color: colors.primary.main, + color: colors.text.secondary, }, inputContainer: { backgroundColor: colors.background.paper, @@ -2067,17 +2058,26 @@ const styles = StyleSheet.create({ emptyCommentsContainer: { alignItems: 'center', justifyContent: 'center', - paddingVertical: spacing.xl * 2, + marginHorizontal: spacing.lg, + marginTop: spacing.lg, + marginBottom: spacing.md, + paddingVertical: spacing.xl + spacing.md, paddingHorizontal: spacing.lg, + borderRadius: borderRadius.lg, + backgroundColor: colors.background.paper, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, }, emptyCommentsIconWrapper: { - width: 72, - height: 72, - borderRadius: 36, - backgroundColor: colors.primary.light + '18', + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: colors.background.default, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, alignItems: 'center', justifyContent: 'center', - marginBottom: spacing.lg, + marginBottom: spacing.sm, }, emptyCommentsTitle: { fontSize: fontSizes.md, diff --git a/src/screens/home/SearchScreen.tsx b/src/screens/home/SearchScreen.tsx index 7a6e7b9..793720b 100644 --- a/src/screens/home/SearchScreen.tsx +++ b/src/screens/home/SearchScreen.tsx @@ -14,8 +14,7 @@ import { RefreshControl, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { Post, User } from '../../types'; @@ -24,12 +23,10 @@ import { postService, authService } from '../../services'; import { PostCard, TabBar, SearchBar } from '../../components/business'; import type { PostCardAction } from '../../components/business/PostCard'; import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common'; -import { HomeStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks/useResponsive'; import { useCursorPagination } from '../../hooks/useCursorPagination'; -type NavigationProp = NativeStackNavigationProp; - const TABS = ['帖子', '用户']; const DEFAULT_PAGE_SIZE = 20; @@ -37,12 +34,10 @@ type SearchType = 'posts' | 'users'; interface SearchScreenProps { onBack?: () => void; - navigation?: NavigationProp; } -export const SearchScreen: React.FC = ({ onBack, navigation: propNavigation }) => { - // 如果传入了 navigation 则使用传入的,否则使用 hook 获取的 - const navigation = propNavigation || useNavigation(); +export const SearchScreen: React.FC = ({ onBack }) => { + const router = useRouter(); const insets = useSafeAreaInsets(); const { searchHistory: history, addSearchHistory, clearSearchHistory } = useUserStore(); @@ -168,12 +163,12 @@ export const SearchScreen: React.FC = ({ onBack, navigation: // 跳转到帖子详情 const handlePostPress = (postId: string, scrollToComments: boolean = false) => { - navigation.navigate('PostDetail', { postId, scrollToComments }); + router.push(hrefs.hrefPostDetail(postId, scrollToComments)); }; // 跳转到用户主页 const handleUserPress = (userId: string) => { - navigation.navigate('UserProfile', { userId }); + router.push(hrefs.hrefUserProfile(userId)); }; // 统一处理 PostCard 的操作(搜索页不支持删除) @@ -515,7 +510,7 @@ export const SearchScreen: React.FC = ({ onBack, navigation: onBack ? onBack() : navigation.goBack()} + onPress={() => (onBack ? onBack() : router.back())} activeOpacity={0.85} > { - const navigation = useNavigation(); + const navigation = useNavigation(); + const router = useRouter(); const insets = useSafeAreaInsets(); // 响应式布局 @@ -59,21 +61,9 @@ export const ChatScreen: React.FC = () => { // 监听屏幕宽度变化,当变为大屏幕时自动跳转到web端首页 useEffect(() => { if (isWideScreen) { - // 导航到大屏幕模式的主界面首页 - navigation.reset({ - index: 0, - routes: [ - { - name: 'Main', - params: { - screen: 'HomeTab', - params: { screen: 'Home' } - } - } - ] - }); + router.replace(hrefs.hrefHome()); } - }, [isWideScreen, navigation]); + }, [isWideScreen, router]); // 输入框区域高度(用于定位浮动 mention 面板) const [inputWrapperHeight, setInputWrapperHeight] = useState(60); @@ -228,16 +218,6 @@ export const ChatScreen: React.FC = () => { }; }, []); - const highlightMessageTemporarily = useCallback((messageId: string, duration = 1500) => { - if (replyHighlightTimerRef.current) { - clearTimeout(replyHighlightTimerRef.current); - } - setSelectedMessageId(messageId); - replyHighlightTimerRef.current = setTimeout(() => { - setSelectedMessageId(prev => (prev === messageId ? null : prev)); - }, duration); - }, [setSelectedMessageId]); - const handleReplyPreviewPress = useCallback((messageId: string) => { const targetId = String(messageId); const targetIndex = displayMessages.findIndex(msg => String(msg.id) === targetId); @@ -245,14 +225,13 @@ export const ChatScreen: React.FC = () => { replyTargetMessageIdRef.current = targetId; setBrowsingHistory(true); - highlightMessageTemporarily(targetId); flatListRef.current?.scrollToIndex({ index: targetIndex, animated: true, viewPosition: 0.5, }); - }, [displayMessages, flatListRef, highlightMessageTemporarily, setBrowsingHistory]); + }, [displayMessages, flatListRef, setBrowsingHistory]); // 监听返回事件,刷新会话列表 useEffect(() => { @@ -399,7 +378,7 @@ export const ChatScreen: React.FC = () => { otherUser={otherUser} routeGroupName={routeGroupName} typingHint={typingHint} - onBack={() => navigation.goBack()} + onBack={() => router.back()} onTitlePress={navigateToInfo} onMorePress={navigateToChatSettings} onGroupInfoPress={handleGroupInfoPress} @@ -431,8 +410,8 @@ export const ChatScreen: React.FC = () => { initialNumToRender={14} maxToRenderPerBatch={10} updateCellsBatchingPeriod={50} - windowSize={9} - removeClippedSubviews={Platform.OS !== 'web'} + windowSize={15} + removeClippedSubviews={false} onScroll={handleMessageListScroll} onScrollBeginDrag={() => { isUserDraggingRef.current = true; diff --git a/src/screens/message/CreateGroupScreen.tsx b/src/screens/message/CreateGroupScreen.tsx index 2f35d03..5185947 100644 --- a/src/screens/message/CreateGroupScreen.tsx +++ b/src/screens/message/CreateGroupScreen.tsx @@ -16,8 +16,7 @@ import { Image, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; @@ -25,13 +24,10 @@ import { groupService } from '../../services/groupService'; import { uploadService } from '../../services/uploadService'; import { Avatar, Text, Button, Loading } from '../../components/common'; import { User } from '../../types'; -import { RootStackParamList } from '../../navigation/types'; import MutualFollowSelectorModal from './components/MutualFollowSelectorModal'; -type NavigationProp = NativeStackNavigationProp; - const CreateGroupScreen: React.FC = () => { - const navigation = useNavigation(); + const router = useRouter(); // 表单状态 const [groupName, setGroupName] = useState(''); @@ -138,7 +134,7 @@ const CreateGroupScreen: React.FC = () => { Alert.alert('成功', '群组创建成功', [ { text: '确定', - onPress: () => navigation.goBack(), + onPress: () => router.back(), }, ]); } catch (error: any) { diff --git a/src/screens/message/GroupInfoScreen.tsx b/src/screens/message/GroupInfoScreen.tsx index 812a411..7c7846d 100644 --- a/src/screens/message/GroupInfoScreen.tsx +++ b/src/screens/message/GroupInfoScreen.tsx @@ -21,8 +21,8 @@ import { Dimensions, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useFocusEffect } from '@react-navigation/native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; @@ -39,16 +39,14 @@ import { JoinType, } from '../../types/dto'; import { User } from '../../types'; -import { RootStackParamList } from '../../navigation/types'; +import { firstRouteParam } from '../../navigation/paramUtils'; +import * as hrefs from '../../navigation/hrefs'; import { messageManager } from '../../stores/messageManager'; import { groupManager } from '../../stores/groupManager'; import MutualFollowSelectorModal from './components/MutualFollowSelectorModal'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); -type NavigationProp = NativeStackNavigationProp; -type GroupInfoRouteProp = RouteProp; - // 加群方式选项 const JOIN_TYPE_OPTIONS: { value: JoinType; label: string; icon: string; desc: string }[] = [ { value: 0, label: '允许任何人加入', icon: 'earth', desc: '任何人都可以直接加入群聊' }, @@ -57,9 +55,13 @@ const JOIN_TYPE_OPTIONS: { value: JoinType; label: string; icon: string; desc: s ]; const GroupInfoScreen: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute(); - const { groupId, conversationId } = route.params; + const router = useRouter(); + const { groupId: groupIdParam, conversationId: conversationIdParam } = useLocalSearchParams<{ + groupId?: string | string[]; + conversationId?: string | string[]; + }>(); + const groupId = firstRouteParam(groupIdParam) ?? ''; + const conversationId = firstRouteParam(conversationIdParam); const { currentUser } = useAuthStore(); // 响应式布局 @@ -112,6 +114,7 @@ const GroupInfoScreen: React.FC = () => { // 加载群组信息 const loadGroupInfo = useCallback(async () => { + if (!groupId) return; try { setLoading(true); @@ -138,7 +141,7 @@ const GroupInfoScreen: React.FC = () => { } catch (error) { console.error('加载群组信息失败:', error); Alert.alert('错误', '加载群组信息失败', [ - { text: '确定', onPress: () => navigation.goBack() }, + { text: '确定', onPress: () => router.back() }, ]); } finally { setLoading(false); @@ -394,7 +397,7 @@ const GroupInfoScreen: React.FC = () => { try { await groupService.dissolveGroup(groupId); Alert.alert('成功', '群组已解散', [ - { text: '确定', onPress: () => navigation.goBack() }, + { text: '确定', onPress: () => router.back() }, ]); } catch (error: any) { console.error('解散群组失败:', error); @@ -420,7 +423,7 @@ const GroupInfoScreen: React.FC = () => { try { await groupService.leaveGroup(groupId); Alert.alert('成功', '已退出群聊', [ - { text: '确定', onPress: () => navigation.goBack() }, + { text: '确定', onPress: () => router.back() }, ]); } catch (error: any) { console.error('退出群聊失败:', error); @@ -464,7 +467,7 @@ const GroupInfoScreen: React.FC = () => { // 跳转到成员管理 const goToMembers = () => { - navigation.navigate('GroupMembers' as any, { groupId }); + router.push(hrefs.hrefGroupMembers(groupId)); }; // 获取加群方式文本 @@ -485,8 +488,8 @@ const GroupInfoScreen: React.FC = () => { Alert.alert('已复制', '群号已复制到剪贴板'); }; - const formatGroupNo = (id: string | number) => { - const raw = String(id); + const formatGroupNo = (id: string) => { + const raw = id; if (raw.length <= 12) return raw; return `${raw.slice(0, 6)}...${raw.slice(-4)}`; }; diff --git a/src/screens/message/GroupInviteDetailScreen.tsx b/src/screens/message/GroupInviteDetailScreen.tsx index cb7788f..0110de4 100644 --- a/src/screens/message/GroupInviteDetailScreen.tsx +++ b/src/screens/message/GroupInviteDetailScreen.tsx @@ -1,25 +1,36 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { View, StyleSheet, ActivityIndicator, Alert, ScrollView } from 'react-native'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { View, StyleSheet, ActivityIndicator, Alert, ScrollView, TouchableOpacity } from 'react-native'; +import { useRouter, useLocalSearchParams } from 'expo-router'; import { SafeAreaView } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; - import { colors, spacing, borderRadius, shadows } from '../../theme'; import { Avatar, Text } from '../../components/common'; -import { RootStackParamList } from '../../navigation/types'; +import { routePayloadCache } from '../../stores/routePayloadCache'; import { groupService } from '../../services/groupService'; import { groupManager } from '../../stores/groupManager'; import { GroupMemberResponse } from '../../types/dto'; import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared'; -type Route = RouteProp; -type Navigation = NativeStackNavigationProp; - const GroupInviteDetailScreen: React.FC = () => { - const route = useRoute(); - const navigation = useNavigation(); - const { message } = route.params; + const router = useRouter(); + const { messageId } = useLocalSearchParams<{ messageId?: string }>(); + const message = messageId ? routePayloadCache.getSystemMessage(messageId) : undefined; + + if (!message) { + return ( + + + + 无法加载该消息,请从通知列表重新打开。 + + router.back()} style={styles.backBtn}> + 返回 + + + + ); + } + const { extra_data } = message; const [loadingMembers, setLoadingMembers] = useState(false); @@ -35,7 +46,7 @@ const GroupInviteDetailScreen: React.FC = () => { if (!extra_data?.group_id) return; setLoadingGroup(true); try { - const group = await groupManager.getGroup(extra_data.group_id); + const group = await groupManager.getGroup(String(extra_data.group_id)); setMemberCount(group.member_count ?? null); } catch { setMemberCount(null); @@ -51,7 +62,7 @@ const GroupInviteDetailScreen: React.FC = () => { if (!extra_data?.group_id) return; setLoadingMembers(true); try { - const res = await groupManager.getMembers(extra_data.group_id, 1, 100); + const res = await groupManager.getMembers(String(extra_data.group_id), 1, 100); const admins = (res.list || []).filter(m => m.role === 'owner' || m.role === 'admin'); setMembers(admins); } catch { @@ -76,9 +87,9 @@ const GroupInviteDetailScreen: React.FC = () => { } setSubmitting(true); try { - await groupService.respondInvite(groupId, { flag, approve }); + await groupService.respondInvite(String(groupId), { flag, approve }); Alert.alert('成功', approve ? '已同意加入群聊' : '已拒绝邀请', [ - { text: '确定', onPress: () => navigation.goBack() }, + { text: '确定', onPress: () => router.back() }, ]); } catch { Alert.alert('操作失败', '请稍后重试'); @@ -87,7 +98,7 @@ const GroupInviteDetailScreen: React.FC = () => { } }; - const groupNo = useMemo(() => extra_data?.group_id || '-', [extra_data?.group_id]); + const groupNo = useMemo(() => String(extra_data?.group_id ?? '-'), [extra_data?.group_id]); const formatGroupNo = (id: string) => { if (id.length <= 12) return id; return `${id.slice(0, 6)}...${id.slice(-4)}`; @@ -168,6 +179,16 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: colors.background.default, }, + emptyWrap: { + flex: 1, + padding: spacing.lg, + justifyContent: 'center', + alignItems: 'center', + gap: spacing.md, + }, + backBtn: { + padding: spacing.sm, + }, scrollView: { flex: 1, }, diff --git a/src/screens/message/GroupMembersScreen.tsx b/src/screens/message/GroupMembersScreen.tsx index 15ffa5d..b862f07 100644 --- a/src/screens/message/GroupMembersScreen.tsx +++ b/src/screens/message/GroupMembersScreen.tsx @@ -19,8 +19,7 @@ import { Dimensions, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useLocalSearchParams } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { useAuthStore } from '../../stores'; @@ -38,8 +37,7 @@ import { GroupMemberResponse, GroupRole, } from '../../types/dto'; -import { RootStackParamList } from '../../navigation/types'; - +import { firstRouteParam } from '../../navigation/paramUtils'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); const GROUP_MEMBER_REMOTE_LIST_KIND: GroupMemberListSourceKind = 'cursor'; @@ -50,9 +48,6 @@ const GRID_CONFIG = { desktop: { columns: 3, itemWidth: '31%' }, }; -type NavigationProp = NativeStackNavigationProp; -type GroupMembersRouteProp = RouteProp; - // 成员分组 interface MemberGroup { title: string; @@ -60,9 +55,8 @@ interface MemberGroup { } const GroupMembersScreen: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute(); - const { groupId } = route.params; + const { groupId: groupIdParam } = useLocalSearchParams<{ groupId?: string | string[] }>(); + const groupId = firstRouteParam(groupIdParam) ?? ''; const { currentUser } = useAuthStore(); // 响应式布局 @@ -155,8 +149,9 @@ const GroupMembersScreen: React.FC = () => { // 初始加载 useEffect(() => { + if (!groupId) return; refresh(); - }, [groupId]); + }, [groupId, refresh]); // 按角色分组 const groupMembers = useCallback((): MemberGroup[] => { diff --git a/src/screens/message/GroupRequestDetailScreen.tsx b/src/screens/message/GroupRequestDetailScreen.tsx index 833be5a..6e00171 100644 --- a/src/screens/message/GroupRequestDetailScreen.tsx +++ b/src/screens/message/GroupRequestDetailScreen.tsx @@ -1,25 +1,38 @@ import React, { useEffect, useMemo, useState } from 'react'; import { View, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter, useLocalSearchParams } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; import { colors, spacing, borderRadius, shadows } from '../../theme'; import { Avatar, Text } from '../../components/common'; -import { RootStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; +import { routePayloadCache } from '../../stores/routePayloadCache'; import { groupService } from '../../services/groupService'; import { groupManager } from '../../stores/groupManager'; import { userManager } from '../../stores/userManager'; import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared'; -type Route = RouteProp; -type Navigation = NativeStackNavigationProp; - const GroupRequestDetailScreen: React.FC = () => { - const route = useRoute(); - const navigation = useNavigation(); - const { message } = route.params; + const router = useRouter(); + const { messageId } = useLocalSearchParams<{ messageId?: string }>(); + const message = messageId ? routePayloadCache.getSystemMessage(messageId) : undefined; + + if (!message) { + return ( + + + + 无法加载该消息,请从通知列表重新打开。 + + router.back()} style={styles.backBtn}> + 返回 + + + + ); + } + const { extra_data } = message; const [submitting, setSubmitting] = useState(false); const [memberCount, setMemberCount] = useState(null); @@ -64,7 +77,7 @@ const GroupRequestDetailScreen: React.FC = () => { if (!extra_data?.group_id) return; setLoadingGroup(true); try { - const group = await groupManager.getGroup(extra_data.group_id); + const group = await groupManager.getGroup(String(extra_data.group_id)); setMemberCount(group.member_count ?? null); } catch { setMemberCount(null); @@ -90,18 +103,18 @@ const GroupRequestDetailScreen: React.FC = () => { setSubmitting(true); try { if (message.system_type === 'group_invite') { - await groupService.respondInvite(groupId, { + await groupService.respondInvite(String(groupId), { flag, approve, }); } else { - await groupService.reviewJoinRequest(groupId, { + await groupService.reviewJoinRequest(String(groupId), { flag, approve, }); } Alert.alert('成功', approve ? '已同意申请' : '已拒绝申请', [ - { text: '确定', onPress: () => navigation.goBack() }, + { text: '确定', onPress: () => router.back() }, ]); } catch (error) { Alert.alert('操作失败', '请稍后重试'); @@ -114,7 +127,7 @@ const GroupRequestDetailScreen: React.FC = () => { if (!applicantId) return; const user = await userManager.getUserById(applicantId); if (user?.id) { - navigation.navigate('UserProfile', { userId: String(user.id) }); + router.push(hrefs.hrefUserProfile(String(user.id))); } }; @@ -124,7 +137,7 @@ const GroupRequestDetailScreen: React.FC = () => { @@ -166,6 +179,16 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: colors.background.default, }, + emptyWrap: { + flex: 1, + padding: spacing.lg, + justifyContent: 'center', + alignItems: 'center', + gap: spacing.md, + }, + backBtn: { + padding: spacing.sm, + }, scrollView: { flex: 1, }, diff --git a/src/screens/message/JoinGroupScreen.tsx b/src/screens/message/JoinGroupScreen.tsx index 36125a9..c143cec 100644 --- a/src/screens/message/JoinGroupScreen.tsx +++ b/src/screens/message/JoinGroupScreen.tsx @@ -10,8 +10,7 @@ import { FlatList, RefreshControl, } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, borderRadius } from '../../theme'; @@ -19,15 +18,12 @@ import Avatar from '../../components/common/Avatar'; import Text from '../../components/common/Text'; import { groupService } from '../../services/groupService'; import { groupManager } from '../../stores/groupManager'; -import { RootStackParamList } from '../../navigation/types'; import { GroupResponse, JoinType } from '../../types/dto'; import { useCursorPagination } from '../../hooks/useCursorPagination'; import { EmptyState } from '../../components/common'; -type NavigationProp = NativeStackNavigationProp; - const JoinGroupScreen: React.FC = () => { - const navigation = useNavigation(); + const router = useRouter(); const [keyword, setKeyword] = useState(''); const [searching, setSearching] = useState(false); const [joiningGroupId, setJoiningGroupId] = useState(null); @@ -92,7 +88,7 @@ const JoinGroupScreen: React.FC = () => { Alert.alert('成功', '操作已提交', [ { text: '确定', - onPress: () => navigation.goBack(), + onPress: () => router.back(), }, ]); } catch (error: any) { @@ -106,13 +102,13 @@ const JoinGroupScreen: React.FC = () => { } }; - const handleCopyGroupId = (groupId: string | number) => { - Clipboard.setString(String(groupId)); + const handleCopyGroupId = (groupId: string) => { + Clipboard.setString(groupId); Alert.alert('已复制', '群号已复制到剪贴板'); }; - const formatGroupNo = (id: string | number) => { - const raw = String(id); + const formatGroupNo = (id: string) => { + const raw = id; if (raw.length <= 12) return raw; return `${raw.slice(0, 6)}...${raw.slice(-4)}`; }; diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index 1414d72..8644d93 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -26,8 +26,8 @@ import { Platform, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useNavigation, useIsFocused } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; +import { useIsFocused } from '@react-navigation/native'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, fontSizes, shadows, borderRadius } from '../../theme'; @@ -43,9 +43,9 @@ import { useMarkAsRead, useMessageManagerConversations, } from '../../stores'; -import { Avatar, Text, EmptyState, ResponsiveContainer } from '../../components/common'; +import { Avatar, Text, EmptyState, ResponsiveContainer, AppBackButton } from '../../components/common'; import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive'; -import { RootStackParamList, MessageStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; // 导入 EmbeddedChat 组件用于桌面端双栏布局 import { EmbeddedChat } from './components/EmbeddedChat'; import { ConversationListRow } from './components/ConversationListRow'; @@ -54,9 +54,6 @@ import { NotificationsScreen } from './NotificationsScreen'; // 导入扫码组件 import { QRCodeScanner } from '../../components/business/QRCodeScanner'; -type NavigationProp = NativeStackNavigationProp; -type MessageNavProp = NativeStackNavigationProp; - // 系统通知会话特殊ID const SYSTEM_MESSAGE_CHANNEL_ID = '-1'; @@ -76,7 +73,7 @@ interface SearchResultItem { * 支持响应式双栏布局 */ export const MessageListScreen: React.FC = () => { - const navigation = useNavigation(); + const router = useRouter(); const isFocused = useIsFocused(); const insets = useSafeAreaInsets(); // 在大屏幕模式下不使用 Bottom Tab Navigator,所以不需要 tabBarHeight @@ -286,22 +283,25 @@ export const MessageListScreen: React.FC = () => { setSelectedConversation(conversation); } else if (conversation.type === 'group' && conversation.group) { // 群聊 - 窄屏下导航 - (navigation as any).navigate('Chat', { - conversationId: String(conversation.id), - groupId: String(conversation.group.id), - groupName: conversation.group.name, - isGroupChat: true, - }); + router.push( + hrefs.hrefChat({ + conversationId: String(conversation.id), + groupId: String(conversation.group.id), + groupName: conversation.group.name, + isGroupChat: true, + }) + ); } else { // 私聊 - 窄屏下导航 const currentUserId = useAuthStore.getState().currentUser?.id; const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId)); - (navigation as any).navigate('Chat', { - conversationId: String(conversation.id), - userId: otherUser ? String(otherUser.id) : undefined, - userName: otherUser?.nickname || otherUser?.username, - isGroupChat: false, - }); + router.push( + hrefs.hrefChat({ + conversationId: String(conversation.id), + userId: otherUser ? String(otherUser.id) : undefined, + isGroupChat: false, + }) + ); } }); }; @@ -311,12 +311,12 @@ export const MessageListScreen: React.FC = () => { // 创建群聊 const handleCreateGroup = () => { - (navigation as any).navigate('CreateGroup'); + router.push(hrefs.hrefGroupCreate()); }; // 主动加群 const handleJoinGroup = () => { - (navigation as any).navigate('JoinGroup'); + router.push(hrefs.hrefGroupJoin()); }; const handleOpenActionMenu = () => { @@ -581,12 +581,13 @@ export const MessageListScreen: React.FC = () => { const conversation = await createConversation(String(user.id)); if (conversation) { - (navigation as any).navigate('Chat', { - conversationId: String(conversation.id), - userId: String(user.id), - userName: user.nickname || user.username, - isGroupChat: false, - }); + router.push( + hrefs.hrefChat({ + conversationId: String(conversation.id), + userId: String(user.id), + isGroupChat: false, + }) + ); } } catch (error) { console.error('创建会话失败:', error); @@ -660,9 +661,7 @@ export const MessageListScreen: React.FC = () => { const renderSearchMode = () => ( - - - + void }> = ({ onBack }) => { const isFocused = useIsFocused(); const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount(); - const navigation = useNavigation>(); + const router = useRouter(); // 修复竞态条件:使用本地状态管理窗口尺寸,避免 hydration 问题 const [windowSize, setWindowSize] = useState({ width: 0, height: 0 }); @@ -291,17 +290,17 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack ) { const postId = await resolvePostId(message); if (postId) { - navigation.navigate('PostDetail', { postId }); + router.push(hrefs.hrefPostDetail(postId)); } } else if (system_type === 'follow') { // 关注 - 跳转到用户主页 if (extra_data?.actor_id_str) { - navigation.navigate('UserProfile', { userId: extra_data.actor_id_str }); + router.push(hrefs.hrefUserProfile(extra_data.actor_id_str)); } } else if (system_type === 'group_join_apply') { - navigation.navigate('GroupRequestDetail', { message }); + router.push(hrefs.hrefGroupRequestDetail(message)); } else if (system_type === 'group_invite') { - navigation.navigate('GroupInviteDetail', { message }); + router.push(hrefs.hrefGroupInviteDetail(message)); } // 其他类型暂不处理跳转 } catch (error) { @@ -316,7 +315,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack const actorId = extra_data?.actor_id_str || (extra_data?.actor_id ? String(extra_data.actor_id) : null); if (actorId) { - navigation.navigate('UserProfile', { userId: actorId }); + router.push(hrefs.hrefUserProfile(actorId)); } }; @@ -350,13 +349,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack return ( {shouldShowBackButton ? ( - - - + ) : ( )} diff --git a/src/screens/message/PrivateChatInfoScreen.tsx b/src/screens/message/PrivateChatInfoScreen.tsx index 0a8c5e3..e7daa98 100644 --- a/src/screens/message/PrivateChatInfoScreen.tsx +++ b/src/screens/message/PrivateChatInfoScreen.tsx @@ -14,8 +14,7 @@ import { Switch, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter, useLocalSearchParams } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; import { useAuthStore } from '../../stores'; @@ -30,16 +29,20 @@ import { messageManager } from '../../stores/messageManager'; import { userManager } from '../../stores/userManager'; import { Avatar, Text, Button, Loading, Divider } from '../../components/common'; import { User } from '../../types'; -import { RootStackParamList, MessageStackParamList } from '../../navigation/types'; -import { navigationService } from '../../infrastructure/navigation/navigationService'; - -type NavigationProp = NativeStackNavigationProp; -type PrivateChatInfoRouteProp = RouteProp; +import * as hrefs from '../../navigation/hrefs'; const PrivateChatInfoScreen: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute(); - const { conversationId, userId, userName, userAvatar } = route.params; + const router = useRouter(); + const raw = useLocalSearchParams<{ + conversationId?: string; + userId?: string; + userName?: string; + userAvatar?: string; + }>(); + const conversationId = raw.conversationId ?? ''; + const userId = raw.userId ?? ''; + const userName = raw.userName; + const userAvatar = raw.userAvatar; const { currentUser } = useAuthStore(); // 用户信息状态 @@ -150,8 +153,7 @@ const PrivateChatInfoScreen: React.FC = () => { // 查看用户资料 const handleViewProfile = () => { - // 使用导航服务跳转到用户资料页面 - navigationService.navigate('UserProfile', { userId }); + router.push(hrefs.hrefUserProfile(userId)); }; // 举报/投诉 @@ -219,7 +221,7 @@ const PrivateChatInfoScreen: React.FC = () => { } setIsBlocked(true); messageManager.removeConversation(conversationId); - navigation.navigate('MessageList' as any); + router.replace(hrefs.hrefMessages()); Alert.alert('已拉黑', '你已成功拉黑该用户'); } catch (error) { Alert.alert('错误', '拉黑失败,请稍后重试'); @@ -245,7 +247,7 @@ const PrivateChatInfoScreen: React.FC = () => { await messageService.deleteConversationForSelf(conversationId); messageManager.removeConversation(conversationId); // 返回消息列表 - navigation.navigate('MessageList' as any); + router.replace(hrefs.hrefMessages()); } catch (error) { const msg = error instanceof ApiError ? error.message : '删除聊天失败'; Alert.alert('错误', msg); diff --git a/src/screens/message/components/ChatScreen/ChatHeader.tsx b/src/screens/message/components/ChatScreen/ChatHeader.tsx index 437a6d1..71cb2a1 100644 --- a/src/screens/message/components/ChatScreen/ChatHeader.tsx +++ b/src/screens/message/components/ChatScreen/ChatHeader.tsx @@ -7,10 +7,10 @@ import React, { useMemo } from 'react'; import { View, TouchableOpacity, StyleSheet } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { Avatar, Text } from '../../../../components/common'; +import { Avatar, Text, AppBackButton } from '../../../../components/common'; import { colors, spacing } from '../../../../theme'; import { chatScreenStyles as baseStyles } from './styles'; -import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive'; +import { useBreakpointGTE } from '../../../../hooks/useResponsive'; import { ChatHeaderProps } from './types'; export const ChatHeader: React.FC = ({ @@ -26,7 +26,6 @@ export const ChatHeader: React.FC = ({ isWideScreen: propIsWideScreen, }) => { // 响应式布局 - const { width } = useResponsive(); // 使用 768px 作为大屏幕和小屏幕的分界线 const isWideScreenHook = useBreakpointGTE('lg'); // 优先使用 props 传入的 isWideScreen,否则使用 hook @@ -74,12 +73,7 @@ export const ChatHeader: React.FC = ({ {/* 大屏幕(>= 768px)时隐藏返回按钮 */} {!isWideScreen ? ( - - - + ) : ( )} @@ -131,14 +125,14 @@ export const ChatHeader: React.FC = ({ style={styles.moreButton} onPress={onGroupInfoPress} > - + ) : ( - + )} diff --git a/src/screens/message/components/ChatScreen/MessageBubble.tsx b/src/screens/message/components/ChatScreen/MessageBubble.tsx index b4feaa0..3fb6dc7 100644 --- a/src/screens/message/components/ChatScreen/MessageBubble.tsx +++ b/src/screens/message/components/ChatScreen/MessageBubble.tsx @@ -120,7 +120,7 @@ export const MessageBubble: React.FC = ({ const data = s.data as ImageSegmentData; return { id: `img-${message.id}-${idx}`, - url: data.url, + url: data.url || data.thumbnail_url || '', thumbnail_url: data.thumbnail_url, width: data.width, height: data.height, @@ -128,7 +128,6 @@ export const MessageBubble: React.FC = ({ }); // 检查当前消息是否被选中 - const isSelected = selectedMessageId === String(message.id); // 获取发送者信息(群聊)- 供子组件使用 const getSenderInfo = React.useCallback((senderId: string): SenderInfo => { @@ -307,7 +306,6 @@ export const MessageBubble: React.FC = ({ isMe ? styles.myBubble : styles.theirBubble, hasReply && segmentStyles.replyBubble, isPureImageMessage && segmentStyles.pureImageBubble, - isSelected && (isMe ? styles.mySelectedBubble : styles.theirSelectedBubble), ]}> = ({ onReplyPress={onReplyPress} onImagePress={(url) => { // 查找点击的图片索引 - const clickIndex = imageSegments.findIndex(img => img.url === url); + const clickIndex = imageSegments.findIndex( + img => img.url === url || img.thumbnail_url === url + ); if (clickIndex !== -1 && onImagePress) { onImagePress(imageSegments, clickIndex); } @@ -419,7 +419,7 @@ export const MessageBubble: React.FC = ({ )} - + {/* 群聊模式:显示发送者昵称 */} {isGroupChat && !isMe && senderInfo && ( {senderInfo.nickname} @@ -483,16 +483,4 @@ const segmentStyles = StyleSheet.create({ }, }); -// 使用 React.memo 优化渲染性能 -export default React.memo(MessageBubble, (prevProps, nextProps) => { - // 自定义比较逻辑:只比较关键属性 - return ( - prevProps.message.id === nextProps.message.id && - prevProps.message.status === nextProps.message.status && - prevProps.selectedMessageId === nextProps.selectedMessageId && - prevProps.currentUserId === nextProps.currentUserId && - prevProps.isGroupChat === nextProps.isGroupChat && - prevProps.otherUserLastReadSeq === nextProps.otherUserLastReadSeq && - prevProps.index === nextProps.index - ); -}); +export default MessageBubble; diff --git a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx index a8eaa7c..502024e 100644 --- a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx +++ b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx @@ -3,7 +3,7 @@ * 用于渲染消息链中的各种 Segment 类型 */ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { View, Text, @@ -131,11 +131,21 @@ const ImageSegment: React.FC<{ const pressPositionRef = useRef({ x: 0, y: 0 }); const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null); const [loadError, setLoadError] = useState(false); - const imageUrl = data.thumbnail_url || data.url; + const [currentImageUri, setCurrentImageUri] = useState(data.thumbnail_url || data.url || ''); + const imageUrl = currentImageUri; + const pressUrl = data.url || data.thumbnail_url || ''; + const stableImageKey = `${data.url || ''}|${data.thumbnail_url || ''}|${data.width || 0}x${data.height || 0}`; // 如果没有有效的图片URL,显示图片占位符 const hasValidUrl = !!imageUrl && imageUrl.trim() !== ''; + useEffect(() => { + // 切换消息图片时重置状态,避免列表复用导致旧状态污染 + setCurrentImageUri(data.thumbnail_url || data.url || ''); + setDimensions(null); + setLoadError(false); + }, [data.thumbnail_url, data.url]); + useEffect(() => { // 重置状态 setLoadError(false); @@ -169,9 +179,7 @@ const ImageSegment: React.FC<{ // 添加超时处理,防止高分辨率图片加载卡住 const timeoutId = setTimeout(() => { - if (!dimensions) { - setDimensions(IMAGE_FALLBACK_SIZE); - } + setDimensions(prev => prev || IMAGE_FALLBACK_SIZE); }, 3000); // 否则从图片获取实际尺寸 @@ -196,7 +204,7 @@ const ImageSegment: React.FC<{ setDimensions({ width: Math.round(newWidth), height: Math.round(newHeight) }); }, - (error) => { + () => { clearTimeout(timeoutId); // 获取失败时使用默认 4:3 比例 setDimensions(IMAGE_FALLBACK_SIZE); @@ -221,10 +229,9 @@ const ImageSegment: React.FC<{ if (!hasValidUrl || loadError) { return ( hasValidUrl && onImagePress?.(data.url)} + onPress={() => hasValidUrl && onImagePress?.(pressUrl)} onLongPress={handleLongPress} delayLongPress={500} activeOpacity={0.9} @@ -250,10 +257,9 @@ const ImageSegment: React.FC<{ if (!dimensions) { return ( onImagePress?.(data.url)} + onPress={() => onImagePress?.(pressUrl)} onLongPress={handleLongPress} delayLongPress={500} activeOpacity={0.9} @@ -272,10 +278,9 @@ const ImageSegment: React.FC<{ return ( onImagePress?.(data.url)} + onPress={() => onImagePress?.(pressUrl)} onLongPress={handleLongPress} delayLongPress={500} activeOpacity={0.9} @@ -287,11 +292,20 @@ const ImageSegment: React.FC<{ height: dimensions.height, borderRadius: 8, }} + recyclingKey={stableImageKey} contentFit="cover" - cachePolicy="disk" + cachePolicy="memory-disk" priority="normal" transition={200} + onLoadStart={() => { + setLoadError(false); + }} onError={() => { + // 缩略图失败时自动回退原图,降低跳转定位后的白块/丢图概率 + if (data.thumbnail_url && data.url && imageUrl === data.thumbnail_url) { + setCurrentImageUri(data.url); + return; + } setLoadError(true); }} /> @@ -680,7 +694,9 @@ export const MessageSegmentsRenderer: React.FC<{ {/* 其他 Segment 内容 */} {otherSegments.map((segment, index) => ( - + {renderSegment(segment, renderProps)} ))} @@ -738,17 +754,17 @@ const styles = StyleSheet.create({ paddingHorizontal: 2, }, atTextMe: { - color: '#1976D2', // 蓝色 - backgroundColor: 'rgba(25, 118, 210, 0.12)', + color: '#4A88C7', + backgroundColor: 'rgba(74, 136, 199, 0.12)', borderRadius: 4, }, atTextOther: { - color: '#1976D2', // 蓝色 - backgroundColor: 'rgba(25, 118, 210, 0.12)', + color: '#4A88C7', + backgroundColor: 'rgba(74, 136, 199, 0.12)', borderRadius: 4, }, atHighlight: { - backgroundColor: 'rgba(25, 118, 210, 0.2)', + backgroundColor: 'rgba(74, 136, 199, 0.2)', borderRadius: 4, paddingHorizontal: 4, }, @@ -966,7 +982,7 @@ const styles = StyleSheet.create({ elevation: 0, }, replyMe: { - backgroundColor: 'rgba(25, 118, 210, 0.1)', + backgroundColor: 'rgba(74, 136, 199, 0.12)', }, replyOther: { backgroundColor: 'rgba(0, 0, 0, 0.03)', @@ -984,7 +1000,7 @@ const styles = StyleSheet.create({ replySender: { fontSize: 13, fontWeight: '600', - color: '#1976D2', + color: '#4A88C7', marginRight: 6, flexShrink: 0, }, diff --git a/src/screens/message/components/ChatScreen/bubbleStyles.ts b/src/screens/message/components/ChatScreen/bubbleStyles.ts index 1739007..a5a3667 100644 --- a/src/screens/message/components/ChatScreen/bubbleStyles.ts +++ b/src/screens/message/components/ChatScreen/bubbleStyles.ts @@ -23,8 +23,8 @@ export const bubbleColors = { }, // 被回复的消息高亮 replyHighlight: { - background: 'rgba(255, 107, 53, 0.08)', - borderLeft: '#FF6B35', + background: 'rgba(74, 136, 199, 0.1)', + borderLeft: '#4A88C7', }, }; diff --git a/src/screens/message/components/ChatScreen/styles.ts b/src/screens/message/components/ChatScreen/styles.ts index 1a2bd32..b438fc9 100644 --- a/src/screens/message/components/ChatScreen/styles.ts +++ b/src/screens/message/components/ChatScreen/styles.ts @@ -200,7 +200,7 @@ export const chatScreenStyles = StyleSheet.create({ }, senderName: { fontSize: 13, - color: '#1976D2', + color: '#4A88C7', marginBottom: 4, marginLeft: 4, fontWeight: '600', @@ -215,7 +215,7 @@ export const chatScreenStyles = StyleSheet.create({ minWidth: 60, }, myBubble: { - backgroundColor: '#E3F2FD', // Telegram风格的浅蓝色 + backgroundColor: '#DFF2FF', // Telegram风格的浅蓝色 borderBottomRightRadius: 4, // 右下角尖,箭头在右下 shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, @@ -255,17 +255,23 @@ export const chatScreenStyles = StyleSheet.create({ // 选中状态 selectedBubble: { borderWidth: 2, - borderColor: '#007AFF', + borderColor: '#7FB6E6', + }, + myMessageContentPanel: { + backgroundColor: '#DFF2FF', + borderRadius: 14, + paddingHorizontal: 4, + paddingBottom: 4, }, mySelectedBubble: { - backgroundColor: '#0051D5', + backgroundColor: '#CFEAFF', borderWidth: 2, - borderColor: 'rgba(255,255,255,0.5)', + borderColor: '#7FB6E6', }, theirSelectedBubble: { - backgroundColor: '#E8F1FF', + backgroundColor: '#EEF5FC', borderWidth: 2, - borderColor: '#007AFF', + borderColor: '#7FB6E6', }, selectedText: { // 文本选中时的样式 @@ -539,7 +545,7 @@ export const chatScreenStyles = StyleSheet.create({ marginRight: spacing.xs, }, panelTabActive: { - backgroundColor: '#E3F2FD', + backgroundColor: '#DFF2FF', }, panelTabEmoji: { fontSize: 24, @@ -1025,11 +1031,11 @@ export const chatScreenStyles = StyleSheet.create({ // 表情包选择状态 stickerItemSelected: { borderWidth: 2, - borderColor: '#007AFF', + borderColor: '#7FB6E6', }, stickerCheckOverlay: { ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0, 122, 255, 0.1)', + backgroundColor: 'rgba(127, 182, 230, 0.14)', alignItems: 'flex-end', justifyContent: 'flex-start', padding: 4, @@ -1045,8 +1051,8 @@ export const chatScreenStyles = StyleSheet.create({ justifyContent: 'center', }, stickerCheckBoxSelected: { - backgroundColor: '#007AFF', - borderColor: '#007AFF', + backgroundColor: '#7FB6E6', + borderColor: '#7FB6E6', }, // 表情管理模态框 @@ -1077,12 +1083,12 @@ export const chatScreenStyles = StyleSheet.create({ }, manageDoneText: { fontSize: 16, - color: '#007AFF', + color: '#4A88C7', fontWeight: '500', }, manageSelectText: { fontSize: 16, - color: '#007AFF', + color: '#4A88C7', fontWeight: '500', }, manageInfoBar: { @@ -1120,7 +1126,7 @@ export const chatScreenStyles = StyleSheet.create({ }, manageSelectAllText: { fontSize: 15, - color: '#007AFF', + color: '#4A88C7', fontWeight: '500', }, manageDeleteButton: { diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 8062eec..f5fecc4 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -17,7 +17,7 @@ import { KeyboardEvent, Alert, } from 'react-native'; -import { useRoute, RouteProp, useNavigation } from '@react-navigation/native'; +import { useLocalSearchParams, router } from 'expo-router'; import { formatDistanceToNow } from 'date-fns'; import { zhCN } from 'date-fns/locale'; import * as ImagePicker from 'expo-image-picker'; @@ -30,8 +30,8 @@ import { useChat, useGroupTyping, useGroupMuted, messageManager } from '../../.. import { groupService } from '../../../../services/groupService'; import { userManager } from '../../../../stores/userManager'; import { groupManager } from '../../../../stores/groupManager'; -import { RootStackParamList } from '../../../../navigation/types'; -import { navigationService } from '../../../../infrastructure/navigation/navigationService'; +import * as hrefs from '../../../../navigation/hrefs'; +import { firstRouteParam } from '../../../../navigation/paramUtils'; import { GroupMessage, PanelType, @@ -46,8 +46,6 @@ import { clearConversationMessages, } from '../../../../services/database'; -type ChatRouteProp = RouteProp; - export const useChatScreen = () => { const getSendErrorMessage = useCallback((error: unknown, fallback: string) => { if (error instanceof ApiError && error.message) { @@ -56,17 +54,30 @@ export const useChatScreen = () => { return fallback; }, []); - const route = useRoute(); - const navigation = useNavigation(); - - // 路由参数 - const routeParams = useMemo(() => ({ - conversationId: route.params?.conversationId || null, - userId: route.params?.userId || null, - isGroupChat: route.params?.isGroupChat || false, - groupId: route.params?.groupId, - groupName: route.params?.groupName, - }), [route.params?.conversationId, route.params?.userId, route.params?.isGroupChat, route.params?.groupId, route.params?.groupName]); + const rawParams = useLocalSearchParams<{ + conversationId?: string | string[]; + userId?: string | string[]; + isGroupChat?: string | string[]; + groupId?: string | string[]; + groupName?: string | string[]; + }>(); + // 路由参数(动态段 + query);群 ID 勿用 Number(),避免超过 2^53-1 时精度丢失 + const routeParams = useMemo(() => { + const isGroupFlag = firstRouteParam(rawParams.isGroupChat); + return { + conversationId: firstRouteParam(rawParams.conversationId) ?? null, + userId: firstRouteParam(rawParams.userId) ?? null, + isGroupChat: isGroupFlag === '1' || isGroupFlag === 'true', + groupId: firstRouteParam(rawParams.groupId), + groupName: firstRouteParam(rawParams.groupName), + }; + }, [ + rawParams.conversationId, + rawParams.userId, + rawParams.isGroupChat, + rawParams.groupId, + rawParams.groupName, + ]); const { conversationId: routeConversationId, userId: routeUserId, isGroupChat, groupId: routeGroupId, groupName: routeGroupName } = routeParams; @@ -399,8 +410,30 @@ export const useChatScreen = () => { console.error('获取成员信息失败:', error); } } catch (error) { + const isGroupNotFound = + error instanceof ApiError && + (error.code === 404 || + error.message === '群组不存在' || + error.message.includes('群组不存在')); + + if (isGroupNotFound) { + Alert.alert('提示', '该群组不存在或已解散', [ + { + text: '确定', + onPress: () => { + if (router.canGoBack()) { + router.back(); + } else { + router.replace(hrefs.hrefMessages()); + } + }, + }, + ]); + return; + } + console.error('获取群组信息失败:', error); - Alert.alert('错误', '无法获取群组信息'); + Alert.alert('错误', getSendErrorMessage(error, '无法获取群组信息')); } }; @@ -1129,7 +1162,7 @@ export const useChatScreen = () => { // 点击头像跳转到用户主页 const handleAvatarPress = useCallback((userId: string) => { if (userId && userId !== currentUserId) { - navigationService.navigate('UserProfile', { userId: String(userId) }); + router.push(hrefs.hrefUserProfile(String(userId))); } }, [currentUserId]); @@ -1198,31 +1231,27 @@ export const useChatScreen = () => { // 导航到群组信息或用户资料 const navigateToInfo = useCallback(() => { if (isGroupChat && routeGroupId) { - navigation.navigate('GroupInfo' as any, { - groupId: routeGroupId, - conversationId: conversationId || undefined, - }); + router.push(hrefs.hrefGroupInfo(routeGroupId, conversationId || undefined)); } else if (otherUser?.id) { - navigationService.navigate('UserProfile', { userId: String(otherUser.id) }); + router.push(hrefs.hrefUserProfile(String(otherUser.id))); } - }, [navigation, isGroupChat, routeGroupId, otherUser]); + }, [isGroupChat, routeGroupId, otherUser, conversationId]); // 导航到聊天管理页面 const navigateToChatSettings = useCallback(() => { if (isGroupChat && routeGroupId) { - navigation.navigate('GroupInfo' as any, { - groupId: routeGroupId, - conversationId: conversationId || undefined, - }); + router.push(hrefs.hrefGroupInfo(routeGroupId, conversationId || undefined)); } else if (otherUser?.id && conversationId) { - navigation.navigate('PrivateChatInfo' as any, { - conversationId: conversationId, - userId: String(otherUser.id), - userName: otherUser.nickname, - userAvatar: otherUser.avatar, - }); + router.push( + hrefs.hrefPrivateChatInfo({ + conversationId, + userId: String(otherUser.id), + userName: otherUser.nickname, + userAvatar: otherUser.avatar, + }) + ); } - }, [navigation, isGroupChat, routeGroupId, otherUser, conversationId]); + }, [isGroupChat, routeGroupId, otherUser, conversationId]); return { // 状态 diff --git a/src/screens/message/components/EmbeddedChat.tsx b/src/screens/message/components/EmbeddedChat.tsx index 8f7eecf..5357c57 100644 --- a/src/screens/message/components/EmbeddedChat.tsx +++ b/src/screens/message/components/EmbeddedChat.tsx @@ -16,17 +16,18 @@ import { Dimensions, Animated, } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Image as ExpoImage } from 'expo-image'; import { colors, spacing, fontSizes, shadows } from '../../../theme'; import { ConversationResponse, MessageResponse, MessageSegment, GroupMemberResponse, ImageSegmentData } from '../../../types/dto'; -import { Avatar, Text, ImageGallery } from '../../../components/common'; +import { Avatar, Text, ImageGallery, AppBackButton } from '../../../components/common'; import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores'; import { extractTextFromSegments } from '../../../types/dto'; import { useBreakpointGTE } from '../../../hooks/useResponsive'; import { useMarkAsRead } from '../../../stores/messageManagerHooks'; import { messageService } from '../../../services'; +import * as hrefs from '../../../navigation/hrefs'; import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel'; import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen'; @@ -39,7 +40,7 @@ interface EmbeddedChatProps { } export const EmbeddedChat: React.FC = ({ conversation, onBack }) => { - const navigation = useNavigation(); + const router = useRouter(); const currentUser = useAuthStore(state => state.currentUser); // 响应式布局 - 使用 768px 作为大屏幕和小屏幕的分界线 @@ -190,21 +191,24 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack // 导航到完整聊天页面 const handleNavigateToFullChat = () => { if (isGroupChat && conversation.group) { - (navigation as any).navigate('Chat', { - conversationId: String(conversation.id), - groupId: String(conversation.group.id), - groupName: conversation.group.name, - isGroupChat: true, - }); + router.push( + hrefs.hrefChat({ + conversationId: String(conversation.id), + groupId: String(conversation.group.id), + groupName: conversation.group.name, + isGroupChat: true, + }) as any + ); } else { const currentUserId = currentUser?.id; const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId)); - (navigation as any).navigate('Chat', { - conversationId: String(conversation.id), - userId: otherUser ? String(otherUser.id) : undefined, - userName: otherUser?.nickname || otherUser?.username, - isGroupChat: false, - }); + router.push( + hrefs.hrefChat({ + conversationId: String(conversation.id), + userId: otherUser ? String(otherUser.id) : undefined, + isGroupChat: false, + }) as any + ); } }; @@ -407,9 +411,7 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack {/* 大屏幕(>= 768px)时隐藏返回按钮 */} {!isWideScreen ? ( - - - + ) : ( )} diff --git a/src/screens/profile/BlockedUsersScreen.tsx b/src/screens/profile/BlockedUsersScreen.tsx index dc40c81..fd282b9 100644 --- a/src/screens/profile/BlockedUsersScreen.tsx +++ b/src/screens/profile/BlockedUsersScreen.tsx @@ -9,14 +9,16 @@ import { Alert, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { navigationService } from '../../infrastructure/navigation/navigationService'; +import { useRouter } from 'expo-router'; import { Avatar, Button, EmptyState, ResponsiveContainer, Text } from '../../components/common'; import { authService } from '../../services'; import { colors, spacing, borderRadius, shadows } from '../../theme'; import { User } from '../../types'; import { useResponsive } from '../../hooks'; +import * as hrefs from '../../navigation/hrefs'; export const BlockedUsersScreen: React.FC = () => { + const router = useRouter(); const { isMobile } = useResponsive(); const insets = useSafeAreaInsets(); const [users, setUsers] = useState([]); @@ -78,7 +80,7 @@ export const BlockedUsersScreen: React.FC = () => { navigationService.navigate('UserProfile', { userId: item.id })} + onPress={() => router.push(hrefs.hrefUserProfile(item.id))} > diff --git a/src/screens/profile/FollowListScreen.tsx b/src/screens/profile/FollowListScreen.tsx index dafa64a..f6802a0 100644 --- a/src/screens/profile/FollowListScreen.tsx +++ b/src/screens/profile/FollowListScreen.tsx @@ -16,23 +16,19 @@ import { ActivityIndicator, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter, useLocalSearchParams } from 'expo-router'; import { colors, spacing, borderRadius, shadows } from '../../theme'; import { User } from '../../types'; import { useAuthStore, useUserStore } from '../../stores'; import { authService } from '../../services'; import { Avatar, Text, Button, Loading, EmptyState, ResponsiveContainer } from '../../components/common'; -import { HomeStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; import { useResponsive, useColumnCount } from '../../hooks'; -type NavigationProp = NativeStackNavigationProp; -type FollowListRouteProp = RouteProp; - const FollowListScreen: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute(); - const { userId, type } = route.params; + const router = useRouter(); + const { userId = '', type: typeParam } = useLocalSearchParams<{ userId?: string; type?: string }>(); + const type = typeParam === 'followers' ? 'followers' : 'following'; const { currentUser } = useAuthStore(); const { followUser, unfollowUser } = useUserStore(); @@ -135,7 +131,7 @@ const FollowListScreen: React.FC = () => { // 跳转到用户主页 const handleUserPress = (targetUserId: string) => { if (targetUserId !== currentUser?.id) { - navigation.push('UserProfile', { userId: targetUserId }); + router.push(hrefs.hrefUserProfile(targetUserId)); } }; @@ -182,11 +178,11 @@ const FollowListScreen: React.FC = () => { @{item.username} - {item.bio && ( + {item.bio?.trim() ? ( {item.bio} - )} + ) : null} {!isSelf && (