refactor(core): introduce EventBus and refactor store infrastructure
- Add EventBus for decoupled event-driven communication between services - Add EventSubscriber component for centralized event handling - Add requestDedupe utility for shared request deduplication - Refactor api/wsService to use eventBus instead of direct router navigation - Extract MessageMapper.toCachedMessages for consistent message mapping - Remove deprecated BaseManager and CacheBus classes - Improve @mention rendering with memberMap support in segment rendering - Update hooks to use useRef instead of useState for fetch tracking
This commit is contained in:
@@ -12,6 +12,7 @@ import * as SystemUI from 'expo-system-ui';
|
|||||||
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
|
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
|
||||||
import { api } from '../src/services/api';
|
import { api } from '../src/services/api';
|
||||||
import { wsService } from '../src/services/wsService';
|
import { wsService } from '../src/services/wsService';
|
||||||
|
import { EventSubscriber } from '../src/infrastructure/EventSubscriber';
|
||||||
import {
|
import {
|
||||||
ThemeBootstrap,
|
ThemeBootstrap,
|
||||||
useAppColors,
|
useAppColors,
|
||||||
@@ -185,15 +186,11 @@ function ThemedStack() {
|
|||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const resolved = useResolvedColorScheme();
|
const resolved = useResolvedColorScheme();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.setExpoRouter(router);
|
|
||||||
wsService.setRouter(router);
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
||||||
<SystemChrome />
|
<SystemChrome />
|
||||||
|
<EventSubscriber />
|
||||||
<SessionGate>
|
<SessionGate>
|
||||||
<NotificationBootstrap />
|
<NotificationBootstrap />
|
||||||
<APKUpdateBootstrap />
|
<APKUpdateBootstrap />
|
||||||
|
|||||||
31
src/core/events/EventBus.ts
Normal file
31
src/core/events/EventBus.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
type Subscriber<T> = (event: T) => void;
|
||||||
|
|
||||||
|
export type AppEvent =
|
||||||
|
| { type: 'NAVIGATE'; payload: { path: string; params?: Record<string, any> } }
|
||||||
|
| { type: 'NAVIGATE_BACK' }
|
||||||
|
| { type: 'SHOW_VERIFICATION_MODAL' }
|
||||||
|
| { type: 'VIBRATE'; payload: { type: 'message' | 'group_message' | 'call' } }
|
||||||
|
| { type: 'SHOW_TOAST'; payload: { message: string; type: 'success' | 'error' | 'info' } }
|
||||||
|
| { type: 'AUTH_LOGOUT' }
|
||||||
|
| { type: 'AUTH_LOGIN'; payload: { userId: string } };
|
||||||
|
|
||||||
|
class EventBusClass {
|
||||||
|
private subscribers: Set<Subscriber<AppEvent>> = new Set();
|
||||||
|
|
||||||
|
emit(event: AppEvent): void {
|
||||||
|
this.subscribers.forEach(subscriber => {
|
||||||
|
try {
|
||||||
|
subscriber(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EventBus] Subscriber error:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(subscriber: Subscriber<AppEvent>): () => void {
|
||||||
|
this.subscribers.add(subscriber);
|
||||||
|
return () => this.subscribers.delete(subscriber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const eventBus = new EventBusClass();
|
||||||
2
src/core/events/index.ts
Normal file
2
src/core/events/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { eventBus, type AppEvent } from './EventBus';
|
||||||
|
export { useEventBus } from './useEventBus';
|
||||||
8
src/core/events/useEventBus.ts
Normal file
8
src/core/events/useEventBus.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { eventBus, AppEvent } from './EventBus';
|
||||||
|
|
||||||
|
export function useEventBus(handler: (event: AppEvent) => void): void {
|
||||||
|
useEffect(() => {
|
||||||
|
return eventBus.subscribe(handler);
|
||||||
|
}, [handler]);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { messageRepository, userCacheRepository, CachedMessage } from '@/database';
|
import { messageRepository, userCacheRepository, CachedMessage } from '@/database';
|
||||||
import { wsClient, WSEventType } from '../../data/datasources/WSClient';
|
import { wsClient, WSEventType } from '../../data/datasources/WSClient';
|
||||||
|
import { MessageMapper } from '@/data/mappers';
|
||||||
import {
|
import {
|
||||||
Message,
|
Message,
|
||||||
Conversation,
|
Conversation,
|
||||||
@@ -50,7 +51,7 @@ interface ReadStateRecord {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
version: number;
|
version: number;
|
||||||
lastReadSeq: number;
|
lastReadSeq: number;
|
||||||
clearTimer?: NodeJS.Timeout;
|
clearTimer?: ReturnType<typeof setTimeout>;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ProcessMessageUseCase {
|
class ProcessMessageUseCase {
|
||||||
@@ -596,16 +597,9 @@ class ProcessMessageUseCase {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 保存到本地
|
// 保存到本地
|
||||||
await messageRepository.saveMessagesBatch(serverMessages.map((m: any) => ({
|
await messageRepository.saveMessagesBatch(
|
||||||
id: m.id,
|
MessageMapper.toCachedMessages(serverMessages, conversationId)
|
||||||
conversationId: m.conversation_id || conversationId,
|
);
|
||||||
senderId: m.sender_id,
|
|
||||||
seq: m.seq,
|
|
||||||
segments: m.segments || [],
|
|
||||||
createdAt: m.created_at,
|
|
||||||
status: m.status || 'normal',
|
|
||||||
isRead: false,
|
|
||||||
})));
|
|
||||||
|
|
||||||
return serverMessages;
|
return serverMessages;
|
||||||
}
|
}
|
||||||
@@ -639,17 +633,9 @@ class ProcessMessageUseCase {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// 保存到本地
|
await messageRepository.saveMessagesBatch(
|
||||||
await messageRepository.saveMessagesBatch(messages.map((m: any) => ({
|
MessageMapper.toCachedMessages(response.messages, conversationId)
|
||||||
id: m.id,
|
);
|
||||||
conversationId: m.conversation_id || conversationId,
|
|
||||||
senderId: m.sender_id,
|
|
||||||
seq: m.seq,
|
|
||||||
segments: m.segments || [],
|
|
||||||
createdAt: m.created_at,
|
|
||||||
status: m.status || 'normal',
|
|
||||||
isRead: false,
|
|
||||||
})));
|
|
||||||
|
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
MessageResponse,
|
MessageResponse,
|
||||||
UserDTO,
|
UserDTO,
|
||||||
} from '../../types/dto';
|
} from '../../types/dto';
|
||||||
|
import type { CachedMessage } from '../../database/types/CachedMessage';
|
||||||
|
|
||||||
// 数据库消息记录类型
|
// 数据库消息记录类型
|
||||||
export interface MessageDbRecord {
|
export interface MessageDbRecord {
|
||||||
@@ -180,4 +181,23 @@ export class MessageMapper {
|
|||||||
avatar: sender.avatar || undefined,
|
avatar: sender.avatar || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static toCachedMessage(apiMessage: any, conversationId?: string): CachedMessage {
|
||||||
|
return {
|
||||||
|
id: apiMessage.id,
|
||||||
|
conversationId: apiMessage.conversation_id || conversationId || '',
|
||||||
|
senderId: apiMessage.sender_id,
|
||||||
|
content: apiMessage.content,
|
||||||
|
type: apiMessage.type || 'text',
|
||||||
|
isRead: apiMessage.is_read || false,
|
||||||
|
createdAt: apiMessage.created_at,
|
||||||
|
seq: apiMessage.seq,
|
||||||
|
status: apiMessage.status || 'normal',
|
||||||
|
segments: apiMessage.segments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static toCachedMessages(apiMessages: any[], conversationId?: string): CachedMessage[] {
|
||||||
|
return apiMessages.map(m => this.toCachedMessage(m, conversationId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/infrastructure/EventSubscriber.tsx
Normal file
44
src/infrastructure/EventSubscriber.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { eventBus, AppEvent } from '@/core/events/EventBus';
|
||||||
|
import { hrefVerificationGuide } from '@/navigation/hrefs';
|
||||||
|
|
||||||
|
let verificationModalShown = false;
|
||||||
|
|
||||||
|
export function EventSubscriber() {
|
||||||
|
useEffect(() => {
|
||||||
|
return eventBus.subscribe((event: AppEvent) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'NAVIGATE':
|
||||||
|
router.push(event.payload.path);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'NAVIGATE_BACK':
|
||||||
|
router.back();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'SHOW_VERIFICATION_MODAL':
|
||||||
|
if (verificationModalShown) return;
|
||||||
|
verificationModalShown = true;
|
||||||
|
router.push(hrefVerificationGuide());
|
||||||
|
setTimeout(() => {
|
||||||
|
verificationModalShown = false;
|
||||||
|
}, 3000);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'VIBRATE':
|
||||||
|
Haptics.notificationAsync(
|
||||||
|
event.payload.type === 'message'
|
||||||
|
? Haptics.NotificationFeedbackType.Success
|
||||||
|
: event.payload.type === 'group_message'
|
||||||
|
? Haptics.NotificationFeedbackType.Warning
|
||||||
|
: Haptics.NotificationFeedbackType.Error
|
||||||
|
).catch(() => {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -211,6 +211,17 @@ export const ChatScreen: React.FC = () => {
|
|||||||
} = useChatScreen();
|
} = useChatScreen();
|
||||||
const displayMessages = useMemo(() => [...messages].reverse(), [messages]);
|
const displayMessages = useMemo(() => [...messages].reverse(), [messages]);
|
||||||
|
|
||||||
|
const longPressMenuMemberMap = useMemo(() => {
|
||||||
|
const map = new Map<string, { nickname: string; user_id: string }>();
|
||||||
|
groupMembers.forEach(m => {
|
||||||
|
map.set(m.user_id, {
|
||||||
|
nickname: m.nickname || m.user?.nickname || '用户',
|
||||||
|
user_id: m.user_id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [groupMembers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loadingMore && showEdgeLoadingIndicator) {
|
if (!loadingMore && showEdgeLoadingIndicator) {
|
||||||
setShowEdgeLoadingIndicator(false);
|
setShowEdgeLoadingIndicator(false);
|
||||||
@@ -629,6 +640,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
onReply={handleReplyMessage}
|
onReply={handleReplyMessage}
|
||||||
onRecall={handleRecall}
|
onRecall={handleRecall}
|
||||||
onDelete={handleDeleteMessage}
|
onDelete={handleDeleteMessage}
|
||||||
|
memberMap={longPressMenuMemberMap}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 图片查看器 */}
|
{/* 图片查看器 */}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
|
|||||||
onRecall,
|
onRecall,
|
||||||
onDelete,
|
onDelete,
|
||||||
onAddSticker,
|
onAddSticker,
|
||||||
|
onReport,
|
||||||
|
memberMap,
|
||||||
}) => {
|
}) => {
|
||||||
const styles = useChatScreenStyles();
|
const styles = useChatScreenStyles();
|
||||||
const scaleAnimation = useRef(new Animated.Value(0)).current;
|
const scaleAnimation = useRef(new Animated.Value(0)).current;
|
||||||
@@ -90,7 +92,7 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
|
|||||||
|
|
||||||
// 复制文本到剪贴板
|
// 复制文本到剪贴板
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
const text = extractTextFromSegments(message.segments);
|
const text = extractTextFromSegments(message.segments, memberMap);
|
||||||
if (text) {
|
if (text) {
|
||||||
Clipboard.setString(text);
|
Clipboard.setString(text);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -99,7 +101,7 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
|
|||||||
|
|
||||||
// 多选功能(这里简化为复制全部)
|
// 多选功能(这里简化为复制全部)
|
||||||
const handleMultiSelect = () => {
|
const handleMultiSelect = () => {
|
||||||
const text = extractTextFromSegments(message.segments);
|
const text = extractTextFromSegments(message.segments, memberMap);
|
||||||
if (text) {
|
if (text) {
|
||||||
Clipboard.setString(text);
|
Clipboard.setString(text);
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export const renderSegment = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染文本 Segment
|
* 渲染文本 Segment(非内联,块级使用)
|
||||||
*/
|
*/
|
||||||
const TextSegmentBody: React.FC<{ data: TextSegmentData; isMe: boolean }> = ({ data, isMe }) => {
|
const TextSegmentBody: React.FC<{ data: TextSegmentData; isMe: boolean }> = ({ data, isMe }) => {
|
||||||
const styles = useSegmentStyles();
|
const styles = useSegmentStyles();
|
||||||
@@ -392,7 +392,7 @@ const AtSegmentBody: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.atText,
|
styles.textContent,
|
||||||
isMe ? styles.atTextMe : styles.atTextOther,
|
isMe ? styles.atTextMe : styles.atTextOther,
|
||||||
isAtMe && styles.atHighlight,
|
isAtMe && styles.atHighlight,
|
||||||
]}
|
]}
|
||||||
@@ -773,15 +773,47 @@ const MessageSegmentsRendererInner: React.FC<{
|
|||||||
{chunks.map((chunk, i) => {
|
{chunks.map((chunk, i) => {
|
||||||
if (chunk.kind === 'inline') {
|
if (chunk.kind === 'inline') {
|
||||||
return (
|
return (
|
||||||
<View key={`inl-${i}`} style={segStyles.inlineChunk}>
|
<Text key={`inl-${i}`} style={[segStyles.inlineChunkBase, isMe ? segStyles.textMe : segStyles.textOther]} selectable={true}>
|
||||||
{chunk.parts.map((segment, j) => (
|
{chunk.parts.map((segment, j) => {
|
||||||
<React.Fragment
|
if (segment.type === 'text') {
|
||||||
key={`segment-${segment.type}-${(segment as any)?.data?.url || (segment as any)?.data?.id || (segment as any)?.data?.user_id || j}`}
|
const data = segment.data as TextSegmentData;
|
||||||
>
|
return data.content ?? data.text ?? '';
|
||||||
{renderSegment(segment, renderProps)}
|
}
|
||||||
</React.Fragment>
|
if (segment.type === 'at') {
|
||||||
))}
|
const data = segment.data as AtSegmentData;
|
||||||
</View>
|
const isAtMe = data.user_id === currentUserId || data.user_id === 'all';
|
||||||
|
let displayName: string;
|
||||||
|
if (data.user_id === 'all') {
|
||||||
|
displayName = '所有人';
|
||||||
|
} else {
|
||||||
|
const member = memberMap?.get(data.user_id);
|
||||||
|
displayName = member?.nickname || data.nickname || '用户';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={`at-${j}`}
|
||||||
|
style={[
|
||||||
|
isMe ? segStyles.atTextMe : segStyles.atTextOther,
|
||||||
|
isAtMe && segStyles.atHighlight,
|
||||||
|
]}
|
||||||
|
onPress={() => data.user_id !== 'all' && onAtPress?.(data.user_id)}
|
||||||
|
>
|
||||||
|
@{displayName}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (segment.type === 'face') {
|
||||||
|
const data = segment.data as FaceSegmentData;
|
||||||
|
if (data.url) {
|
||||||
|
return (
|
||||||
|
<Image key={`face-${j}`} source={{ uri: data.url }} style={segStyles.faceImage} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `[${data.name || `表情${data.id}`}]`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (chunk.kind === 'images') {
|
if (chunk.kind === 'images') {
|
||||||
@@ -836,11 +868,11 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
},
|
},
|
||||||
inlineChunk: {
|
inlineChunkBase: {
|
||||||
flexDirection: 'row',
|
fontSize,
|
||||||
flexWrap: 'wrap',
|
lineHeight: Math.round(fontSize * 1.43),
|
||||||
alignItems: 'flex-end',
|
letterSpacing: 0.1,
|
||||||
width: '100%',
|
fontWeight: '400',
|
||||||
},
|
},
|
||||||
imagesChunk: {
|
imagesChunk: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -874,11 +906,7 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
|
|||||||
color: colors.chat.textPrimary,
|
color: colors.chat.textPrimary,
|
||||||
},
|
},
|
||||||
|
|
||||||
// @提及 - 与普通文本字号一致,仅颜色区分
|
// @提及 - 仅颜色区分,继承父元素字号
|
||||||
atText: {
|
|
||||||
fontSize,
|
|
||||||
lineHeight: Math.round(fontSize * 1.43),
|
|
||||||
},
|
|
||||||
atTextMe: {
|
atTextMe: {
|
||||||
color: '#1A5F9E',
|
color: '#1A5F9E',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -166,7 +166,8 @@ export interface LongPressMenuProps {
|
|||||||
onRecall: (messageId: string) => void;
|
onRecall: (messageId: string) => void;
|
||||||
onDelete: (messageId: string) => void;
|
onDelete: (messageId: string) => void;
|
||||||
onAddSticker?: (imageUrl: string) => void;
|
onAddSticker?: (imageUrl: string) => void;
|
||||||
onReport?: (message: GroupMessage) => void; // 举报消息回调
|
onReport?: (message: GroupMessage) => void;
|
||||||
|
memberMap?: Map<string, { nickname: string; user_id: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 聊天头部 Props
|
// 聊天头部 Props
|
||||||
|
|||||||
@@ -405,6 +405,17 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
|
|
||||||
// 群成员列表
|
// 群成员列表
|
||||||
const [groupMembers, setGroupMembers] = useState<GroupMemberResponse[]>([]);
|
const [groupMembers, setGroupMembers] = useState<GroupMemberResponse[]>([]);
|
||||||
|
|
||||||
|
const embeddedMemberMap = useMemo(() => {
|
||||||
|
const map = new Map<string, { nickname: string; user_id: string }>();
|
||||||
|
groupMembers.forEach(m => {
|
||||||
|
map.set(m.user_id, {
|
||||||
|
nickname: m.nickname || m.user?.nickname || '用户',
|
||||||
|
user_id: m.user_id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [groupMembers]);
|
||||||
|
|
||||||
// 格式化时间
|
// 格式化时间
|
||||||
const formatTime = useCallback((dateString: string): string => {
|
const formatTime = useCallback((dateString: string): string => {
|
||||||
@@ -1023,6 +1034,7 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
onRecall={handleRecall}
|
onRecall={handleRecall}
|
||||||
onDelete={handleDeleteMessage}
|
onDelete={handleDeleteMessage}
|
||||||
|
memberMap={embeddedMemberMap}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 图片查看器 */}
|
{/* 图片查看器 */}
|
||||||
|
|||||||
@@ -6,11 +6,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { CommonActions } from '@react-navigation/native';
|
|
||||||
import Constants from 'expo-constants';
|
import Constants from 'expo-constants';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
import { hrefVerificationGuide } from '../navigation/hrefs';
|
import { eventBus } from '@/core/events/EventBus';
|
||||||
|
|
||||||
// 生产地址 https://bbs.littlelan.cn
|
// 生产地址 https://bbs.littlelan.cn
|
||||||
const getBaseUrl = () => {
|
const getBaseUrl = () => {
|
||||||
@@ -74,37 +73,27 @@ interface JwtPayload {
|
|||||||
// API 客户端类
|
// API 客户端类
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
private navigation: any = null;
|
|
||||||
private expoRouter: any = null;
|
|
||||||
private verificationModalShown = false;
|
private verificationModalShown = false;
|
||||||
// 刷新锁:防止并发刷新
|
|
||||||
private refreshLock: Promise<boolean> | null = null;
|
private refreshLock: Promise<boolean> | null = null;
|
||||||
|
|
||||||
constructor(baseUrl: string) {
|
constructor(baseUrl: string) {
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
setExpoRouter(router: any) {
|
|
||||||
this.expoRouter = router;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleVerificationRequired() {
|
private handleVerificationRequired() {
|
||||||
if (this.verificationModalShown) return;
|
if (this.verificationModalShown) return;
|
||||||
this.verificationModalShown = true;
|
this.verificationModalShown = true;
|
||||||
if (this.expoRouter) {
|
eventBus.emit({ type: 'SHOW_VERIFICATION_MODAL' });
|
||||||
this.expoRouter.push(hrefVerificationGuide());
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.verificationModalShown = false;
|
this.verificationModalShown = false;
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置导航对象(用于处理401跳转)
|
private navigateToLogin() {
|
||||||
setNavigation(navigation: any) {
|
eventBus.emit({ type: 'NAVIGATE', payload: { path: '/(auth)/login' } });
|
||||||
this.navigation = navigation;
|
eventBus.emit({ type: 'AUTH_LOGOUT' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 JWT token 获取 payload
|
|
||||||
private parseJwt(token: string): JwtPayload | null {
|
private parseJwt(token: string): JwtPayload | null {
|
||||||
try {
|
try {
|
||||||
const parts = token.split('.');
|
const parts = token.split('.');
|
||||||
@@ -208,19 +197,10 @@ class ApiClient {
|
|||||||
if (this.isTokenExpiringSoon(token)) {
|
if (this.isTokenExpiringSoon(token)) {
|
||||||
const refreshed = await this.refreshToken();
|
const refreshed = await this.refreshToken();
|
||||||
if (!refreshed) {
|
if (!refreshed) {
|
||||||
// 刷新失败,清除 token 并跳转登录
|
|
||||||
await this.clearToken();
|
await this.clearToken();
|
||||||
if (this.navigation) {
|
this.navigateToLogin();
|
||||||
this.navigation.dispatch(
|
|
||||||
CommonActions.reset({
|
|
||||||
index: 0,
|
|
||||||
routes: [{ name: 'Login' }],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new ApiError(401, '登录已过期,请重新登录');
|
throw new ApiError(401, '登录已过期,请重新登录');
|
||||||
}
|
}
|
||||||
// 刷新成功,使用新 token
|
|
||||||
const newToken = await this.getToken();
|
const newToken = await this.getToken();
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
headers['Authorization'] = `Bearer ${newToken}`;
|
headers['Authorization'] = `Bearer ${newToken}`;
|
||||||
@@ -246,23 +226,13 @@ class ApiClient {
|
|||||||
|
|
||||||
// 处理 401 未授权
|
// 处理 401 未授权
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
// 尝试刷新 token
|
|
||||||
const refreshed = await this.refreshToken();
|
const refreshed = await this.refreshToken();
|
||||||
if (!refreshed) {
|
if (!refreshed) {
|
||||||
// 刷新失败,清除 token 并跳转登录
|
|
||||||
await this.clearToken();
|
await this.clearToken();
|
||||||
if (this.navigation) {
|
this.navigateToLogin();
|
||||||
this.navigation.dispatch(
|
|
||||||
CommonActions.reset({
|
|
||||||
index: 0,
|
|
||||||
routes: [{ name: 'Login' }],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new ApiError(401, '登录已过期,请重新登录');
|
throw new ApiError(401, '登录已过期,请重新登录');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刷新成功后重试请求
|
|
||||||
return this.request(method, path, params, body);
|
return this.request(method, path, params, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ class WebRTCManager {
|
|||||||
// ICE restart 相关状态
|
// ICE restart 相关状态
|
||||||
private reconnectAttempts = 0;
|
private reconnectAttempts = 0;
|
||||||
private readonly MAX_RECONNECT_ATTEMPTS = 3;
|
private readonly MAX_RECONNECT_ATTEMPTS = 3;
|
||||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private disconnectTimer: NodeJS.Timeout | null = null;
|
private disconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
async initialize(iceServers: ICEServer[] = []): Promise<void> {
|
async initialize(iceServers: ICEServer[] = []): Promise<void> {
|
||||||
if (this.peerConnection) {
|
if (this.peerConnection) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { AppState, AppStateStatus } from 'react-native';
|
import { AppState, AppStateStatus } from 'react-native';
|
||||||
|
|
||||||
import { api, WS_URL } from './api';
|
import { api, WS_URL } from './api';
|
||||||
import { hrefVerificationGuide } from '../navigation/hrefs';
|
import { eventBus } from '@/core/events/EventBus';
|
||||||
import { MessageCategory, SystemMessageType, SystemMessageExtraData, MessageSegment } from '../types/dto';
|
import { MessageCategory, SystemMessageType, SystemMessageExtraData, MessageSegment } from '../types/dto';
|
||||||
import { systemNotificationService } from './systemNotificationService';
|
import { systemNotificationService } from './systemNotificationService';
|
||||||
|
|
||||||
@@ -300,8 +300,8 @@ class WebSocketService {
|
|||||||
private reconnectAttempts = 0;
|
private reconnectAttempts = 0;
|
||||||
private maxReconnectAttempts = 20;
|
private maxReconnectAttempts = 20;
|
||||||
private reconnectDelay = 3000;
|
private reconnectDelay = 3000;
|
||||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private heartbeatTimer: NodeJS.Timeout | null = null;
|
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private heartbeatInterval = 30000; // 30秒心跳
|
private heartbeatInterval = 30000; // 30秒心跳
|
||||||
private messageHandlers: Map<WSMessageType, MessageHandler[]> = new Map();
|
private messageHandlers: Map<WSMessageType, MessageHandler[]> = new Map();
|
||||||
private connectionHandlers: ConnectionHandler[] = [];
|
private connectionHandlers: ConnectionHandler[] = [];
|
||||||
@@ -318,7 +318,7 @@ class WebSocketService {
|
|||||||
|
|
||||||
// 降级状态
|
// 降级状态
|
||||||
private isFallbackMode = false;
|
private isFallbackMode = false;
|
||||||
private fallbackCheckTimer: NodeJS.Timeout | null = null;
|
private fallbackCheckTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
private getWSUrl(token: string | null): string {
|
private getWSUrl(token: string | null): string {
|
||||||
return `${WS_URL}?token=${encodeURIComponent(token || '')}`;
|
return `${WS_URL}?token=${encodeURIComponent(token || '')}`;
|
||||||
@@ -1122,20 +1122,12 @@ class WebSocketService {
|
|||||||
private handleVerificationRequired(): void {
|
private handleVerificationRequired(): void {
|
||||||
if (this.verificationModalShown) return;
|
if (this.verificationModalShown) return;
|
||||||
this.verificationModalShown = true;
|
this.verificationModalShown = true;
|
||||||
if (this.routerRef) {
|
eventBus.emit({ type: 'SHOW_VERIFICATION_MODAL' });
|
||||||
this.routerRef.push(hrefVerificationGuide());
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.verificationModalShown = false;
|
this.verificationModalShown = false;
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
private routerRef: any = null;
|
|
||||||
|
|
||||||
setRouter(router: any): void {
|
|
||||||
this.routerRef = router;
|
|
||||||
}
|
|
||||||
|
|
||||||
private startHeartbeat(): void {
|
private startHeartbeat(): void {
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
this.heartbeatTimer = setInterval(() => {
|
this.heartbeatTimer = setInterval(() => {
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
/**
|
|
||||||
* BaseManager - 通用 Manager 基类
|
|
||||||
*
|
|
||||||
* 提取自 postManager/groupManager/userManager 的重复逻辑:
|
|
||||||
* - 请求去重 (dedupe)
|
|
||||||
* - 缓存条目管理 (CacheEntry)
|
|
||||||
* - 过期检查 (isExpired)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { CacheBus, CacheEvent } from './cacheBus';
|
|
||||||
|
|
||||||
/** 缓存条目结构 */
|
|
||||||
export interface CacheEntry<T> {
|
|
||||||
data: T;
|
|
||||||
timestamp: number;
|
|
||||||
ttl: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创建缓存条目 */
|
|
||||||
export function createCacheEntry<T>(data: T, ttl: number): CacheEntry<T> {
|
|
||||||
return { data, timestamp: Date.now(), ttl };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 检查缓存是否过期 */
|
|
||||||
export function isCacheExpired(entry: CacheEntry<any>): boolean {
|
|
||||||
return Date.now() - entry.timestamp > entry.ttl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用 Manager 基类
|
|
||||||
*
|
|
||||||
* 子类只需关注业务逻辑,无需重复实现缓存和去重机制
|
|
||||||
*/
|
|
||||||
export abstract class BaseManager<
|
|
||||||
TSnapshot,
|
|
||||||
TEvent extends CacheEvent = CacheEvent
|
|
||||||
> extends CacheBus<TSnapshot, TEvent> {
|
|
||||||
/** 待处理请求 Map,用于去重 */
|
|
||||||
protected pendingRequests = new Map<string, Promise<any>>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 请求去重:相同 key 的并发请求会被合并为一个
|
|
||||||
* @param key 唯一标识
|
|
||||||
* @param fetcher 实际请求函数
|
|
||||||
*/
|
|
||||||
protected async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
|
||||||
const pending = this.pendingRequests.get(key) as Promise<T> | undefined;
|
|
||||||
if (pending) return pending;
|
|
||||||
|
|
||||||
const request = fetcher().finally(() => {
|
|
||||||
this.pendingRequests.delete(key);
|
|
||||||
});
|
|
||||||
this.pendingRequests.set(key, request);
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清除所有待处理请求 */
|
|
||||||
protected clearPendingRequests(): void {
|
|
||||||
this.pendingRequests.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 带缓存的 Manager 基类
|
|
||||||
*
|
|
||||||
* 进一步封装内存缓存逻辑
|
|
||||||
*/
|
|
||||||
export abstract class CachedManager<
|
|
||||||
TSnapshot,
|
|
||||||
TEvent extends CacheEvent = CacheEvent
|
|
||||||
> extends BaseManager<TSnapshot, TEvent> {
|
|
||||||
/** 内存缓存 Map */
|
|
||||||
protected memoryCache = new Map<string, CacheEntry<any>>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取缓存
|
|
||||||
* @param key 缓存 key
|
|
||||||
* @returns 缓存数据,过期或不存在返回 null
|
|
||||||
*/
|
|
||||||
protected getFromCache<T>(key: string): T | null {
|
|
||||||
const entry = this.memoryCache.get(key) as CacheEntry<T> | undefined;
|
|
||||||
if (!entry) return null;
|
|
||||||
if (isCacheExpired(entry)) {
|
|
||||||
this.memoryCache.delete(key);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return entry.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置缓存
|
|
||||||
* @param key 缓存 key
|
|
||||||
* @param data 数据
|
|
||||||
* @param ttl 过期时间(毫秒)
|
|
||||||
*/
|
|
||||||
protected setCache<T>(key: string, data: T, ttl: number): void {
|
|
||||||
this.memoryCache.set(key, createCacheEntry(data, ttl));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查缓存是否存在且未过期
|
|
||||||
*/
|
|
||||||
protected hasValidCache(key: string): boolean {
|
|
||||||
const entry = this.memoryCache.get(key);
|
|
||||||
if (!entry) return false;
|
|
||||||
return !isCacheExpired(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查缓存是否存在但已过期
|
|
||||||
*/
|
|
||||||
protected hasExpiredCache(key: string): boolean {
|
|
||||||
const entry = this.memoryCache.get(key);
|
|
||||||
if (!entry) return false;
|
|
||||||
return isCacheExpired(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清除所有缓存 */
|
|
||||||
protected clearCache(): void {
|
|
||||||
this.memoryCache.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清除指定 key 的缓存 */
|
|
||||||
protected deleteCache(key: string): void {
|
|
||||||
this.memoryCache.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
export type CacheEventType =
|
|
||||||
| 'list_updated'
|
|
||||||
| 'detail_updated'
|
|
||||||
| 'cache_state_updated'
|
|
||||||
| 'error';
|
|
||||||
|
|
||||||
export interface CacheEvent<TPayload = any> {
|
|
||||||
type: CacheEventType;
|
|
||||||
payload: TPayload;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CacheSubscriber<TEvent extends CacheEvent = CacheEvent> = (event: TEvent) => void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一缓存总线基座:
|
|
||||||
* - 单一订阅入口
|
|
||||||
* - 统一事件通知
|
|
||||||
* - 新订阅立即收到快照
|
|
||||||
*/
|
|
||||||
export abstract class CacheBus<TSnapshot, TEvent extends CacheEvent = CacheEvent> {
|
|
||||||
private subscribers = new Set<CacheSubscriber<TEvent>>();
|
|
||||||
|
|
||||||
protected abstract getSnapshot(): TSnapshot;
|
|
||||||
|
|
||||||
protected notify(event: TEvent): void {
|
|
||||||
this.subscribers.forEach((subscriber) => {
|
|
||||||
try {
|
|
||||||
subscriber(event);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[CacheBus] subscriber error:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe(subscriber: CacheSubscriber<TEvent>): () => void {
|
|
||||||
this.subscribers.add(subscriber);
|
|
||||||
this.emitInitialSnapshot(subscriber);
|
|
||||||
return () => {
|
|
||||||
this.subscribers.delete(subscriber);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected emitInitialSnapshot(subscriber: CacheSubscriber<TEvent>): void {
|
|
||||||
subscriber({
|
|
||||||
type: 'cache_state_updated',
|
|
||||||
payload: this.getSnapshot(),
|
|
||||||
timestamp: Date.now(),
|
|
||||||
} as TEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import { groupService } from '../../services/groupService';
|
|||||||
import { groupCacheRepository, userCacheRepository } from '@/database';
|
import { groupCacheRepository, userCacheRepository } from '@/database';
|
||||||
import { useGroupManagerStore } from './groupStore';
|
import { useGroupManagerStore } from './groupStore';
|
||||||
import { createCacheEntry, isCacheExpired, DEFAULT_TTL } from '../utils/cache';
|
import { createCacheEntry, isCacheExpired, DEFAULT_TTL } from '../utils/cache';
|
||||||
|
import { createDedupe } from '../utils/requestDedupe';
|
||||||
import {
|
import {
|
||||||
IGroupListPagedSource,
|
IGroupListPagedSource,
|
||||||
IGroupMemberListPagedSource,
|
IGroupMemberListPagedSource,
|
||||||
@@ -18,6 +19,8 @@ import {
|
|||||||
GROUP_MEMBER_LIST_PAGE_SIZE,
|
GROUP_MEMBER_LIST_PAGE_SIZE,
|
||||||
} from '../groupListSources';
|
} from '../groupListSources';
|
||||||
|
|
||||||
|
const dedupe = createDedupe(() => useGroupManagerStore);
|
||||||
|
|
||||||
function buildMembersKey(groupId: string, page: number, pageSize: number): string {
|
function buildMembersKey(groupId: string, page: number, pageSize: number): string {
|
||||||
return `members:${groupId}:${page}:${pageSize}`;
|
return `members:${groupId}:${page}:${pageSize}`;
|
||||||
}
|
}
|
||||||
@@ -74,7 +77,7 @@ class GroupManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.dedupe(`groups:list:${page}:${pageSize}`, async () => {
|
return dedupe(`groups:list:${page}:${pageSize}`, async () => {
|
||||||
const store = useGroupManagerStore.getState();
|
const store = useGroupManagerStore.getState();
|
||||||
const response = await groupService.getGroups(page, pageSize);
|
const response = await groupService.getGroups(page, pageSize);
|
||||||
const groups = response.list || [];
|
const groups = response.list || [];
|
||||||
@@ -87,7 +90,7 @@ class GroupManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private refreshGroupsInBackground(page = 1, pageSize = GROUP_LIST_PAGE_SIZE): void {
|
private refreshGroupsInBackground(page = 1, pageSize = GROUP_LIST_PAGE_SIZE): void {
|
||||||
this.dedupe(`groups:bg:list:${page}:${pageSize}`, async () => {
|
dedupe(`groups:bg:list:${page}:${pageSize}`, async () => {
|
||||||
const store = useGroupManagerStore.getState();
|
const store = useGroupManagerStore.getState();
|
||||||
const response = await groupService.getGroups(page, pageSize);
|
const response = await groupService.getGroups(page, pageSize);
|
||||||
const groups = response.list || [];
|
const groups = response.list || [];
|
||||||
@@ -130,7 +133,7 @@ class GroupManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.dedupe(`groups:detail:${groupId}`, async () => {
|
return dedupe(`groups:detail:${groupId}`, async () => {
|
||||||
const store = useGroupManagerStore.getState();
|
const store = useGroupManagerStore.getState();
|
||||||
const group = await groupService.getGroup(groupId);
|
const group = await groupService.getGroup(groupId);
|
||||||
store.setGroup(groupId, group);
|
store.setGroup(groupId, group);
|
||||||
@@ -140,7 +143,7 @@ class GroupManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private refreshGroupInBackground(groupId: string): void {
|
private refreshGroupInBackground(groupId: string): void {
|
||||||
this.dedupe(`groups:detail:bg:${groupId}`, async () => {
|
dedupe(`groups:detail:bg:${groupId}`, async () => {
|
||||||
const store = useGroupManagerStore.getState();
|
const store = useGroupManagerStore.getState();
|
||||||
const group = await groupService.getGroup(groupId);
|
const group = await groupService.getGroup(groupId);
|
||||||
store.setGroup(groupId, group);
|
store.setGroup(groupId, group);
|
||||||
@@ -179,7 +182,7 @@ class GroupManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.dedupe(`groups:members:${key}`, async () => {
|
return dedupe(`groups:members:${key}`, async () => {
|
||||||
const store = useGroupManagerStore.getState();
|
const store = useGroupManagerStore.getState();
|
||||||
const response = await groupService.getMembers(groupId, page, pageSize);
|
const response = await groupService.getMembers(groupId, page, pageSize);
|
||||||
const members = response.list || [];
|
const members = response.list || [];
|
||||||
@@ -202,7 +205,7 @@ class GroupManager {
|
|||||||
pageSize = GROUP_MEMBER_LIST_PAGE_SIZE
|
pageSize = GROUP_MEMBER_LIST_PAGE_SIZE
|
||||||
): void {
|
): void {
|
||||||
const key = buildMembersKey(groupId, page, pageSize);
|
const key = buildMembersKey(groupId, page, pageSize);
|
||||||
this.dedupe(`groups:members:bg:${key}`, async () => {
|
dedupe(`groups:members:bg:${key}`, async () => {
|
||||||
const store = useGroupManagerStore.getState();
|
const store = useGroupManagerStore.getState();
|
||||||
const response = await groupService.getMembers(groupId, page, pageSize);
|
const response = await groupService.getMembers(groupId, page, pageSize);
|
||||||
const members = response.list || [];
|
const members = response.list || [];
|
||||||
@@ -240,18 +243,6 @@ class GroupManager {
|
|||||||
clear(): void {
|
clear(): void {
|
||||||
useGroupManagerStore.getState().reset();
|
useGroupManagerStore.getState().reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
|
||||||
const store = useGroupManagerStore.getState();
|
|
||||||
const pending = store.getPendingRequest<T>(key);
|
|
||||||
if (pending) return pending;
|
|
||||||
|
|
||||||
const request = fetcher().finally(() => {
|
|
||||||
useGroupManagerStore.getState().deletePendingRequest(key);
|
|
||||||
});
|
|
||||||
store.setPendingRequest(key, request);
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const groupManager = new GroupManager();
|
export const groupManager = new GroupManager();
|
||||||
|
|||||||
@@ -1,38 +1,29 @@
|
|||||||
/**
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
* 群组相关 React Hooks
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useGroupManagerStore } from './groupStore';
|
import { useGroupManagerStore } from './groupStore';
|
||||||
import { groupManager } from './GroupManager';
|
import { groupManager } from './GroupManager';
|
||||||
import type { GroupResponse, GroupMemberResponse } from '../../types/dto';
|
import type { GroupResponse, GroupMemberResponse } from '../../types/dto';
|
||||||
import { GROUP_LIST_PAGE_SIZE, GROUP_MEMBER_LIST_PAGE_SIZE } from '../groupListSources';
|
import { GROUP_LIST_PAGE_SIZE, GROUP_MEMBER_LIST_PAGE_SIZE } from '../groupListSources';
|
||||||
|
|
||||||
// ==================== useGroups ====================
|
|
||||||
|
|
||||||
export interface UseGroupsResult {
|
export interface UseGroupsResult {
|
||||||
groups: GroupResponse[];
|
groups: GroupResponse[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取群组列表 hook
|
|
||||||
*/
|
|
||||||
export function useGroups(): UseGroupsResult {
|
export function useGroups(): UseGroupsResult {
|
||||||
const groups = useGroupManagerStore(state => state.groupsListEntry?.data ?? []);
|
const groups = useGroupManagerStore(state => state.groupsListEntry?.data ?? []);
|
||||||
const isLoading = useGroupManagerStore(state => state.isLoadingGroups);
|
const isLoading = useGroupManagerStore(state => state.isLoadingGroups);
|
||||||
const [hasFetched, setHasFetched] = useState(false);
|
const hasFetchedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasFetched) {
|
if (!hasFetchedRef.current) {
|
||||||
setHasFetched(true);
|
hasFetchedRef.current = true;
|
||||||
useGroupManagerStore.getState().setLoadingGroups(true);
|
useGroupManagerStore.getState().setLoadingGroups(true);
|
||||||
groupManager.getGroups(1, GROUP_LIST_PAGE_SIZE).finally(() => {
|
groupManager.getGroups(1, GROUP_LIST_PAGE_SIZE).finally(() => {
|
||||||
useGroupManagerStore.getState().setLoadingGroups(false);
|
useGroupManagerStore.getState().setLoadingGroups(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [hasFetched]);
|
}, []);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
useGroupManagerStore.getState().setLoadingGroups(true);
|
useGroupManagerStore.getState().setLoadingGroups(true);
|
||||||
@@ -46,17 +37,12 @@ export function useGroups(): UseGroupsResult {
|
|||||||
return { groups, isLoading, refresh };
|
return { groups, isLoading, refresh };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== useGroup ====================
|
|
||||||
|
|
||||||
export interface UseGroupResult {
|
export interface UseGroupResult {
|
||||||
group: GroupResponse | null;
|
group: GroupResponse | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
refresh: () => Promise<GroupResponse | null>;
|
refresh: () => Promise<GroupResponse | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取单个群组 hook
|
|
||||||
*/
|
|
||||||
export function useGroup(groupId: string | undefined | null): UseGroupResult {
|
export function useGroup(groupId: string | undefined | null): UseGroupResult {
|
||||||
const group = useGroupManagerStore(state =>
|
const group = useGroupManagerStore(state =>
|
||||||
groupId ? (state.groupsMap.get(groupId)?.data ?? null) : null
|
groupId ? (state.groupsMap.get(groupId)?.data ?? null) : null
|
||||||
@@ -64,18 +50,18 @@ export function useGroup(groupId: string | undefined | null): UseGroupResult {
|
|||||||
const isLoading = useGroupManagerStore(state =>
|
const isLoading = useGroupManagerStore(state =>
|
||||||
groupId ? state.loadingGroupIds.has(groupId) : false
|
groupId ? state.loadingGroupIds.has(groupId) : false
|
||||||
);
|
);
|
||||||
const [hasFetched, setHasFetched] = useState(false);
|
const lastFetchedRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!groupId) return;
|
if (!groupId) return;
|
||||||
if (!hasFetched) {
|
if (lastFetchedRef.current !== groupId) {
|
||||||
setHasFetched(true);
|
lastFetchedRef.current = groupId;
|
||||||
useGroupManagerStore.getState().setLoadingGroup(groupId, true);
|
useGroupManagerStore.getState().setLoadingGroup(groupId, true);
|
||||||
groupManager.getGroup(groupId).finally(() => {
|
groupManager.getGroup(groupId).finally(() => {
|
||||||
useGroupManagerStore.getState().setLoadingGroup(groupId, false);
|
useGroupManagerStore.getState().setLoadingGroup(groupId, false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [groupId, hasFetched]);
|
}, [groupId]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!groupId) return null;
|
if (!groupId) return null;
|
||||||
@@ -90,17 +76,12 @@ export function useGroup(groupId: string | undefined | null): UseGroupResult {
|
|||||||
return { group, isLoading, refresh };
|
return { group, isLoading, refresh };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== useGroupMembers ====================
|
|
||||||
|
|
||||||
export interface UseGroupMembersResult {
|
export interface UseGroupMembersResult {
|
||||||
members: GroupMemberResponse[];
|
members: GroupMemberResponse[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取群组成员 hook
|
|
||||||
*/
|
|
||||||
export function useGroupMembers(
|
export function useGroupMembers(
|
||||||
groupId: string | undefined | null,
|
groupId: string | undefined | null,
|
||||||
page = 1,
|
page = 1,
|
||||||
@@ -113,18 +94,19 @@ export function useGroupMembers(
|
|||||||
const isLoading = useGroupManagerStore(state =>
|
const isLoading = useGroupManagerStore(state =>
|
||||||
membersKey ? state.loadingMemberKeys.has(membersKey) : false
|
membersKey ? state.loadingMemberKeys.has(membersKey) : false
|
||||||
);
|
);
|
||||||
const [hasFetched, setHasFetched] = useState(false);
|
const lastFetchedRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!groupId) return;
|
if (!groupId) return;
|
||||||
if (!hasFetched) {
|
const key = `${groupId}:${page}:${pageSize}`;
|
||||||
setHasFetched(true);
|
if (lastFetchedRef.current !== key) {
|
||||||
|
lastFetchedRef.current = key;
|
||||||
useGroupManagerStore.getState().setLoadingMembers(groupId, true, page, pageSize);
|
useGroupManagerStore.getState().setLoadingMembers(groupId, true, page, pageSize);
|
||||||
groupManager.getMembers(groupId, page, pageSize).finally(() => {
|
groupManager.getMembers(groupId, page, pageSize).finally(() => {
|
||||||
useGroupManagerStore.getState().setLoadingMembers(groupId, false, page, pageSize);
|
useGroupManagerStore.getState().setLoadingMembers(groupId, false, page, pageSize);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [groupId, page, pageSize, hasFetched]);
|
}, [groupId, page, pageSize]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!groupId) return;
|
if (!groupId) return;
|
||||||
@@ -137,4 +119,4 @@ export function useGroupMembers(
|
|||||||
}, [groupId, page, pageSize]);
|
}, [groupId, page, pageSize]);
|
||||||
|
|
||||||
return { members, isLoading, refresh };
|
return { members, isLoading, refresh };
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
import type { ConversationResponse, MessageResponse } from '../../../types/dto';
|
import type { ConversationResponse, MessageResponse } from '../../../types/dto';
|
||||||
import { messageService } from '../../../services/messageService';
|
import { messageService } from '../../../services/messageService';
|
||||||
import { messageRepository, conversationRepository } from '@/database';
|
import { messageRepository, conversationRepository } from '@/database';
|
||||||
|
import { MessageMapper } from '@/data/mappers';
|
||||||
import {
|
import {
|
||||||
type IConversationListPagedSource,
|
type IConversationListPagedSource,
|
||||||
SqliteConversationListPagedSource,
|
SqliteConversationListPagedSource,
|
||||||
@@ -242,19 +243,9 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
const mergedSnapshot = mergeMessagesById(existingMessages, snapshotMessages);
|
const mergedSnapshot = mergeMessagesById(existingMessages, snapshotMessages);
|
||||||
store.setMessages(conversationId, mergedSnapshot);
|
store.setMessages(conversationId, mergedSnapshot);
|
||||||
|
|
||||||
// 持久化到本地
|
messageRepository.saveMessagesBatch(
|
||||||
messageRepository.saveMessagesBatch(snapshotMessages.map((m: any) => ({
|
MessageMapper.toCachedMessages(snapshotMessages, conversationId)
|
||||||
id: m.id,
|
).catch(error => {
|
||||||
conversationId: m.conversation_id || conversationId,
|
|
||||||
senderId: m.sender_id,
|
|
||||||
content: m.content,
|
|
||||||
type: m.type || 'text',
|
|
||||||
isRead: m.is_read || false,
|
|
||||||
createdAt: m.created_at,
|
|
||||||
seq: m.seq,
|
|
||||||
status: m.status || 'normal',
|
|
||||||
segments: m.segments,
|
|
||||||
}))).catch(error => {
|
|
||||||
console.error('[MessageSyncService] 保存快照消息到本地失败:', error);
|
console.error('[MessageSyncService] 保存快照消息到本地失败:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -270,18 +261,9 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
const mergedMessages = mergeMessagesById(existingMessages, newMessages);
|
const mergedMessages = mergeMessagesById(existingMessages, newMessages);
|
||||||
store.setMessages(conversationId, mergedMessages);
|
store.setMessages(conversationId, mergedMessages);
|
||||||
|
|
||||||
messageRepository.saveMessagesBatch(newMessages.map((m: any) => ({
|
messageRepository.saveMessagesBatch(
|
||||||
id: m.id,
|
MessageMapper.toCachedMessages(newMessages, conversationId)
|
||||||
conversationId: m.conversation_id || conversationId,
|
).catch(error => {
|
||||||
senderId: m.sender_id,
|
|
||||||
content: m.content,
|
|
||||||
type: m.type || 'text',
|
|
||||||
isRead: m.is_read || false,
|
|
||||||
createdAt: m.created_at,
|
|
||||||
seq: m.seq,
|
|
||||||
status: m.status || 'normal',
|
|
||||||
segments: m.segments,
|
|
||||||
}))).catch(error => {
|
|
||||||
console.error('[MessageSyncService] 保存增量消息到本地失败:', error);
|
console.error('[MessageSyncService] 保存增量消息到本地失败:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -300,18 +282,9 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
|
|
||||||
store.setMessages(conversationId, mergedMessages);
|
store.setMessages(conversationId, mergedMessages);
|
||||||
|
|
||||||
messageRepository.saveMessagesBatch(newMessages.map((m: any) => ({
|
messageRepository.saveMessagesBatch(
|
||||||
id: m.id,
|
MessageMapper.toCachedMessages(newMessages, conversationId)
|
||||||
conversationId: m.conversation_id || conversationId,
|
).catch(error => {
|
||||||
senderId: m.sender_id,
|
|
||||||
content: m.content,
|
|
||||||
type: m.type || 'text',
|
|
||||||
isRead: m.is_read || false,
|
|
||||||
createdAt: m.created_at,
|
|
||||||
seq: m.seq,
|
|
||||||
status: m.status || 'normal',
|
|
||||||
segments: m.segments,
|
|
||||||
}))).catch(error => {
|
|
||||||
console.error('[MessageSyncService] 保存消息到本地失败:', error);
|
console.error('[MessageSyncService] 保存消息到本地失败:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -368,18 +341,9 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
if (response?.messages && response.messages.length > 0) {
|
if (response?.messages && response.messages.length > 0) {
|
||||||
const serverMessages = response.messages;
|
const serverMessages = response.messages;
|
||||||
|
|
||||||
await messageRepository.saveMessagesBatch(serverMessages.map((m: any) => ({
|
await messageRepository.saveMessagesBatch(
|
||||||
id: m.id,
|
MessageMapper.toCachedMessages(serverMessages, conversationId)
|
||||||
conversationId: m.conversation_id || conversationId,
|
);
|
||||||
senderId: m.sender_id,
|
|
||||||
content: m.content,
|
|
||||||
type: m.type || 'text',
|
|
||||||
isRead: m.is_read || false,
|
|
||||||
createdAt: m.created_at,
|
|
||||||
seq: m.seq,
|
|
||||||
status: m.status || 'normal',
|
|
||||||
segments: m.segments,
|
|
||||||
})));
|
|
||||||
|
|
||||||
const existingMessages = store.getMessages(conversationId);
|
const existingMessages = store.getMessages(conversationId);
|
||||||
const mergedMessages = this.mergeOlderMessages(existingMessages, serverMessages);
|
const mergedMessages = this.mergeOlderMessages(existingMessages, serverMessages);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type {
|
|||||||
WSGroupNoticeMessage,
|
WSGroupNoticeMessage,
|
||||||
} from '../../../services/wsService';
|
} from '../../../services/wsService';
|
||||||
import { wsService, GroupNoticeType } from '../../../services/wsService';
|
import { wsService, GroupNoticeType } from '../../../services/wsService';
|
||||||
import { vibrateOnMessage } from '../../../services/messageVibrationService';
|
import { eventBus } from '@/core/events/EventBus';
|
||||||
import { messageRepository } from '@/database';
|
import { messageRepository } from '@/database';
|
||||||
import type { MessageResponse, ConversationResponse } from '../../../types/dto';
|
import type { MessageResponse, ConversationResponse } from '../../../types/dto';
|
||||||
import type {
|
import type {
|
||||||
@@ -75,29 +75,24 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
this.sseUnsubscribe();
|
this.sseUnsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = useMessageStore.getState();
|
|
||||||
|
|
||||||
// 监听私聊消息
|
|
||||||
wsService.on('chat', (message: WSChatMessage) => {
|
wsService.on('chat', (message: WSChatMessage) => {
|
||||||
if (!store.getState().isInitialized || this.isBootstrapping) {
|
if (!useMessageStore.getState().isInitialized || this.isBootstrapping) {
|
||||||
this.bufferedChatMessages.push(message);
|
this.bufferedChatMessages.push(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.handleNewMessage(message);
|
this.handleNewMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊消息
|
|
||||||
wsService.on('group_message', (message: WSGroupChatMessage) => {
|
wsService.on('group_message', (message: WSGroupChatMessage) => {
|
||||||
if (!store.getState().isInitialized || this.isBootstrapping) {
|
if (!useMessageStore.getState().isInitialized || this.isBootstrapping) {
|
||||||
this.bufferedChatMessages.push(message);
|
this.bufferedChatMessages.push(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.handleNewMessage(message);
|
this.handleNewMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听私聊已读回执
|
|
||||||
wsService.on('read', (message: WSReadMessage) => {
|
wsService.on('read', (message: WSReadMessage) => {
|
||||||
if (!store.getState().isInitialized || this.isBootstrapping) {
|
if (!useMessageStore.getState().isInitialized || this.isBootstrapping) {
|
||||||
this.bufferedReadReceipts.push(message);
|
this.bufferedReadReceipts.push(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -106,7 +101,7 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
|
|
||||||
// 监听群聊已读回执
|
// 监听群聊已读回执
|
||||||
wsService.on('group_read', (message: WSGroupReadMessage) => {
|
wsService.on('group_read', (message: WSGroupReadMessage) => {
|
||||||
if (!store.getState().isInitialized || this.isBootstrapping) {
|
if (!useMessageStore.getState().isInitialized || this.isBootstrapping) {
|
||||||
this.bufferedReadReceipts.push(message);
|
this.bufferedReadReceipts.push(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -135,13 +130,13 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
|
|
||||||
// 监听连接状态
|
// 监听连接状态
|
||||||
wsService.onConnect(() => {
|
wsService.onConnect(() => {
|
||||||
store.setSSEConnected(true);
|
useMessageStore.getState().setSSEConnected(true);
|
||||||
|
|
||||||
// 冷启动/重连兜底
|
// 冷启动/重连兜底
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - this.lastReconnectSyncAt > 1500) {
|
if (now - this.lastReconnectSyncAt > 1500) {
|
||||||
this.lastReconnectSyncAt = now;
|
this.lastReconnectSyncAt = now;
|
||||||
const activeConversation = store.getActiveConversation();
|
const activeConversation = useMessageStore.getState().getActiveConversation();
|
||||||
|
|
||||||
if (!activeConversation) {
|
if (!activeConversation) {
|
||||||
this.fetchConversationsCallback(true, 'sse-reconnect').catch(error => {
|
this.fetchConversationsCallback(true, 'sse-reconnect').catch(error => {
|
||||||
@@ -162,7 +157,7 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
wsService.onDisconnect(() => {
|
wsService.onDisconnect(() => {
|
||||||
store.setSSEConnected(false);
|
useMessageStore.getState().setSSEConnected(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,8 +300,8 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
|
|
||||||
if (shouldIncrementUnread) {
|
if (shouldIncrementUnread) {
|
||||||
if (!suppressVibration) {
|
if (!suppressVibration) {
|
||||||
const vibrationType = message.type === 'group_message' ? 'group_message' : 'chat';
|
const vibrationType = message.type === 'group_message' ? 'group_message' : 'message';
|
||||||
vibrateOnMessage(vibrationType).catch(() => {});
|
eventBus.emit({ type: 'VIBRATE', payload: { type: vibrationType } });
|
||||||
}
|
}
|
||||||
this.incrementUnreadCount(normalizedConversationId);
|
this.incrementUnreadCount(normalizedConversationId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export interface ReadStateRecord {
|
|||||||
/** 已上报的已读序号(用于去重) */
|
/** 已上报的已读序号(用于去重) */
|
||||||
lastReadSeq: number;
|
lastReadSeq: number;
|
||||||
/** 清除保护的定时器ID */
|
/** 清除保护的定时器ID */
|
||||||
clearTimer?: NodeJS.Timeout;
|
clearTimer?: ReturnType<typeof setTimeout>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 依赖注入接口 ====================
|
// ==================== 依赖注入接口 ====================
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* PostManager - 撣硋<EFBFBD>蝞∠<EFBFBD><EFBFBD>詨<EFBFBD>璅∪<EFBFBD>嚗<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>嚗?
|
* PostManager - 帖子管理核心模块(重构版<EFBFBD>?
|
||||||
*
|
*
|
||||||
* 雿輻鍂 Zustand store 餈𥡝<EFBFBD><EFBFBD>嗆<EFBFBD><EFBFBD>恣<EFBFBD>?
|
* 使用 Zustand store 进行状态管<EFBFBD>?
|
||||||
* 靽脲<EFBFBD>銝擧唂 API <20><><EFBFBD><EFBFBD>𤾸<EFBFBD>摰?
|
* 保持与旧 API 的向后兼<E5908E>?
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Post } from '../../types';
|
import type { Post } from '../../types';
|
||||||
import { postService } from '../../services/postService';
|
import { postService } from '../../services/postService';
|
||||||
import { usePostManagerStore } from './postStore';
|
import { usePostManagerStore } from './postStore';
|
||||||
import { createCacheEntry, isCacheExpired, DEFAULT_TTL } from '../utils/cache';
|
import { createCacheEntry, isCacheExpired, DEFAULT_TTL } from '../utils/cache';
|
||||||
|
import { createDedupe } from '../utils/requestDedupe';
|
||||||
import {
|
import {
|
||||||
IPostListPagedSource,
|
IPostListPagedSource,
|
||||||
PostListTab,
|
PostListTab,
|
||||||
@@ -17,13 +18,13 @@ import {
|
|||||||
POST_LIST_PAGE_SIZE,
|
POST_LIST_PAGE_SIZE,
|
||||||
} from '../postListSources';
|
} from '../postListSources';
|
||||||
|
|
||||||
// ==================== 颲<>𨭌<EFBFBD>賣㺭 ====================
|
|
||||||
|
|
||||||
function buildListKey(type: string, page: number, pageSize: number): string {
|
function buildListKey(type: string, page: number, pageSize: number): string {
|
||||||
return `list:${type}:${page}:${pageSize}`;
|
return `list:${type}:${page}:${pageSize}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== PostManager 蝐?====================
|
const dedupe = createDedupe(() => usePostManagerStore);
|
||||||
|
|
||||||
|
// ==================== PostManager <20>?====================
|
||||||
|
|
||||||
class PostManager {
|
class PostManager {
|
||||||
// ==================== 列表相关 ====================
|
// ==================== 列表相关 ====================
|
||||||
@@ -41,19 +42,19 @@ class PostManager {
|
|||||||
const store = usePostManagerStore.getState();
|
const store = usePostManagerStore.getState();
|
||||||
const entry = store.listsMap.get(key);
|
const entry = store.listsMap.get(key);
|
||||||
|
|
||||||
// 璉<EFBFBD><EFBFBD>交<EFBFBD><EFBFBD><EFBFBD><EFBFBD>摮?
|
// 检查有效缓<EFBFBD>?
|
||||||
if (!forceRefresh && entry && !isCacheExpired(entry)) {
|
if (!forceRefresh && entry && !isCacheExpired(entry)) {
|
||||||
return entry.data;
|
return entry.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 餈<EFBFBD><EFBFBD>蝻枏<EFBFBD>嚗𡁜<EFBFBD><EFBFBD>啣<EFBFBD><EFBFBD>堆<EFBFBD>蝡见朖餈𥪜<EFBFBD><EFBFBD>扳㺭<EFBFBD>?
|
// 过期缓存:后台刷新,立即返回旧数<EFBFBD>?
|
||||||
if (!forceRefresh && entry && isCacheExpired(entry)) {
|
if (!forceRefresh && entry && isCacheExpired(entry)) {
|
||||||
this.refreshPostsInBackground(key, type, page, pageSize);
|
this.refreshPostsInBackground(key, type, page, pageSize);
|
||||||
return entry.data;
|
return entry.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无缓存:发起请求
|
// 无缓存:发起请求
|
||||||
return this.dedupe(`posts:${key}`, async () => {
|
return dedupe(`posts:${key}`, async () => {
|
||||||
const store = usePostManagerStore.getState();
|
const store = usePostManagerStore.getState();
|
||||||
const response = await postService.getPosts(page, pageSize, type);
|
const response = await postService.getPosts(page, pageSize, type);
|
||||||
const posts = response.list || [];
|
const posts = response.list || [];
|
||||||
@@ -68,7 +69,7 @@ class PostManager {
|
|||||||
page: number,
|
page: number,
|
||||||
pageSize: number
|
pageSize: number
|
||||||
): void {
|
): void {
|
||||||
this.dedupe(`posts:bg:${key}`, async () => {
|
dedupe(`posts:bg:${key}`, async () => {
|
||||||
const store = usePostManagerStore.getState();
|
const store = usePostManagerStore.getState();
|
||||||
const response = await postService.getPosts(page, pageSize, type);
|
const response = await postService.getPosts(page, pageSize, type);
|
||||||
const posts = response.list || [];
|
const posts = response.list || [];
|
||||||
@@ -80,7 +81,7 @@ class PostManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <EFBFBD>𥕦遣撣硋<EFBFBD><EFBFBD>𡑒”<EFBFBD>唳旿皞琜<EFBFBD><EFBFBD>其<EFBFBD> Sources 璅∪<EFBFBD>嚗?
|
* 创建帖子列表数据源(用于 Sources 模式<EFBFBD>?
|
||||||
*/
|
*/
|
||||||
createPostListSource(
|
createPostListSource(
|
||||||
options: { tab?: PostListTab; channelId?: string; pageSize?: number } = {},
|
options: { tab?: PostListTab; channelId?: string; pageSize?: number } = {},
|
||||||
@@ -103,14 +104,14 @@ class PostManager {
|
|||||||
return entry.data;
|
return entry.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 餈<EFBFBD><EFBFBD>蝻枏<EFBFBD>嚗𡁜<EFBFBD><EFBFBD>啣<EFBFBD><EFBFBD>?
|
// 过期缓存:后台刷<EFBFBD>?
|
||||||
if (!forceRefresh && entry && isCacheExpired(entry) && entry.data) {
|
if (!forceRefresh && entry && isCacheExpired(entry) && entry.data) {
|
||||||
this.refreshPostDetailInBackground(postId);
|
this.refreshPostDetailInBackground(postId);
|
||||||
return entry.data;
|
return entry.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无缓存:发起请求
|
// 无缓存:发起请求
|
||||||
return this.dedupe(`posts:detail:${postId}`, async () => {
|
return dedupe(`posts:detail:${postId}`, async () => {
|
||||||
const store = usePostManagerStore.getState();
|
const store = usePostManagerStore.getState();
|
||||||
const post = await postService.getPost(postId);
|
const post = await postService.getPost(postId);
|
||||||
store.setPostDetail(postId, post);
|
store.setPostDetail(postId, post);
|
||||||
@@ -119,7 +120,7 @@ class PostManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private refreshPostDetailInBackground(postId: string): void {
|
private refreshPostDetailInBackground(postId: string): void {
|
||||||
this.dedupe(`posts:detail:bg:${postId}`, async () => {
|
dedupe(`posts:detail:bg:${postId}`, async () => {
|
||||||
const store = usePostManagerStore.getState();
|
const store = usePostManagerStore.getState();
|
||||||
const post = await postService.getPost(postId);
|
const post = await postService.getPost(postId);
|
||||||
store.setPostDetail(postId, post);
|
store.setPostDetail(postId, post);
|
||||||
@@ -132,7 +133,7 @@ class PostManager {
|
|||||||
// ==================== 缓存管理 ====================
|
// ==================== 缓存管理 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 雿輻<EFBFBD>摮睃仃<EFBFBD>?
|
* 使缓存失<EFBFBD>?
|
||||||
*/
|
*/
|
||||||
invalidate(postId?: string): void {
|
invalidate(postId?: string): void {
|
||||||
if (postId) {
|
if (postId) {
|
||||||
@@ -143,32 +144,15 @@ class PostManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 皜<EFBFBD>膄<EFBFBD><EFBFBD><EFBFBD>㗇㺭<EFBFBD>?
|
* 清除所有数<EFBFBD>?
|
||||||
*/
|
*/
|
||||||
clear(): void {
|
clear(): void {
|
||||||
usePostManagerStore.getState().reset();
|
usePostManagerStore.getState().reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 霂瑟<E99C82><E7919F>駁<EFBFBD> ====================
|
|
||||||
|
|
||||||
private async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
|
||||||
const store = usePostManagerStore.getState();
|
|
||||||
const pending = store.getPendingRequest<T>(key);
|
|
||||||
if (pending) return pending;
|
|
||||||
|
|
||||||
const request = fetcher().finally(() => {
|
|
||||||
usePostManagerStore.getState().deletePendingRequest(key);
|
|
||||||
});
|
|
||||||
store.setPendingRequest(key, request);
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== <20>蓥<EFBFBD>撖澆枂 ====================
|
|
||||||
|
|
||||||
export const postManager = new PostManager();
|
export const postManager = new PostManager();
|
||||||
|
|
||||||
// 撖澆枂蝐颱誑<E9A2B1>舀<EFBFBD>蝐餃<E89D90>璉<EFBFBD><E79289>亙<EFBFBD>瘚贝<E7989A>
|
|
||||||
export { PostManager };
|
export { PostManager };
|
||||||
|
|
||||||
export default postManager;
|
export default postManager;
|
||||||
|
|||||||
@@ -1,25 +1,16 @@
|
|||||||
/**
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
* 帖子相关 React Hooks
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { usePostManagerStore } from './postStore';
|
import { usePostManagerStore } from './postStore';
|
||||||
import { postManager } from './PostManager';
|
import { postManager } from './PostManager';
|
||||||
import type { Post } from '../../types';
|
import type { Post } from '../../types';
|
||||||
import { POST_LIST_PAGE_SIZE } from '../postListSources';
|
import { POST_LIST_PAGE_SIZE } from '../postListSources';
|
||||||
import type { PostListTab } from '../postListSources';
|
import type { PostListTab } from '../postListSources';
|
||||||
|
|
||||||
// ==================== usePostList ====================
|
|
||||||
|
|
||||||
export interface UsePostListResult {
|
export interface UsePostListResult {
|
||||||
posts: Post[];
|
posts: Post[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取帖子列表 hook
|
|
||||||
*/
|
|
||||||
export function usePostList(
|
export function usePostList(
|
||||||
type: PostListTab = 'hot',
|
type: PostListTab = 'hot',
|
||||||
page = 1,
|
page = 1,
|
||||||
@@ -33,17 +24,18 @@ export function usePostList(
|
|||||||
const key = `list:${type}:${page}:${pageSize}`;
|
const key = `list:${type}:${page}:${pageSize}`;
|
||||||
return state.loadingListKeys.has(key);
|
return state.loadingListKeys.has(key);
|
||||||
});
|
});
|
||||||
const [hasFetched, setHasFetched] = useState(false);
|
const lastFetchedRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasFetched) {
|
const key = `${type}:${page}:${pageSize}`;
|
||||||
setHasFetched(true);
|
if (lastFetchedRef.current !== key) {
|
||||||
|
lastFetchedRef.current = key;
|
||||||
usePostManagerStore.getState().setLoadingList(type, page, pageSize, true);
|
usePostManagerStore.getState().setLoadingList(type, page, pageSize, true);
|
||||||
postManager.getPosts(type, page, pageSize).finally(() => {
|
postManager.getPosts(type, page, pageSize).finally(() => {
|
||||||
usePostManagerStore.getState().setLoadingList(type, page, pageSize, false);
|
usePostManagerStore.getState().setLoadingList(type, page, pageSize, false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [type, page, pageSize, hasFetched]);
|
}, [type, page, pageSize]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
usePostManagerStore.getState().setLoadingList(type, page, pageSize, true);
|
usePostManagerStore.getState().setLoadingList(type, page, pageSize, true);
|
||||||
@@ -57,17 +49,12 @@ export function usePostList(
|
|||||||
return { posts, isLoading, refresh };
|
return { posts, isLoading, refresh };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== usePostDetail ====================
|
|
||||||
|
|
||||||
export interface UsePostDetailResult {
|
export interface UsePostDetailResult {
|
||||||
post: Post | null;
|
post: Post | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
refresh: () => Promise<Post | null>;
|
refresh: () => Promise<Post | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取帖子详情 hook
|
|
||||||
*/
|
|
||||||
export function usePostDetail(postId: string | undefined | null): UsePostDetailResult {
|
export function usePostDetail(postId: string | undefined | null): UsePostDetailResult {
|
||||||
const post = usePostManagerStore(state =>
|
const post = usePostManagerStore(state =>
|
||||||
postId ? (state.detailsMap.get(postId)?.data ?? null) : null
|
postId ? (state.detailsMap.get(postId)?.data ?? null) : null
|
||||||
@@ -75,18 +62,18 @@ export function usePostDetail(postId: string | undefined | null): UsePostDetailR
|
|||||||
const isLoading = usePostManagerStore(state =>
|
const isLoading = usePostManagerStore(state =>
|
||||||
postId ? state.loadingDetailIds.has(postId) : false
|
postId ? state.loadingDetailIds.has(postId) : false
|
||||||
);
|
);
|
||||||
const [hasFetched, setHasFetched] = useState(false);
|
const lastFetchedRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!postId) return;
|
if (!postId) return;
|
||||||
if (!hasFetched) {
|
if (lastFetchedRef.current !== postId) {
|
||||||
setHasFetched(true);
|
lastFetchedRef.current = postId;
|
||||||
usePostManagerStore.getState().setLoadingDetail(postId, true);
|
usePostManagerStore.getState().setLoadingDetail(postId, true);
|
||||||
postManager.getPostDetail(postId).finally(() => {
|
postManager.getPostDetail(postId).finally(() => {
|
||||||
usePostManagerStore.getState().setLoadingDetail(postId, false);
|
usePostManagerStore.getState().setLoadingDetail(postId, false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [postId, hasFetched]);
|
}, [postId]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!postId) return null;
|
if (!postId) return null;
|
||||||
@@ -99,4 +86,4 @@ export function usePostDetail(postId: string | undefined | null): UsePostDetailR
|
|||||||
}, [postId]);
|
}, [postId]);
|
||||||
|
|
||||||
return { post, isLoading, refresh };
|
return { post, isLoading, refresh };
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,9 @@ import { authService } from '../../services/authService';
|
|||||||
import { userCacheRepository } from '@/database';
|
import { userCacheRepository } from '@/database';
|
||||||
import { useUserManagerStore } from './userStore';
|
import { useUserManagerStore } from './userStore';
|
||||||
import { createCacheEntry, isCacheExpired, DEFAULT_TTL } from '../utils/cache';
|
import { createCacheEntry, isCacheExpired, DEFAULT_TTL } from '../utils/cache';
|
||||||
|
import { createDedupe } from '../utils/requestDedupe';
|
||||||
|
|
||||||
|
const dedupe = createDedupe(() => useUserManagerStore);
|
||||||
|
|
||||||
// ==================== UserManager 类 ====================
|
// ==================== UserManager 类 ====================
|
||||||
|
|
||||||
@@ -37,7 +40,7 @@ class UserManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.dedupe('users:current', async () => {
|
return dedupe('users:current', async () => {
|
||||||
const user = await authService.fetchCurrentUserFromAPI();
|
const user = await authService.fetchCurrentUserFromAPI();
|
||||||
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
|
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
|
||||||
store.setCurrentUserEntry(newEntry);
|
store.setCurrentUserEntry(newEntry);
|
||||||
@@ -51,7 +54,7 @@ class UserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private refreshCurrentUserInBackground(): void {
|
private refreshCurrentUserInBackground(): void {
|
||||||
this.dedupe('users:current:bg', async () => {
|
dedupe('users:current:bg', async () => {
|
||||||
const store = useUserManagerStore.getState();
|
const store = useUserManagerStore.getState();
|
||||||
const user = await authService.fetchCurrentUserFromAPI();
|
const user = await authService.fetchCurrentUserFromAPI();
|
||||||
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
|
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
|
||||||
@@ -90,7 +93,7 @@ class UserManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.dedupe(`users:detail:${userId}`, async () => {
|
return dedupe(`users:detail:${userId}`, async () => {
|
||||||
const store = useUserManagerStore.getState();
|
const store = useUserManagerStore.getState();
|
||||||
const user = await authService.fetchUserByIdFromAPI(userId);
|
const user = await authService.fetchUserByIdFromAPI(userId);
|
||||||
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
|
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
|
||||||
@@ -104,7 +107,7 @@ class UserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private refreshUserByIdInBackground(userId: string): void {
|
private refreshUserByIdInBackground(userId: string): void {
|
||||||
this.dedupe(`users:detail:bg:${userId}`, async () => {
|
dedupe(`users:detail:bg:${userId}`, async () => {
|
||||||
const store = useUserManagerStore.getState();
|
const store = useUserManagerStore.getState();
|
||||||
const user = await authService.fetchUserByIdFromAPI(userId);
|
const user = await authService.fetchUserByIdFromAPI(userId);
|
||||||
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
|
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
|
||||||
@@ -122,18 +125,6 @@ class UserManager {
|
|||||||
invalidateUsers(): void {
|
invalidateUsers(): void {
|
||||||
useUserManagerStore.getState().invalidateAll();
|
useUserManagerStore.getState().invalidateAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
|
||||||
const store = useUserManagerStore.getState();
|
|
||||||
const pending = store.getPendingRequest<T>(key);
|
|
||||||
if (pending) return pending;
|
|
||||||
|
|
||||||
const request = fetcher().finally(() => {
|
|
||||||
useUserManagerStore.getState().deletePendingRequest(key);
|
|
||||||
});
|
|
||||||
store.setPendingRequest(key, request);
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userManager = new UserManager();
|
export const userManager = new UserManager();
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
/**
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
* 用户相关 React Hooks
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useUserManagerStore } from './userStore';
|
import { useUserManagerStore } from './userStore';
|
||||||
import { userManager } from './UserManager';
|
import { userManager } from './UserManager';
|
||||||
import type { UserDTO } from '../../types/dto';
|
import type { UserDTO } from '../../types/dto';
|
||||||
|
import { useAuthStore } from '../authStore';
|
||||||
// ==================== useCurrentUser ====================
|
|
||||||
|
|
||||||
export interface UseCurrentUserResult {
|
export interface UseCurrentUserResult {
|
||||||
user: UserDTO | null;
|
user: UserDTO | null;
|
||||||
@@ -15,24 +10,30 @@ export interface UseCurrentUserResult {
|
|||||||
refresh: () => Promise<UserDTO | null>;
|
refresh: () => Promise<UserDTO | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获å<C2B7>–当å‰<C3A5>用户 hook
|
|
||||||
* è‡ªåŠ¨åŠ è½½ï¼Œæ”¯æŒ<C3A6>缓å˜å’Œå<C592>Žå<C5BD>°åˆ·æ–°
|
|
||||||
*/
|
|
||||||
export function useCurrentUser(): UseCurrentUserResult {
|
export function useCurrentUser(): UseCurrentUserResult {
|
||||||
const user = useUserManagerStore(state => state.currentUser);
|
const user = useUserManagerStore(state => state.currentUser);
|
||||||
const isLoading = useUserManagerStore(state => state.isLoadingCurrentUser);
|
const isLoading = useUserManagerStore(state => state.isLoadingCurrentUser);
|
||||||
const [hasFetched, setHasFetched] = useState(false);
|
const lastFetchedRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasFetched) {
|
const authUser = useAuthStore.getState().currentUser;
|
||||||
setHasFetched(true);
|
const userId = authUser?.id;
|
||||||
|
if (userId && lastFetchedRef.current !== userId) {
|
||||||
|
lastFetchedRef.current = userId;
|
||||||
useUserManagerStore.getState().setLoadingCurrentUser(true);
|
useUserManagerStore.getState().setLoadingCurrentUser(true);
|
||||||
userManager.getCurrentUser().finally(() => {
|
userManager.getCurrentUser().finally(() => {
|
||||||
useUserManagerStore.getState().setLoadingCurrentUser(false);
|
useUserManagerStore.getState().setLoadingCurrentUser(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [hasFetched]);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return useAuthStore.subscribe((state, prev) => {
|
||||||
|
if (!state.currentUser && prev.currentUser) {
|
||||||
|
lastFetchedRef.current = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
useUserManagerStore.getState().setLoadingCurrentUser(true);
|
useUserManagerStore.getState().setLoadingCurrentUser(true);
|
||||||
@@ -46,18 +47,12 @@ export function useCurrentUser(): UseCurrentUserResult {
|
|||||||
return { user, isLoading, refresh };
|
return { user, isLoading, refresh };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== useUser ====================
|
|
||||||
|
|
||||||
export interface UseUserResult {
|
export interface UseUserResult {
|
||||||
user: UserDTO | null;
|
user: UserDTO | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
refresh: () => Promise<UserDTO | null>;
|
refresh: () => Promise<UserDTO | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* æ ¹æ<C2B9>® ID 获å<C2B7>–用户 hook
|
|
||||||
* è‡ªåŠ¨åŠ è½½ï¼Œæ”¯æŒ<C3A6>缓å˜å’Œå<C592>Žå<C5BD>°åˆ·æ–°
|
|
||||||
*/
|
|
||||||
export function useUser(userId: string | undefined | null): UseUserResult {
|
export function useUser(userId: string | undefined | null): UseUserResult {
|
||||||
const user = useUserManagerStore(state =>
|
const user = useUserManagerStore(state =>
|
||||||
userId ? (state.usersMap.get(userId)?.data ?? null) : null
|
userId ? (state.usersMap.get(userId)?.data ?? null) : null
|
||||||
@@ -65,18 +60,18 @@ export function useUser(userId: string | undefined | null): UseUserResult {
|
|||||||
const isLoading = useUserManagerStore(state =>
|
const isLoading = useUserManagerStore(state =>
|
||||||
userId ? state.loadingUserIds.has(userId) : false
|
userId ? state.loadingUserIds.has(userId) : false
|
||||||
);
|
);
|
||||||
const [hasFetched, setHasFetched] = useState(false);
|
const lastFetchedRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
if (!hasFetched) {
|
if (lastFetchedRef.current !== userId) {
|
||||||
setHasFetched(true);
|
lastFetchedRef.current = userId;
|
||||||
useUserManagerStore.getState().setLoadingUser(userId, true);
|
useUserManagerStore.getState().setLoadingUser(userId, true);
|
||||||
userManager.getUserById(userId).finally(() => {
|
userManager.getUserById(userId).finally(() => {
|
||||||
useUserManagerStore.getState().setLoadingUser(userId, false);
|
useUserManagerStore.getState().setLoadingUser(userId, false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [userId, hasFetched]);
|
}, [userId]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!userId) return null;
|
if (!userId) return null;
|
||||||
@@ -91,33 +86,27 @@ export function useUser(userId: string | undefined | null): UseUserResult {
|
|||||||
return { user, isLoading, refresh };
|
return { user, isLoading, refresh };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== useUsers ====================
|
|
||||||
|
|
||||||
export interface UseUsersResult {
|
export interface UseUsersResult {
|
||||||
getUser: (id: string) => UserDTO | null;
|
getUser: (id: string) => UserDTO | null;
|
||||||
refreshUser: (id: string) => Promise<UserDTO | null>;
|
refreshUser: (id: string) => Promise<UserDTO | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 多用户获å<C2B7>?hook
|
|
||||||
* 用于批é‡<C3A9>获å<C2B7>–多个用户信æ<C2A1>¯
|
|
||||||
*/
|
|
||||||
export function useUsers(userIds: string[]): UseUsersResult {
|
export function useUsers(userIds: string[]): UseUsersResult {
|
||||||
const usersMap = useUserManagerStore(state => state.usersMap);
|
const usersMap = useUserManagerStore(state => state.usersMap);
|
||||||
const loadingUserIds = useUserManagerStore(state => state.loadingUserIds);
|
const loadingUserIds = useUserManagerStore(state => state.loadingUserIds);
|
||||||
const [fetchedIds, setFetchedIds] = useState<Set<string>>(new Set());
|
const fetchedIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
userIds.forEach(id => {
|
userIds.forEach(id => {
|
||||||
if (!fetchedIds.has(id) && !loadingUserIds.has(id)) {
|
if (!fetchedIdsRef.current.has(id) && !loadingUserIds.has(id)) {
|
||||||
setFetchedIds(prev => new Set(prev).add(id));
|
fetchedIdsRef.current = new Set(fetchedIdsRef.current).add(id);
|
||||||
useUserManagerStore.getState().setLoadingUser(id, true);
|
useUserManagerStore.getState().setLoadingUser(id, true);
|
||||||
userManager.getUserById(id).finally(() => {
|
userManager.getUserById(id).finally(() => {
|
||||||
useUserManagerStore.getState().setLoadingUser(id, false);
|
useUserManagerStore.getState().setLoadingUser(id, false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [userIds, fetchedIds, loadingUserIds]);
|
}, [userIds, loadingUserIds]);
|
||||||
|
|
||||||
const getUser = useCallback((id: string) => {
|
const getUser = useCallback((id: string) => {
|
||||||
return usersMap.get(id)?.data ?? null;
|
return usersMap.get(id)?.data ?? null;
|
||||||
@@ -133,4 +122,4 @@ export function useUsers(userIds: string[]): UseUsersResult {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { getUser, refreshUser };
|
return { getUser, refreshUser };
|
||||||
}
|
}
|
||||||
58
src/stores/utils/requestDedupe.ts
Normal file
58
src/stores/utils/requestDedupe.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { StoreApi } from 'zustand';
|
||||||
|
|
||||||
|
export interface PendingRequestsSlice {
|
||||||
|
pendingRequests: Map<string, Promise<any>>;
|
||||||
|
getPendingRequest: <T>(key: string) => Promise<T> | undefined;
|
||||||
|
setPendingRequest: <T>(key: string, request: Promise<T>) => void;
|
||||||
|
deletePendingRequest: (key: string) => void;
|
||||||
|
clearPendingRequests: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPendingRequestsSlice(
|
||||||
|
set: (partial: any) => void,
|
||||||
|
get: () => any
|
||||||
|
): PendingRequestsSlice {
|
||||||
|
return {
|
||||||
|
pendingRequests: new Map(),
|
||||||
|
|
||||||
|
getPendingRequest: <T>(key: string) => {
|
||||||
|
return get().pendingRequests.get(key) as Promise<T> | undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
setPendingRequest: <T>(key: string, request: Promise<T>) => {
|
||||||
|
set((state: { pendingRequests: Map<string, Promise<any>> }) => {
|
||||||
|
const newPendingRequests = new Map(state.pendingRequests);
|
||||||
|
newPendingRequests.set(key, request);
|
||||||
|
return { pendingRequests: newPendingRequests };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deletePendingRequest: (key: string) => {
|
||||||
|
set((state: { pendingRequests: Map<string, Promise<any>> }) => {
|
||||||
|
const newPendingRequests = new Map(state.pendingRequests);
|
||||||
|
newPendingRequests.delete(key);
|
||||||
|
return { pendingRequests: newPendingRequests };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearPendingRequests: () => {
|
||||||
|
set({ pendingRequests: new Map() });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDedupe<TStore extends PendingRequestsSlice>(
|
||||||
|
getStore: () => StoreApi<TStore>
|
||||||
|
) {
|
||||||
|
return async function dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||||
|
const store = getStore().getState();
|
||||||
|
const pending = store.getPendingRequest<T>(key);
|
||||||
|
if (pending) return pending;
|
||||||
|
|
||||||
|
const request = fetcher().finally(() => {
|
||||||
|
getStore().getState().deletePendingRequest(key);
|
||||||
|
});
|
||||||
|
store.setPendingRequest(key, request);
|
||||||
|
return request;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -721,7 +721,7 @@ export interface SSEEvent extends WSEvent {}
|
|||||||
* 从消息segments中提取纯文本内容
|
* 从消息segments中提取纯文本内容
|
||||||
* 用于消息列表显示、通知等场景
|
* 用于消息列表显示、通知等场景
|
||||||
*/
|
*/
|
||||||
export function extractTextFromSegments(segments?: MessageSegment[]): string {
|
export function extractTextFromSegments(segments?: MessageSegment[], memberMap?: Map<string, { nickname: string; user_id: string }>): string {
|
||||||
if (!segments || segments.length === 0) {
|
if (!segments || segments.length === 0) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -731,7 +731,6 @@ export function extractTextFromSegments(segments?: MessageSegment[]): string {
|
|||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
switch (segment.type) {
|
switch (segment.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
// 兼容 content 和 text 两种字段
|
|
||||||
const textData = segment.data as TextSegmentData;
|
const textData = segment.data as TextSegmentData;
|
||||||
result += textData.content ?? textData.text ?? '';
|
result += textData.content ?? textData.text ?? '';
|
||||||
break;
|
break;
|
||||||
@@ -739,10 +738,10 @@ export function extractTextFromSegments(segments?: MessageSegment[]): string {
|
|||||||
const atData = segment.data as AtSegmentData;
|
const atData = segment.data as AtSegmentData;
|
||||||
if (atData.user_id === 'all') {
|
if (atData.user_id === 'all') {
|
||||||
result += '@所有人 ';
|
result += '@所有人 ';
|
||||||
} else if (atData.nickname) {
|
|
||||||
result += `@${atData.nickname} `;
|
|
||||||
} else {
|
} else {
|
||||||
result += `@${atData.user_id} `;
|
const member = memberMap?.get(atData.user_id);
|
||||||
|
const displayName = member?.nickname || atData.nickname || atData.user_id;
|
||||||
|
result += `@${displayName} `;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'image':
|
case 'image':
|
||||||
|
|||||||
Reference in New Issue
Block a user