Merge branch 'dev' of https://code.littlelan.cn/carrot_bbs/frontend into dev
This commit is contained in:
3
app.json
3
app.json
@@ -25,7 +25,7 @@
|
|||||||
],
|
],
|
||||||
"ITSAppUsesNonExemptEncryption": false
|
"ITSAppUsesNonExemptEncryption": false
|
||||||
},
|
},
|
||||||
"bundleIdentifier": "cn.qczlit.withyou"
|
"bundleIdentifier": "cn.qczlit.weiyou"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
@@ -66,6 +66,7 @@
|
|||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"./plugins/withSigning",
|
||||||
"./plugins/withMainActivityConfigChange",
|
"./plugins/withMainActivityConfigChange",
|
||||||
[
|
[
|
||||||
"./plugins/withForegroundService",
|
"./plugins/withForegroundService",
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ function NotificationBootstrap() {
|
|||||||
const conversationType = extras.conversation_type || extras.conversationType;
|
const conversationType = extras.conversation_type || extras.conversationType;
|
||||||
const groupId = extras.group_id || extras.groupId;
|
const groupId = extras.group_id || extras.groupId;
|
||||||
const groupName = extras.group_name || extras.groupName;
|
const groupName = extras.group_name || extras.groupName;
|
||||||
|
const groupAvatar = extras.group_avatar || extras.groupAvatar;
|
||||||
|
|
||||||
if (conversationId) {
|
if (conversationId) {
|
||||||
const isGroup = conversationType === 'group';
|
const isGroup = conversationType === 'group';
|
||||||
@@ -169,6 +170,7 @@ function NotificationBootstrap() {
|
|||||||
q.set('isGroupChat', '1');
|
q.set('isGroupChat', '1');
|
||||||
if (groupId) q.set('groupId', groupId);
|
if (groupId) q.set('groupId', groupId);
|
||||||
if (groupName) q.set('groupName', groupName);
|
if (groupName) q.set('groupName', groupName);
|
||||||
|
if (groupAvatar) q.set('groupAvatar', groupAvatar);
|
||||||
} else if (senderId) {
|
} else if (senderId) {
|
||||||
q.set('userId', senderId);
|
q.set('userId', senderId);
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 395 KiB After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 395 KiB After Width: | Height: | Size: 38 KiB |
@@ -95,7 +95,116 @@ const withJPush = (config, options = {}) => {
|
|||||||
config = withAppDelegate(config, (config) => {
|
config = withAppDelegate(config, (config) => {
|
||||||
const { contents } = config.modResults;
|
const { contents } = config.modResults;
|
||||||
|
|
||||||
if (!contents.includes('JPush')) {
|
if (contents.includes('JPush') || contents.includes('JCoreModule')) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSwift = contents.includes('class AppDelegate') || contents.includes('@main');
|
||||||
|
|
||||||
|
if (isSwift) {
|
||||||
|
let newContents = contents;
|
||||||
|
|
||||||
|
if (!newContents.includes('import JPushRN')) {
|
||||||
|
if (newContents.includes('import UIKit')) {
|
||||||
|
newContents = newContents.replace(
|
||||||
|
/(import UIKit\n)/,
|
||||||
|
`$1import UserNotifications\n`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
newContents = newContents.replace(
|
||||||
|
/(internal import Expo\n)/,
|
||||||
|
`$1import UserNotifications\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swift: Add JPush init in application(_:didFinishLaunchingWithOptions:)
|
||||||
|
// Match various Swift signatures
|
||||||
|
const swiftDidFinishPatterns = [
|
||||||
|
/public override func application\(\s*_ application: UIApplication,\s*didFinishLaunchingWithOptions launchOptions: \[UIApplication\.LaunchOptionsKey: Any\]\? = nil\s*\) -> Bool \{/,
|
||||||
|
/func application\(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: \[UIApplication\.LaunchOptionsKey: Any\]\?\?\) -> Bool \{/,
|
||||||
|
];
|
||||||
|
|
||||||
|
let didInsertInit = false;
|
||||||
|
for (const pattern of swiftDidFinishPatterns) {
|
||||||
|
if (pattern.test(newContents)) {
|
||||||
|
newContents = newContents.replace(
|
||||||
|
pattern,
|
||||||
|
`$&\n // JPush initialization\n #if DEBUG\n JCoreModule.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)\n #else\n JCoreModule.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)\n #endif`
|
||||||
|
);
|
||||||
|
didInsertInit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: find the didFinishLaunchingWithOptions method start and insert after the opening brace
|
||||||
|
if (!didInsertInit && newContents.includes('didFinishLaunchingWithOptions')) {
|
||||||
|
const lines = newContents.split('\n');
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].includes('didFinishLaunchingWithOptions') && lines[i].includes('{')) {
|
||||||
|
lines.splice(i + 1, 0,
|
||||||
|
' // JPush initialization',
|
||||||
|
' #if DEBUG',
|
||||||
|
` JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)`,
|
||||||
|
' #else',
|
||||||
|
` JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)`,
|
||||||
|
' #endif',
|
||||||
|
' UNUserNotificationCenter.current().delegate = self'
|
||||||
|
);
|
||||||
|
newContents = lines.join('\n');
|
||||||
|
didInsertInit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also add delegate = self after regex-based insertion if not already present
|
||||||
|
if (didInsertInit && !newContents.includes('UNUserNotificationCenter.current().delegate')) {
|
||||||
|
newContents = newContents.replace(
|
||||||
|
/(#endif\n)(\s+let delegate)/,
|
||||||
|
`$1 UNUserNotificationCenter.current().delegate = self\n$2`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swift: Add delegate methods before the closing brace of the class
|
||||||
|
const jpushSwiftMethods = `
|
||||||
|
// JPush: Register for remote notifications
|
||||||
|
override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||||
|
JPUSHService.registerDeviceToken(deviceToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||||
|
NSLog("did fail to register for remote notifications with error: %@", error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JPush: Handle remote notification
|
||||||
|
override func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||||
|
JPUSHService.handleRemoteNotification(userInfo)
|
||||||
|
completionHandler(.newData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UNUserNotificationCenter delegate: foreground notification display
|
||||||
|
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||||
|
completionHandler([.alert, .badge, .sound])
|
||||||
|
}
|
||||||
|
|
||||||
|
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||||
|
JPUSHService.handleRemoteNotification(response.notification.request.content.userInfo)
|
||||||
|
completionHandler()
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!newContents.includes('JPUSHService.registerDeviceToken')) {
|
||||||
|
// Find the closing brace of the AppDelegate class
|
||||||
|
const lastBraceIndex = newContents.lastIndexOf('}');
|
||||||
|
if (lastBraceIndex !== -1) {
|
||||||
|
newContents = newContents.slice(0, lastBraceIndex) + jpushSwiftMethods + '\n' + newContents.slice(lastBraceIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.modResults.contents = newContents;
|
||||||
|
} else {
|
||||||
|
// ObjC AppDelegate
|
||||||
let newContents = contents;
|
let newContents = contents;
|
||||||
|
|
||||||
if (!newContents.includes('#import <UserNotifications/UserNotifications.h>')) {
|
if (!newContents.includes('#import <UserNotifications/UserNotifications.h>')) {
|
||||||
@@ -116,7 +225,7 @@ const withJPush = (config, options = {}) => {
|
|||||||
if (didFinishLaunchPattern.test(newContents)) {
|
if (didFinishLaunchPattern.test(newContents)) {
|
||||||
newContents = newContents.replace(
|
newContents = newContents.replace(
|
||||||
didFinishLaunchPattern,
|
didFinishLaunchPattern,
|
||||||
`$1\n // JPush initialization\n [JCoreModule setupWithOption:launchOptions appKey:@"${appKey}" channel:@"${channel}" apsForProduction:NO];\n`
|
`$1\n // JPush initialization\n #if DEBUG\n [JCoreModule setupWithOption:launchOptions appKey:@"${appKey}" channel:@"${channel}" apsForProduction:NO];\n #else\n [JCoreModule setupWithOption:launchOptions appKey:@"${appKey}" channel:@"${channel}" apsForProduction:YES];\n #endif\n`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +269,26 @@ const withJPush = (config, options = {}) => {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. iOS: Add required capabilities and permissions to Info.plist
|
// 4. iOS: Add bridging header for Swift if needed
|
||||||
|
config = withDangerousMod(config, [
|
||||||
|
'ios',
|
||||||
|
async (config) => {
|
||||||
|
const appDelegatePath = path.join(config.modRequest.platformProjectRoot, 'app', 'AppDelegate.swift');
|
||||||
|
if (fs.existsSync(appDelegatePath)) {
|
||||||
|
const bridgingHeaderSearch = path.join(config.modRequest.platformProjectRoot, 'app', 'app-Bridging-Header.h');
|
||||||
|
if (fs.existsSync(bridgingHeaderSearch)) {
|
||||||
|
let bridgingContent = fs.readFileSync(bridgingHeaderSearch, 'utf-8');
|
||||||
|
if (!bridgingContent.includes('JCoreModule')) {
|
||||||
|
bridgingContent += '\n#import "JPUSHService.h"\n#import <UserNotifications/UserNotifications.h>\n';
|
||||||
|
fs.writeFileSync(bridgingHeaderSearch, bridgingContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 5. iOS: Add required capabilities and permissions to Info.plist
|
||||||
config = withInfoPlist(config, (config) => {
|
config = withInfoPlist(config, (config) => {
|
||||||
config.modResults.UIBackgroundModes = config.modResults.UIBackgroundModes || [];
|
config.modResults.UIBackgroundModes = config.modResults.UIBackgroundModes || [];
|
||||||
if (!config.modResults.UIBackgroundModes.includes('remote-notification')) {
|
if (!config.modResults.UIBackgroundModes.includes('remote-notification')) {
|
||||||
|
|||||||
41
plugins/withSigning.js
Normal file
41
plugins/withSigning.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const { withDangerousMod } = require('@expo/config-plugins');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const withSigning = (config, options = {}) => {
|
||||||
|
const { teamId = 'X3964MGXHG' } = options;
|
||||||
|
|
||||||
|
config = withDangerousMod(config, [
|
||||||
|
'ios',
|
||||||
|
async (config) => {
|
||||||
|
const platformRoot = config.modRequest.platformProjectRoot;
|
||||||
|
|
||||||
|
// 1. Inject CODE_SIGN_STYLE and DEVELOPMENT_TEAM into pbxproj
|
||||||
|
const pbxprojPath = path.join(platformRoot, 'app.xcodeproj', 'project.pbxproj');
|
||||||
|
if (fs.existsSync(pbxprojPath)) {
|
||||||
|
let contents = fs.readFileSync(pbxprojPath, 'utf-8');
|
||||||
|
const entitlementsLine = 'CODE_SIGN_ENTITLEMENTS = app/app.entitlements;';
|
||||||
|
const signingBlock = `${entitlementsLine}\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tDEVELOPMENT_TEAM = ${teamId};`;
|
||||||
|
contents = contents.replaceAll(entitlementsLine, signingBlock);
|
||||||
|
fs.writeFileSync(pbxprojPath, contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Set aps-environment to production in entitlements
|
||||||
|
const entitlementsPath = path.join(platformRoot, 'app', 'app.entitlements');
|
||||||
|
if (fs.existsSync(entitlementsPath)) {
|
||||||
|
let entitlements = fs.readFileSync(entitlementsPath, 'utf-8');
|
||||||
|
entitlements = entitlements.replace(
|
||||||
|
/<key>aps-environment<\/key>\s*<string>development<\/string>/,
|
||||||
|
'<key>aps-environment</key>\n\t<string>production</string>'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(entitlementsPath, entitlements);
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = withSigning;
|
||||||
@@ -86,13 +86,15 @@ export function hrefChat(params: {
|
|||||||
isGroupChat?: boolean;
|
isGroupChat?: boolean;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
groupName?: string;
|
groupName?: string;
|
||||||
|
groupAvatar?: string;
|
||||||
}): string {
|
}): string {
|
||||||
const { conversationId, userId, isGroupChat, groupId, groupName } = params;
|
const { conversationId, userId, isGroupChat, groupId, groupName, groupAvatar } = params;
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
if (userId) q.set('userId', userId);
|
if (userId) q.set('userId', userId);
|
||||||
if (isGroupChat) q.set('isGroupChat', '1');
|
if (isGroupChat) q.set('isGroupChat', '1');
|
||||||
if (groupId != null && groupId !== '') q.set('groupId', String(groupId));
|
if (groupId != null && groupId !== '') q.set('groupId', String(groupId));
|
||||||
if (groupName) q.set('groupName', groupName);
|
if (groupName) q.set('groupName', groupName);
|
||||||
|
if (groupAvatar) q.set('groupAvatar', groupAvatar);
|
||||||
const qs = q.toString();
|
const qs = q.toString();
|
||||||
return `/chat/${encodeURIComponent(conversationId)}${qs ? `?${qs}` : ''}`;
|
return `/chat/${encodeURIComponent(conversationId)}${qs ? `?${qs}` : ''}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,8 +165,8 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
|||||||
muteAll,
|
muteAll,
|
||||||
followRestrictionHint,
|
followRestrictionHint,
|
||||||
canSendPrivateImage,
|
canSendPrivateImage,
|
||||||
routeGroupId,
|
effectiveGroupId,
|
||||||
routeGroupName,
|
effectiveGroupName,
|
||||||
otherUserLastReadSeq,
|
otherUserLastReadSeq,
|
||||||
messageMap,
|
messageMap,
|
||||||
loadingMore,
|
loadingMore,
|
||||||
@@ -458,7 +458,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
|||||||
isGroupChat={isGroupChat}
|
isGroupChat={isGroupChat}
|
||||||
groupInfo={groupInfo}
|
groupInfo={groupInfo}
|
||||||
otherUser={otherUser}
|
otherUser={otherUser}
|
||||||
routeGroupName={routeGroupName ?? undefined}
|
routeGroupName={effectiveGroupName ?? undefined}
|
||||||
typingHint={typingHint}
|
typingHint={typingHint}
|
||||||
onBack={props.onEmbeddedBack ? props.onEmbeddedBack : () => router.back()}
|
onBack={props.onEmbeddedBack ? props.onEmbeddedBack : () => router.back()}
|
||||||
onTitlePress={navigateToInfo}
|
onTitlePress={navigateToInfo}
|
||||||
@@ -669,7 +669,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
|||||||
{/* 群信息侧边栏面板 - 仅大屏幕显示 */}
|
{/* 群信息侧边栏面板 - 仅大屏幕显示 */}
|
||||||
<GroupInfoPanel
|
<GroupInfoPanel
|
||||||
visible={showGroupInfoPanel}
|
visible={showGroupInfoPanel}
|
||||||
groupId={routeGroupId ? String(routeGroupId) : undefined}
|
groupId={effectiveGroupId ? String(effectiveGroupId) : undefined}
|
||||||
groupInfo={groupInfo as any}
|
groupInfo={groupInfo as any}
|
||||||
conversationId={conversationId || undefined}
|
conversationId={conversationId || undefined}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ export const MessageListScreen: React.FC = () => {
|
|||||||
conversationId: String(conversation.id),
|
conversationId: String(conversation.id),
|
||||||
groupId: String(conversation.group.id),
|
groupId: String(conversation.group.id),
|
||||||
groupName: conversation.group.name,
|
groupName: conversation.group.name,
|
||||||
|
groupAvatar: conversation.group.avatar,
|
||||||
isGroupChat: true,
|
isGroupChat: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
isGroupChat?: string | string[];
|
isGroupChat?: string | string[];
|
||||||
groupId?: string | string[];
|
groupId?: string | string[];
|
||||||
groupName?: string | string[];
|
groupName?: string | string[];
|
||||||
|
groupAvatar?: string | string[];
|
||||||
}>();
|
}>();
|
||||||
// 路由参数(动态段 + query);群 ID 勿用 Number(),避免超过 2^53-1 时精度丢失
|
// 路由参数(动态段 + query);群 ID 勿用 Number(),避免超过 2^53-1 时精度丢失
|
||||||
// 嵌入式模式下优先使用 props,否则从路由参数获取
|
// 嵌入式模式下优先使用 props,否则从路由参数获取
|
||||||
@@ -107,6 +108,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
isGroupChat: props.embeddedIsGroupChat ?? false,
|
isGroupChat: props.embeddedIsGroupChat ?? false,
|
||||||
groupId: props.embeddedGroupId ?? null,
|
groupId: props.embeddedGroupId ?? null,
|
||||||
groupName: props.embeddedGroupName ?? null,
|
groupName: props.embeddedGroupName ?? null,
|
||||||
|
groupAvatar: null as string | null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const isGroupFlag = firstRouteParam(rawParams.isGroupChat);
|
const isGroupFlag = firstRouteParam(rawParams.isGroupChat);
|
||||||
@@ -116,6 +118,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
isGroupChat: isGroupFlag === '1' || isGroupFlag === 'true',
|
isGroupChat: isGroupFlag === '1' || isGroupFlag === 'true',
|
||||||
groupId: firstRouteParam(rawParams.groupId),
|
groupId: firstRouteParam(rawParams.groupId),
|
||||||
groupName: firstRouteParam(rawParams.groupName),
|
groupName: firstRouteParam(rawParams.groupName),
|
||||||
|
groupAvatar: firstRouteParam(rawParams.groupAvatar),
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
props?.embeddedConversationId,
|
props?.embeddedConversationId,
|
||||||
@@ -127,9 +130,10 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
rawParams.isGroupChat,
|
rawParams.isGroupChat,
|
||||||
rawParams.groupId,
|
rawParams.groupId,
|
||||||
rawParams.groupName,
|
rawParams.groupName,
|
||||||
|
rawParams.groupAvatar,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { conversationId: routeConversationId, userId: routeUserId, isGroupChat, groupId: routeGroupId, groupName: routeGroupName } = routeParams;
|
const { conversationId: routeConversationId, userId: routeUserId, isGroupChat, groupId: routeGroupId, groupName: routeGroupName, groupAvatar: routeGroupAvatar } = routeParams;
|
||||||
|
|
||||||
// 当前会话ID(可能在新建私聊会话后动态更新)
|
// 当前会话ID(可能在新建私聊会话后动态更新)
|
||||||
const [conversationId, setConversationId] = useState<string | null>(routeConversationId);
|
const [conversationId, setConversationId] = useState<string | null>(routeConversationId);
|
||||||
@@ -147,13 +151,18 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
conversation,
|
conversation,
|
||||||
} = useChat(conversationId);
|
} = useChat(conversationId);
|
||||||
|
|
||||||
|
// 统一入口:优先路由参数,其次从 conversation 对象提取(JPUSH 进入时 conversation 异步加载)
|
||||||
|
const effectiveGroupId = routeGroupId || (isGroupChat && conversation?.group?.id) || null;
|
||||||
|
const effectiveGroupName = routeGroupName || (isGroupChat && conversation?.group?.name) || null;
|
||||||
|
const effectiveGroupAvatar = routeGroupAvatar || (isGroupChat && conversation?.group?.avatar) || null;
|
||||||
|
|
||||||
// 本地状态
|
// 本地状态
|
||||||
const [currentUserId, setCurrentUserId] = useState<string>('');
|
const [currentUserId, setCurrentUserId] = useState<string>('');
|
||||||
|
|
||||||
// 【新架构】使用 MessageManager 获取群聊输入状态和禁言状态
|
// 【新架构】使用 MessageManager 获取群聊输入状态和禁言状态
|
||||||
const { typingUsers: groupTypingUsers } = useGroupTyping(routeGroupId ? String(routeGroupId) : null);
|
const { typingUsers: groupTypingUsers } = useGroupTyping(effectiveGroupId ? String(effectiveGroupId) : null);
|
||||||
const { isMuted: isMutedFromManager, setMuted: setMutedStatus } = useGroupMuted(
|
const { isMuted: isMutedFromManager, setMuted: setMutedStatus } = useGroupMuted(
|
||||||
routeGroupId ? String(routeGroupId) : null,
|
effectiveGroupId ? String(effectiveGroupId) : null,
|
||||||
currentUserId
|
currentUserId
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -202,8 +211,18 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null);
|
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null);
|
||||||
|
|
||||||
// 群聊相关状态
|
// 群聊相关状态
|
||||||
const [groupInfo, setGroupInfo] = useState<{ name?: string; member_count?: number } | null>(null);
|
const [groupInfo, setGroupInfo] = useState<{ name?: string; member_count?: number; avatar?: string | null } | null>(null);
|
||||||
const [groupMembers, setGroupMembers] = useState<GroupMemberResponse[]>([]);
|
const [groupMembers, setGroupMembers] = useState<GroupMemberResponse[]>([]);
|
||||||
|
|
||||||
|
// 群名/头像:优先使用路由参数(JPUSH extras),其次从 conversation 对象获取
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isGroupChat) return;
|
||||||
|
const name = effectiveGroupName;
|
||||||
|
const avatar = effectiveGroupAvatar;
|
||||||
|
if (name || avatar) {
|
||||||
|
setGroupInfo(prev => prev ? prev : { name: name || undefined, avatar: avatar || null });
|
||||||
|
}
|
||||||
|
}, [isGroupChat, effectiveGroupName, effectiveGroupAvatar]);
|
||||||
const [currentUserRole, setCurrentUserRole] = useState<UserRole>('member');
|
const [currentUserRole, setCurrentUserRole] = useState<UserRole>('member');
|
||||||
const [mentionQuery, setMentionQuery] = useState<string>('');
|
const [mentionQuery, setMentionQuery] = useState<string>('');
|
||||||
const [selectedMentions, setSelectedMentions] = useState<string[]>([]);
|
const [selectedMentions, setSelectedMentions] = useState<string[]>([]);
|
||||||
@@ -453,14 +472,14 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
|
|
||||||
// 群聊模式:获取群组信息和成员列表
|
// 群聊模式:获取群组信息和成员列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isGroupChat || !routeGroupId) return;
|
if (!isGroupChat || !effectiveGroupId) return;
|
||||||
|
|
||||||
const fetchGroupInfo = async () => {
|
const fetchGroupInfo = async () => {
|
||||||
try {
|
try {
|
||||||
const group = await groupManager.getGroup(routeGroupId);
|
const group = await groupManager.getGroup(effectiveGroupId);
|
||||||
setGroupInfo(group);
|
setGroupInfo(group);
|
||||||
|
|
||||||
const membersResponse = await groupManager.getMembers(routeGroupId, 1, MEMBERS_PAGE_SIZE);
|
const membersResponse = await groupManager.getMembers(effectiveGroupId, 1, MEMBERS_PAGE_SIZE);
|
||||||
const members = membersResponse.list || [];
|
const members = membersResponse.list || [];
|
||||||
setGroupMembers(members);
|
setGroupMembers(members);
|
||||||
|
|
||||||
@@ -470,7 +489,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const myInfo = await groupService.getMyMemberInfo(routeGroupId);
|
const myInfo = await groupService.getMyMemberInfo(effectiveGroupId);
|
||||||
setIsMuted(myInfo.is_muted);
|
setIsMuted(myInfo.is_muted);
|
||||||
setMutedStatus(myInfo.is_muted);
|
setMutedStatus(myInfo.is_muted);
|
||||||
setMuteAll(myInfo.mute_all);
|
setMuteAll(myInfo.mute_all);
|
||||||
@@ -508,7 +527,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
if (currentUserId) {
|
if (currentUserId) {
|
||||||
fetchGroupInfo();
|
fetchGroupInfo();
|
||||||
}
|
}
|
||||||
}, [isGroupChat, routeGroupId, currentUserId, setMutedStatus]);
|
}, [isGroupChat, effectiveGroupId, currentUserId, setMutedStatus]);
|
||||||
|
|
||||||
// 私聊模式:创建或获取会话
|
// 私聊模式:创建或获取会话
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1066,7 +1085,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGroupChat && routeGroupId) {
|
if (isGroupChat && effectiveGroupId) {
|
||||||
await messageService.sendMessageByAction('group', conversationId, segments);
|
await messageService.sendMessageByAction('group', conversationId, segments);
|
||||||
|
|
||||||
setInputText('');
|
setInputText('');
|
||||||
@@ -1097,7 +1116,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
pendingAttachments,
|
pendingAttachments,
|
||||||
conversationId,
|
conversationId,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
routeGroupId,
|
effectiveGroupId,
|
||||||
sendMessageViaManager,
|
sendMessageViaManager,
|
||||||
isMuted,
|
isMuted,
|
||||||
muteAll,
|
muteAll,
|
||||||
@@ -1264,7 +1283,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
try {
|
try {
|
||||||
const segments = buildImageSegments(stickerUrl, stickerUrl, undefined, undefined, replyingTo);
|
const segments = buildImageSegments(stickerUrl, stickerUrl, undefined, undefined, replyingTo);
|
||||||
|
|
||||||
if (isGroupChat && routeGroupId) {
|
if (isGroupChat && effectiveGroupId) {
|
||||||
await messageService.sendMessageByAction('group', conversationId, segments);
|
await messageService.sendMessageByAction('group', conversationId, segments);
|
||||||
setReplyingTo(null);
|
setReplyingTo(null);
|
||||||
} else {
|
} else {
|
||||||
@@ -1282,7 +1301,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setSendingImage(false);
|
setSendingImage(false);
|
||||||
}
|
}
|
||||||
}, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, canSendPrivateImage, scrollToLatest]);
|
}, [conversationId, isGroupChat, effectiveGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, canSendPrivateImage, scrollToLatest]);
|
||||||
|
|
||||||
// 切换表情面板
|
// 切换表情面板
|
||||||
const toggleEmojiPanel = useCallback(() => {
|
const toggleEmojiPanel = useCallback(() => {
|
||||||
@@ -1310,7 +1329,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
// 撤回消息 - 现在通过 MessageManager 处理
|
// 撤回消息 - 现在通过 MessageManager 处理
|
||||||
const handleRecall = useCallback(async (messageId: string) => {
|
const handleRecall = useCallback(async (messageId: string) => {
|
||||||
try {
|
try {
|
||||||
if (isGroupChat && routeGroupId) {
|
if (isGroupChat && effectiveGroupId) {
|
||||||
await messageService.recallMessage(messageId);
|
await messageService.recallMessage(messageId);
|
||||||
} else {
|
} else {
|
||||||
await messageService.recallMessage(messageId);
|
await messageService.recallMessage(messageId);
|
||||||
@@ -1320,7 +1339,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
console.error('撤回消息失败:', error);
|
console.error('撤回消息失败:', error);
|
||||||
Alert.alert('撤回失败', '无法撤回消息');
|
Alert.alert('撤回失败', '无法撤回消息');
|
||||||
}
|
}
|
||||||
}, [isGroupChat, routeGroupId, conversationId]);
|
}, [isGroupChat, effectiveGroupId, conversationId]);
|
||||||
|
|
||||||
// 长按消息显示操作菜单
|
// 长按消息显示操作菜单
|
||||||
const handleLongPressMessage = useCallback((message: GroupMessage, position?: MenuPosition) => {
|
const handleLongPressMessage = useCallback((message: GroupMessage, position?: MenuPosition) => {
|
||||||
@@ -1455,17 +1474,17 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
|
|
||||||
// 导航到群组信息或用户资料
|
// 导航到群组信息或用户资料
|
||||||
const navigateToInfo = useCallback(() => {
|
const navigateToInfo = useCallback(() => {
|
||||||
if (isGroupChat && routeGroupId) {
|
if (isGroupChat && effectiveGroupId) {
|
||||||
router.push(hrefs.hrefGroupInfo(routeGroupId, conversationId || undefined));
|
router.push(hrefs.hrefGroupInfo(effectiveGroupId, conversationId || undefined));
|
||||||
} else if (otherUser?.id) {
|
} else if (otherUser?.id) {
|
||||||
router.push(hrefs.hrefUserProfile(String(otherUser.id)));
|
router.push(hrefs.hrefUserProfile(String(otherUser.id)));
|
||||||
}
|
}
|
||||||
}, [isGroupChat, routeGroupId, otherUser, conversationId]);
|
}, [isGroupChat, effectiveGroupId, otherUser, conversationId]);
|
||||||
|
|
||||||
// 导航到聊天管理页面
|
// 导航到聊天管理页面
|
||||||
const navigateToChatSettings = useCallback(() => {
|
const navigateToChatSettings = useCallback(() => {
|
||||||
if (isGroupChat && routeGroupId) {
|
if (isGroupChat && effectiveGroupId) {
|
||||||
router.push(hrefs.hrefGroupInfo(routeGroupId, conversationId || undefined));
|
router.push(hrefs.hrefGroupInfo(effectiveGroupId, conversationId || undefined));
|
||||||
} else if (otherUser?.id && conversationId) {
|
} else if (otherUser?.id && conversationId) {
|
||||||
router.push(
|
router.push(
|
||||||
hrefs.hrefPrivateChatInfo({
|
hrefs.hrefPrivateChatInfo({
|
||||||
@@ -1476,7 +1495,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [isGroupChat, routeGroupId, otherUser, conversationId]);
|
}, [isGroupChat, effectiveGroupId, otherUser, conversationId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 状态
|
// 状态
|
||||||
@@ -1512,8 +1531,8 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
muteAll,
|
muteAll,
|
||||||
followRestrictionHint,
|
followRestrictionHint,
|
||||||
canSendPrivateImage,
|
canSendPrivateImage,
|
||||||
routeGroupId,
|
effectiveGroupId,
|
||||||
routeGroupName,
|
effectiveGroupName,
|
||||||
otherUserLastReadSeq,
|
otherUserLastReadSeq,
|
||||||
messageMap,
|
messageMap,
|
||||||
loadingMore,
|
loadingMore,
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ export const PrivacySettingsScreen: React.FC = () => {
|
|||||||
variant="body"
|
variant="body"
|
||||||
style={[
|
style={[
|
||||||
styles.dropdownOptionText,
|
styles.dropdownOptionText,
|
||||||
isActive && styles.dropdownOptionTextActive,
|
...(isActive ? [styles.dropdownOptionTextActive] : []),
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ export const SettingsScreen: React.FC = () => {
|
|||||||
<View
|
<View
|
||||||
key={group.title}
|
key={group.title}
|
||||||
style={[
|
style={[
|
||||||
{ overflow: 'visible', zIndex: group.items.some((i) => i.isThemePicker && openPicker === 'theme') ? 10 : groupIndex },
|
{ overflow: 'visible', zIndex: group.items.some((i) => 'isThemePicker' in i && i.isThemePicker && openPicker === 'theme') ? 10 : groupIndex },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.groupHeader}>
|
<View style={styles.groupHeader}>
|
||||||
|
|||||||
@@ -94,13 +94,6 @@ class MessageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshConversationsFromServer(
|
|
||||||
page = 1,
|
|
||||||
pageSize = 20
|
|
||||||
): Promise<ConversationListResponse> {
|
|
||||||
return this.requestOffsetConversationsPage(page, pageSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 远端会话列表单页(offset / cursor 统一入口)
|
* 远端会话列表单页(offset / cursor 统一入口)
|
||||||
* 两种模式均先写 SQLite 再返回,行为与 NetworkRemoteConversationListPagedSource 配套。
|
* 两种模式均先写 SQLite 再返回,行为与 NetworkRemoteConversationListPagedSource 配套。
|
||||||
@@ -175,50 +168,6 @@ class MessageService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 会话相关 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取会话列表
|
|
||||||
* GET /api/v1/conversations/list
|
|
||||||
*/
|
|
||||||
async getConversations(
|
|
||||||
page = 1,
|
|
||||||
pageSize = 20,
|
|
||||||
forceRefresh = false
|
|
||||||
): Promise<ConversationListResponse> {
|
|
||||||
// 如果强制刷新,直接从服务器获取,不使用缓存
|
|
||||||
if (!forceRefresh) {
|
|
||||||
const cachedList = await conversationRepository.getListCache();
|
|
||||||
if (cachedList.length > 0) {
|
|
||||||
// 后台刷新数据,但不阻塞当前返回
|
|
||||||
this.refreshConversationsFromServer(page, pageSize).catch(error => {
|
|
||||||
console.error('后台刷新会话列表失败:', error);
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
list: cachedList,
|
|
||||||
total: cachedList.length,
|
|
||||||
page: 1,
|
|
||||||
page_size: cachedList.length,
|
|
||||||
total_pages: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 强制刷新或没有缓存时,直接从服务器获取
|
|
||||||
try {
|
|
||||||
return await this.refreshConversationsFromServer(page, pageSize);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取会话列表失败:', error);
|
|
||||||
return {
|
|
||||||
list: [],
|
|
||||||
total: 0,
|
|
||||||
page: 1,
|
|
||||||
page_size: pageSize,
|
|
||||||
total_pages: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建私聊会话
|
* 创建私聊会话
|
||||||
* POST /api/v1/conversations
|
* POST /api/v1/conversations
|
||||||
@@ -428,98 +377,6 @@ class MessageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送文本消息(便捷方法)
|
|
||||||
*/
|
|
||||||
async sendTextMessage(
|
|
||||||
conversationId: string,
|
|
||||||
content: string,
|
|
||||||
detailType: 'private' | 'group' = 'private'
|
|
||||||
): Promise<MessageResponse> {
|
|
||||||
return this.sendTextMessageByAction(detailType, conversationId, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通过 HTTP POST 发送文本消息
|
|
||||||
* @param detailType 消息类型: 'private' 私聊, 'group' 群聊
|
|
||||||
* @param conversationId 会话ID
|
|
||||||
* @param text 文本内容
|
|
||||||
* @returns 消息响应
|
|
||||||
*/
|
|
||||||
async sendTextMessageByAction(
|
|
||||||
detailType: 'private' | 'group',
|
|
||||||
conversationId: string,
|
|
||||||
text: string
|
|
||||||
): Promise<MessageResponse> {
|
|
||||||
const message: MessageSegment[] = [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
data: {
|
|
||||||
text: text,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return this.sendMessageByAction(detailType, conversationId, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送图片消息(便捷方法)
|
|
||||||
*/
|
|
||||||
async sendImageMessage(
|
|
||||||
conversationId: string,
|
|
||||||
mediaUrl: string,
|
|
||||||
content?: string,
|
|
||||||
detailType: 'private' | 'group' = 'private'
|
|
||||||
): Promise<MessageResponse> {
|
|
||||||
const message: MessageSegment[] = [
|
|
||||||
{
|
|
||||||
type: 'image',
|
|
||||||
data: {
|
|
||||||
url: mediaUrl,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
// 如果有文本说明,添加一个文本段
|
|
||||||
if (content) {
|
|
||||||
message.unshift({
|
|
||||||
type: 'text',
|
|
||||||
data: { text: content },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return this.sendMessageByAction(detailType, conversationId, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送 segments 格式消息(新版统一格式)
|
|
||||||
* @param conversationId 会话ID
|
|
||||||
* @param segments 消息链数组
|
|
||||||
* @param detailType 消息类型
|
|
||||||
*/
|
|
||||||
async sendSegmentsMessage(
|
|
||||||
conversationId: string,
|
|
||||||
segments: MessageSegment[],
|
|
||||||
detailType: 'private' | 'group' = 'private'
|
|
||||||
): Promise<MessageResponse> {
|
|
||||||
return this.sendMessageByAction(detailType, conversationId, segments);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送带回复的 segments 消息
|
|
||||||
* @param conversationId 会话ID
|
|
||||||
* @param segments 消息链数组
|
|
||||||
* @param replyToId 被回复消息的ID
|
|
||||||
* @param detailType 消息类型
|
|
||||||
*/
|
|
||||||
async sendReplySegmentsMessage(
|
|
||||||
conversationId: string,
|
|
||||||
segments: MessageSegment[],
|
|
||||||
replyToId: string,
|
|
||||||
detailType: 'private' | 'group' = 'private'
|
|
||||||
): Promise<MessageResponse> {
|
|
||||||
// 使用 reply_to_id 参数发送回复消息
|
|
||||||
return this.sendMessageByAction(detailType, conversationId, segments, replyToId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 撤回/删除消息
|
* 撤回/删除消息
|
||||||
* 优先使用WebSocket,失败时降级到HTTP
|
* 优先使用WebSocket,失败时降级到HTTP
|
||||||
@@ -568,6 +425,19 @@ class MessageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量标记所有会话已读
|
||||||
|
* POST /api/v1/conversations/read-all
|
||||||
|
*/
|
||||||
|
async markAllAsRead(conversations: Array<{ id: string; lastSeq: number }>): Promise<void> {
|
||||||
|
await api.post('/conversations/read-all', {
|
||||||
|
conversations: conversations.map(c => ({
|
||||||
|
conversation_id: c.id,
|
||||||
|
last_read_seq: Number(c.lastSeq),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上报输入状态
|
* 上报输入状态
|
||||||
* 优先使用WebSocket,失败时降级到HTTP
|
* 优先使用WebSocket,失败时降级到HTTP
|
||||||
@@ -598,65 +468,12 @@ class MessageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取单个会话未读数
|
* 获取同步元数据(轻量级 seq + 时间,用于增量同步判断)
|
||||||
* GET /api/v1/conversations/unread/count?conversation_id=xxx
|
* GET /api/v1/conversations/sync-data
|
||||||
*/
|
*/
|
||||||
async getConversationUnreadCount(
|
async getSyncData(): Promise<Array<{ id: string; max_seq: number; last_message_at: string }>> {
|
||||||
conversationId: string
|
const response = await api.get<{ conversations: Array<{ id: string; max_seq: number; last_message_at: string }> }>('/conversations/sync-data');
|
||||||
): Promise<ConversationUnreadCountResponse> {
|
return response.data?.conversations || [];
|
||||||
try {
|
|
||||||
const response = await api.get<ConversationUnreadCountResponse>(
|
|
||||||
'/conversations/unread/count',
|
|
||||||
{ conversation_id: conversationId }
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取会话未读数失败:', error);
|
|
||||||
return {
|
|
||||||
conversation_id: conversationId,
|
|
||||||
unread_count: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 系统消息相关 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取系统消息列表
|
|
||||||
* GET /api/v1/messages/system
|
|
||||||
* @param limit 返回数量限制
|
|
||||||
* @param beforeId 获取此ID之前的消息(用于分页)
|
|
||||||
*/
|
|
||||||
async getSystemMessages(
|
|
||||||
pageSize: number = 20,
|
|
||||||
page: number = 1
|
|
||||||
): Promise<SystemMessageListResponse> {
|
|
||||||
try {
|
|
||||||
const response = await api.get<{
|
|
||||||
list: any[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
page_size: number;
|
|
||||||
total_pages: number;
|
|
||||||
}>('/messages/system', {
|
|
||||||
page,
|
|
||||||
page_size: pageSize,
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = response.data;
|
|
||||||
return {
|
|
||||||
messages: data.list || [],
|
|
||||||
total: data.total || 0,
|
|
||||||
has_more: data.page < data.total_pages,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取系统消息列表失败:', error);
|
|
||||||
return {
|
|
||||||
messages: [],
|
|
||||||
total: 0,
|
|
||||||
has_more: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -692,41 +509,6 @@ class MessageService {
|
|||||||
await api.put('/messages/system/read-all');
|
await api.put('/messages/system/read-all');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 游标分页方法 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取消息列表(游标分页)
|
|
||||||
* GET /api/v1/conversations/:id/messages/cursor
|
|
||||||
* @param conversationId 会话ID
|
|
||||||
* @param params 游标分页请求参数
|
|
||||||
*/
|
|
||||||
async getMessagesCursor(
|
|
||||||
conversationId: string,
|
|
||||||
params: CursorPaginationRequest = {}
|
|
||||||
): Promise<CursorPaginationResponse<MessageResponse>> {
|
|
||||||
try {
|
|
||||||
const response = await api.get<any>(
|
|
||||||
`/conversations/${encodeURIComponent(conversationId)}/messages/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 {
|
|
||||||
list: [],
|
|
||||||
next_cursor: null,
|
|
||||||
prev_cursor: null,
|
|
||||||
has_more: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取系统消息列表(游标分页)
|
* 获取系统消息列表(游标分页)
|
||||||
* GET /api/v1/messages/system/cursor
|
* GET /api/v1/messages/system/cursor
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ try {
|
|||||||
const Constants = require('expo-constants').default;
|
const Constants = require('expo-constants').default;
|
||||||
const extra = Constants?.expoConfig?.extra || Constants?.extra || {};
|
const extra = Constants?.expoConfig?.extra || Constants?.extra || {};
|
||||||
jpushAppKey = extra.jpushAppKey || '';
|
jpushAppKey = extra.jpushAppKey || '';
|
||||||
jpushChannel = extra.jpushChannel || 'withyou-default';
|
jpushChannel = extra.jpushChannel || 'developer-default';
|
||||||
} else {
|
} else {
|
||||||
console.warn('[JPush] native module not linked, skipping');
|
console.warn('[JPush] native module not linked, skipping');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* MessageManager - 消息管理核心模块(重构版)
|
* MessageManager - 消息管理核心模块
|
||||||
*
|
*
|
||||||
* 作为轻量级协调者,整合各个专注的服务模块
|
* 作为轻量级协调者,整合各个专注的服务模块
|
||||||
* 直接使用 zustand store 进行状态管理
|
* 直接使用 zustand store 进行状态管理
|
||||||
*
|
|
||||||
* 重构说明:
|
|
||||||
* - 移除了 MessageStateManager 包装层
|
|
||||||
* - 直接使用 zustand store
|
|
||||||
* - 服务层移至 services/ 目录
|
|
||||||
* - 移除了 SubscriptionManager,改用 Zustand selector 进行响应式订阅
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ConversationResponse, MessageResponse, MessageSegment, UserDTO } from '../../types/dto';
|
import type { ConversationResponse, MessageResponse, MessageSegment, UserDTO } from '../../types/dto';
|
||||||
@@ -29,7 +23,6 @@ import { useMessageStore, normalizeConversationId, loadPersistedLastSystemMessag
|
|||||||
|
|
||||||
// 重新导出类型,保持兼容性
|
// 重新导出类型,保持兼容性
|
||||||
export type {
|
export type {
|
||||||
MessageManagerState,
|
|
||||||
ReadStateRecord,
|
ReadStateRecord,
|
||||||
MessageManagerConversationListDeps,
|
MessageManagerConversationListDeps,
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -74,6 +67,7 @@ class MessageManager {
|
|||||||
fetchConversations: (force, source) => this.fetchConversations(force, source),
|
fetchConversations: (force, source) => this.fetchConversations(force, source),
|
||||||
fetchUnreadCount: () => this.fetchUnreadCount(),
|
fetchUnreadCount: () => this.fetchUnreadCount(),
|
||||||
fetchMessages: (id) => this.fetchMessages(id),
|
fetchMessages: (id) => this.fetchMessages(id),
|
||||||
|
syncBySeq: () => this.syncService.syncBySeq(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -350,6 +344,11 @@ class MessageManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const task = (async () => {
|
const task = (async () => {
|
||||||
|
// 确保会话数据在 store 中(JPUSH 通知进入时 store 可能还没有此会话)
|
||||||
|
const store = useMessageStore.getState();
|
||||||
|
if (!store.getConversation(normalizedId)) {
|
||||||
|
await this.fetchConversationDetail(normalizedId);
|
||||||
|
}
|
||||||
await this.fetchMessages(normalizedId);
|
await this.fetchMessages(normalizedId);
|
||||||
})();
|
})();
|
||||||
this.activatingConversationTasks.set(normalizedId, task);
|
this.activatingConversationTasks.set(normalizedId, task);
|
||||||
@@ -380,7 +379,7 @@ class MessageManager {
|
|||||||
private shouldDeferConversationRefresh(source: string, forceRefresh: boolean): boolean {
|
private shouldDeferConversationRefresh(source: string, forceRefresh: boolean): boolean {
|
||||||
if (!this.getActiveConversation()) return false;
|
if (!this.getActiveConversation()) return false;
|
||||||
if (!forceRefresh) return false;
|
if (!forceRefresh) return false;
|
||||||
return source === 'sse-reconnect' || source === 'prefetch' || source === 'hooks-initial-refresh';
|
return source === 'sse-reconnect' || source === 'prefetch';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* 消息模块 React Hooks
|
* 消息模块 React Hooks
|
||||||
*
|
*
|
||||||
* 提供React组件与消息状态管理的集成
|
* 提供React组件与消息状态管理的集成
|
||||||
* 所有hooks都基于zustand store的selector机制
|
* 所有hooks都基于zustand store的selector机制
|
||||||
* 确保组件能实时获取状态更新
|
* 确保组件能实时获取状态更新
|
||||||
*
|
|
||||||
* 重构说明:
|
|
||||||
* - 移除了 SubscriptionManager,改用 Zustand selector
|
|
||||||
* - Zustand 会自动处理依赖追踪和组件重渲染
|
|
||||||
* - 不需要手动管理订阅/取消订阅
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 消息模块统一导出
|
* 消息模块统一导出
|
||||||
*
|
|
||||||
* 重构说明:
|
|
||||||
* - 移除了 MessageStateManager 包装层
|
|
||||||
* - 直接使用 zustand store 进行状态管理
|
|
||||||
* - 服务层移至 services/ 目录
|
|
||||||
* - 新增 hooks.ts 封装常用逻辑
|
|
||||||
* - 移除了 SubscriptionManager,改用 Zustand selector 进行响应式订阅
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ==================== 主管理器 ====================
|
// ==================== 主管理器 ====================
|
||||||
@@ -24,7 +17,6 @@ export type { MessageState, MessageActions, MessageStore } from './store';
|
|||||||
// ==================== 类型导出 ====================
|
// ==================== 类型导出 ====================
|
||||||
// 从 types.ts 导出所有类型
|
// 从 types.ts 导出所有类型
|
||||||
export type {
|
export type {
|
||||||
MessageManagerState,
|
|
||||||
ReadStateRecord,
|
ReadStateRecord,
|
||||||
MessageManagerConversationListDeps,
|
MessageManagerConversationListDeps,
|
||||||
HandleNewMessageOptions,
|
HandleNewMessageOptions,
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 会话操作服务
|
* 会话操作服务
|
||||||
* 处理会话的 CRUD 操作
|
* 处理会话的 CRUD 操作
|
||||||
*
|
|
||||||
* 重构说明:
|
|
||||||
* - 直接使用 zustand store 替代 IMessageStateManager
|
|
||||||
* - 移除了 SubscriptionManager 调用,Zustand 的 set() 会自动触发组件重渲染
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ConversationResponse } from '../../../types/dto';
|
import type { ConversationResponse } from '../../../types/dto';
|
||||||
@@ -102,32 +98,6 @@ export class ConversationOperations implements IConversationOperations {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 增加会话未读数
|
|
||||||
*/
|
|
||||||
incrementUnreadCount(conversationId: string): void {
|
|
||||||
const store = useMessageStore.getState();
|
|
||||||
const conversation = store.getConversation(conversationId);
|
|
||||||
if (!conversation) return;
|
|
||||||
|
|
||||||
const prevUnreadCount = conversation.unread_count || 0;
|
|
||||||
const newUnreadCount = prevUnreadCount + 1;
|
|
||||||
|
|
||||||
// 创建新对象而不是直接修改,确保状态一致性
|
|
||||||
const updatedConv: ConversationResponse = {
|
|
||||||
...conversation,
|
|
||||||
unread_count: newUnreadCount,
|
|
||||||
};
|
|
||||||
|
|
||||||
store.updateConversation(updatedConv);
|
|
||||||
|
|
||||||
const currentUnread = store.getUnreadCount();
|
|
||||||
store.setUnreadCount(
|
|
||||||
currentUnread.total + 1,
|
|
||||||
currentUnread.system
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置系统消息未读数
|
* 设置系统消息未读数
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 消息发送服务
|
* 消息发送服务
|
||||||
* 处理消息发送相关逻辑
|
* 处理消息发送相关逻辑
|
||||||
*
|
|
||||||
* 重构说明:
|
|
||||||
* - 直接使用 zustand store 替代 IMessageStateManager
|
|
||||||
* - 移除了 SubscriptionManager 调用,Zustand 的 set() 会自动触发组件重渲染
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { MessageResponse, MessageSegment, ConversationResponse } from '../../../types/dto';
|
import type { MessageResponse, MessageSegment, ConversationResponse } from '../../../types/dto';
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 消息同步服务
|
* 消息同步服务
|
||||||
* 处理消息和会话的服务器同步逻辑
|
* 处理消息和会话的服务器同步逻辑
|
||||||
*
|
|
||||||
* 重构说明:
|
|
||||||
* - 直接使用 zustand store 替代 IMessageStateManager
|
|
||||||
* - 移除了 SubscriptionManager 调用,Zustand 的 set() 会自动触发组件重渲染
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ConversationResponse, MessageResponse } from '../../../types/dto';
|
import type { ConversationResponse, MessageResponse } from '../../../types/dto';
|
||||||
@@ -33,8 +29,8 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
|
|
||||||
/** 正在加载会话列表下一页 */
|
/** 正在加载会话列表下一页 */
|
||||||
private loadingMoreConversations = false;
|
private loadingMoreConversations = false;
|
||||||
/** 聊天页活跃期间延迟的会话列表刷新 */
|
/** fetchUnreadCount 去重:复用 in-flight promise */
|
||||||
private deferredConversationRefresh = false;
|
private fetchUnreadCountPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
getCurrentUserId: () => string | null,
|
getCurrentUserId: () => string | null,
|
||||||
@@ -68,7 +64,6 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.shouldDeferConversationRefresh(source, forceRefresh)) {
|
if (this.shouldDeferConversationRefresh(source, forceRefresh)) {
|
||||||
this.deferredConversationRefresh = true;
|
|
||||||
if (__DEV__) {
|
if (__DEV__) {
|
||||||
console.log('[MessageSyncService] defer fetchConversations', {
|
console.log('[MessageSyncService] defer fetchConversations', {
|
||||||
source,
|
source,
|
||||||
@@ -108,15 +103,12 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
newConversations.set(normalizedConv.id, normalizedConv);
|
newConversations.set(normalizedConv.id, normalizedConv);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 使用 store.setConversations 会自动排序并更新 conversationList
|
// 合并更新:conversations + totalUnread 在同一次 set 中完成,消除中间状态窗口
|
||||||
store.setConversations(newConversations);
|
|
||||||
|
|
||||||
// 计算并更新未读数
|
|
||||||
const totalUnread = Array.from(newConversations.values()).reduce(
|
const totalUnread = Array.from(newConversations.values()).reduce(
|
||||||
(sum, conv) => sum + (conv.unread_count || 0),
|
(sum, conv) => sum + (conv.unread_count || 0),
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
store.setUnreadCount(totalUnread, store.getUnreadCount().system);
|
store.setConversationsWithUnread(newConversations, totalUnread, store.getUnreadCount().system);
|
||||||
|
|
||||||
this.persistConversationListCache();
|
this.persistConversationListCache();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -209,8 +201,6 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
if (!hasInMemoryMessages) {
|
if (!hasInMemoryMessages) {
|
||||||
try {
|
try {
|
||||||
const localMessages = await messageRepository.getByConversation(conversationId, 20);
|
const localMessages = await messageRepository.getByConversation(conversationId, 20);
|
||||||
const localMaxSeq = await messageRepository.getMaxSeq(conversationId);
|
|
||||||
baselineMaxSeq = localMaxSeq;
|
|
||||||
|
|
||||||
if (localMessages.length > 0) {
|
if (localMessages.length > 0) {
|
||||||
const formattedMessages: MessageResponse[] = localMessages.map(m => ({
|
const formattedMessages: MessageResponse[] = localMessages.map(m => ({
|
||||||
@@ -225,7 +215,6 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
|
|
||||||
store.setMessages(conversationId, formattedMessages);
|
store.setMessages(conversationId, formattedMessages);
|
||||||
} else {
|
} else {
|
||||||
// 冷启动兜底:先设置空列表
|
|
||||||
store.setMessages(conversationId, []);
|
store.setMessages(conversationId, []);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -233,32 +222,20 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 服务端快照 + 增量同步
|
// 更新 baseline:合并本地DB后重新计算
|
||||||
try {
|
const currentMessages = store.getMessages(conversationId);
|
||||||
const snapshotResp = await messageService.getMessages(conversationId, undefined, undefined, 50);
|
baselineMaxSeq = currentMessages.reduce((max, m) => Math.max(max, m.seq || 0), 0);
|
||||||
const snapshotMessages = snapshotResp?.messages || [];
|
|
||||||
|
|
||||||
if (snapshotMessages.length > 0) {
|
|
||||||
const existingMessages = store.getMessages(conversationId);
|
|
||||||
const mergedSnapshot = mergeMessagesById(existingMessages, snapshotMessages);
|
|
||||||
store.setMessages(conversationId, mergedSnapshot);
|
|
||||||
|
|
||||||
messageRepository.saveMessagesBatch(
|
// 策略:有本地数据时仅增量同步,无数据时拉快照
|
||||||
MessageMapper.toCachedMessages(snapshotMessages, conversationId)
|
if (baselineMaxSeq > 0) {
|
||||||
).catch(error => {
|
// 增量同步:只拉 baselineMaxSeq 之后的消息
|
||||||
console.error('[MessageSyncService] 保存快照消息到本地失败:', error);
|
try {
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 增量补齐
|
|
||||||
const snapshotMaxSeq = snapshotMessages.reduce((max, m) => Math.max(max, m.seq || 0), 0);
|
|
||||||
if (snapshotMaxSeq > baselineMaxSeq) {
|
|
||||||
const incrementalResp = await messageService.getMessages(conversationId, baselineMaxSeq);
|
const incrementalResp = await messageService.getMessages(conversationId, baselineMaxSeq);
|
||||||
const newMessages = incrementalResp?.messages || [];
|
const newMessages = incrementalResp?.messages || [];
|
||||||
|
|
||||||
if (newMessages.length > 0) {
|
if (newMessages.length > 0) {
|
||||||
const existingMessages = store.getMessages(conversationId);
|
const existingMsgs = store.getMessages(conversationId);
|
||||||
const mergedMessages = mergeMessagesById(existingMessages, newMessages);
|
const mergedMessages = mergeMessagesById(existingMsgs, newMessages);
|
||||||
store.setMessages(conversationId, mergedMessages);
|
store.setMessages(conversationId, mergedMessages);
|
||||||
|
|
||||||
messageRepository.saveMessagesBatch(
|
messageRepository.saveMessagesBatch(
|
||||||
@@ -267,9 +244,27 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
console.error('[MessageSyncService] 保存增量消息到本地失败:', error);
|
console.error('[MessageSyncService] 保存增量消息到本地失败:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MessageSyncService] 增量同步失败:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 冷启动:本地无数据,拉快照
|
||||||
|
try {
|
||||||
|
const snapshotResp = await messageService.getMessages(conversationId, undefined, undefined, 50);
|
||||||
|
const snapshotMessages = snapshotResp?.messages || [];
|
||||||
|
|
||||||
|
if (snapshotMessages.length > 0) {
|
||||||
|
store.setMessages(conversationId, snapshotMessages);
|
||||||
|
|
||||||
|
messageRepository.saveMessagesBatch(
|
||||||
|
MessageMapper.toCachedMessages(snapshotMessages, conversationId)
|
||||||
|
).catch(error => {
|
||||||
|
console.error('[MessageSyncService] 保存快照消息到本地失败:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MessageSyncService] 快照同步失败:', error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('[MessageSyncService] 快照/增量同步失败:', error);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 指定了 afterSeq
|
// 指定了 afterSeq
|
||||||
@@ -360,11 +355,23 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取未读数
|
* 获取未读数(去重:复用 in-flight promise)
|
||||||
*/
|
*/
|
||||||
async fetchUnreadCount(): Promise<void> {
|
async fetchUnreadCount(): Promise<void> {
|
||||||
|
if (this.fetchUnreadCountPromise) {
|
||||||
|
return this.fetchUnreadCountPromise;
|
||||||
|
}
|
||||||
|
this.fetchUnreadCountPromise = this.doFetchUnreadCount();
|
||||||
|
try {
|
||||||
|
await this.fetchUnreadCountPromise;
|
||||||
|
} finally {
|
||||||
|
this.fetchUnreadCountPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doFetchUnreadCount(): Promise<void> {
|
||||||
const store = useMessageStore.getState();
|
const store = useMessageStore.getState();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [unreadData, systemUnreadData] = await Promise.all([
|
const [unreadData, systemUnreadData] = await Promise.all([
|
||||||
messageService.getUnreadCount(),
|
messageService.getUnreadCount(),
|
||||||
@@ -388,28 +395,77 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 服务端汇总未读为 0 时,清掉内存中残留的红点
|
// 服务端汇总未读为 0:不主动清零本地未读,等 fetchConversations 提供权威数据
|
||||||
if (totalUnread === 0) {
|
// 避免后端缓存竞态导致的未读数抖动
|
||||||
const currentConversations = store.getState().conversations;
|
|
||||||
let anyCleared = false;
|
|
||||||
const newConversations = new Map(currentConversations);
|
|
||||||
for (const [cid, conv] of newConversations) {
|
|
||||||
if ((conv.unread_count || 0) > 0) {
|
|
||||||
newConversations.set(cid, { ...conv, unread_count: 0 });
|
|
||||||
anyCleared = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (anyCleared) {
|
|
||||||
// 使用 zustand set 函数正确更新状态
|
|
||||||
store.setConversations(newConversations);
|
|
||||||
this.persistConversationListCache();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[MessageSyncService] 获取未读数失败:', error);
|
console.error('[MessageSyncService] 获取未读数失败:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于 seq 的轻量增量同步(用于重连场景,替代全量刷新)
|
||||||
|
* 返回 true 表示同步成功,false 表示需要退化为全量刷新
|
||||||
|
*/
|
||||||
|
async syncBySeq(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const serverItems = await messageService.getSyncData();
|
||||||
|
if (!serverItems || serverItems.length === 0) return false;
|
||||||
|
|
||||||
|
const store = useMessageStore.getState();
|
||||||
|
const localConversations = store.conversations;
|
||||||
|
|
||||||
|
// 没有本地数据,无法增量同步
|
||||||
|
if (localConversations.size === 0) return false;
|
||||||
|
|
||||||
|
// 构建 serverMap
|
||||||
|
const serverMap = new Map<string, { max_seq: number; last_message_at: string }>();
|
||||||
|
for (const item of serverItems) {
|
||||||
|
serverMap.set(normalizeConversationId(item.id), item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对比差异
|
||||||
|
const staleIds: string[] = [];
|
||||||
|
for (const [id, conv] of localConversations) {
|
||||||
|
const serverItem = serverMap.get(id);
|
||||||
|
if (!serverItem) continue;
|
||||||
|
const localMaxSeq = Number(conv.last_seq || 0);
|
||||||
|
const serverMaxSeq = Number(serverItem.max_seq || 0);
|
||||||
|
if (serverMaxSeq > localMaxSeq) {
|
||||||
|
staleIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 差异过大(>50%),退化为全量刷新
|
||||||
|
if (staleIds.length > localConversations.size * 0.5) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对有差异的会话更新数据
|
||||||
|
if (staleIds.length > 0) {
|
||||||
|
const detailPromises = staleIds.map(id =>
|
||||||
|
this.fetchConversationDetail(id).catch(() => {})
|
||||||
|
);
|
||||||
|
await Promise.all(detailPromises);
|
||||||
|
|
||||||
|
// 重新读取 store,避免 stale 引用
|
||||||
|
const activeId = useMessageStore.getState().getActiveConversation();
|
||||||
|
if (activeId && staleIds.includes(activeId)) {
|
||||||
|
const localMaxSeq = useMessageStore.getState().getMessages(activeId).reduce(
|
||||||
|
(max, m) => Math.max(max, m.seq || 0), 0
|
||||||
|
);
|
||||||
|
if (localMaxSeq > 0) {
|
||||||
|
await this.fetchMessages(activeId, localMaxSeq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MessageSyncService] syncBySeq 失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查是否可加载更多会话
|
* 检查是否可加载更多会话
|
||||||
*/
|
*/
|
||||||
@@ -487,15 +543,12 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
newConversations.set(normalizedConv.id, normalizedConv);
|
newConversations.set(normalizedConv.id, normalizedConv);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 使用 store.setConversations 会自动排序并更新 conversationList
|
// 合并更新:conversations + totalUnread 在同一次 set 中完成
|
||||||
store.setConversations(newConversations);
|
|
||||||
|
|
||||||
// 计算并更新未读数
|
|
||||||
const totalUnread = Array.from(newConversations.values()).reduce(
|
const totalUnread = Array.from(newConversations.values()).reduce(
|
||||||
(sum, conv) => sum + (conv.unread_count || 0),
|
(sum, conv) => sum + (conv.unread_count || 0),
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
store.setUnreadCount(totalUnread, store.getUnreadCount().system);
|
store.setConversationsWithUnread(newConversations, totalUnread, store.getUnreadCount().system);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 已读回执管理器
|
* 已读回执管理器
|
||||||
* 管理消息已读状态的同步和保护
|
* 管理消息已读状态的同步和保护
|
||||||
*
|
|
||||||
* 重构说明:
|
|
||||||
* - 直接使用 zustand store 替代 IMessageStateManager
|
|
||||||
* - 移除了 SubscriptionManager 调用,Zustand 的 set() 会自动触发组件重渲染
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ConversationResponse } from '../../../types/dto';
|
import type { ConversationResponse } from '../../../types/dto';
|
||||||
@@ -64,18 +60,16 @@ export class ReadReceiptManager implements IReadReceiptManager {
|
|||||||
lastReadSeq: seq,
|
lastReadSeq: seq,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. 乐观更新本地状态
|
// 2. 乐观更新本地状态(原子更新,避免中间渲染状态)
|
||||||
const updatedConv: ConversationResponse = {
|
const updatedConv: ConversationResponse = {
|
||||||
...conversation,
|
...conversation,
|
||||||
unread_count: 0,
|
unread_count: 0,
|
||||||
my_last_read_seq: Math.max(Number(conversation.my_last_read_seq || 0), seq),
|
my_last_read_seq: Math.max(Number(conversation.my_last_read_seq || 0), seq),
|
||||||
};
|
};
|
||||||
|
|
||||||
store.updateConversation(updatedConv);
|
|
||||||
|
|
||||||
const currentUnread = store.getUnreadCount();
|
const currentUnread = store.getUnreadCount();
|
||||||
const newTotalUnread = Math.max(0, currentUnread.total - prevUnreadCount);
|
const newTotalUnread = Math.max(0, currentUnread.total - prevUnreadCount);
|
||||||
store.setUnreadCount(newTotalUnread, currentUnread.system);
|
store.updateConversationWithUnread(updatedConv, newTotalUnread, currentUnread.system);
|
||||||
|
|
||||||
// 3. 更新本地数据库
|
// 3. 更新本地数据库
|
||||||
messageRepository.markConversationAsRead(normalizedId).catch(console.error);
|
messageRepository.markConversationAsRead(normalizedId).catch(console.error);
|
||||||
@@ -117,34 +111,52 @@ export class ReadReceiptManager implements IReadReceiptManager {
|
|||||||
*/
|
*/
|
||||||
async markAllAsRead(): Promise<void> {
|
async markAllAsRead(): Promise<void> {
|
||||||
const store = useMessageStore.getState();
|
const store = useMessageStore.getState();
|
||||||
const conversations = store.conversations;
|
const conversationsMap = store.conversations;
|
||||||
const prevTotalUnread = store.getUnreadCount().total;
|
const prevTotalUnread = store.getUnreadCount().total;
|
||||||
const currentSystem = store.getUnreadCount().system;
|
const currentSystem = store.getUnreadCount().system;
|
||||||
|
|
||||||
// 乐观更新
|
// 先收集所有有未读数的会话(不变原 Map)
|
||||||
conversations.forEach(conv => {
|
const unreadConversations: Array<{ id: string; lastSeq: number }> = [];
|
||||||
conv.unread_count = 0;
|
conversationsMap.forEach(conv => {
|
||||||
|
if ((conv.unread_count || 0) > 0) {
|
||||||
|
unreadConversations.push({
|
||||||
|
id: conv.id,
|
||||||
|
lastSeq: Number(conv.last_seq || 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
store.setUnreadCount(0, currentSystem);
|
if (unreadConversations.length === 0 && prevTotalUnread === 0) return;
|
||||||
|
|
||||||
|
// 乐观更新:创建新 Map,将所有有未读的会话置零(不变原对象)
|
||||||
|
const newConversations = new Map(conversationsMap);
|
||||||
|
conversationsMap.forEach((conv, id) => {
|
||||||
|
if ((conv.unread_count || 0) > 0) {
|
||||||
|
newConversations.set(id, { ...conv, unread_count: 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
store.setConversationsWithUnread(newConversations, 0, currentSystem);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 标记系统消息已读
|
// 标记系统消息已读
|
||||||
await messageService.markAllSystemMessagesRead();
|
await messageService.markAllSystemMessagesRead();
|
||||||
|
|
||||||
// 标记所有会话已读
|
// 批量标记会话已读
|
||||||
const promises: Promise<any>[] = [];
|
if (unreadConversations.length > 0) {
|
||||||
conversations.forEach(conv => {
|
try {
|
||||||
if ((conv.unread_count || 0) > 0) {
|
await messageService.markAllAsRead(unreadConversations);
|
||||||
promises.push(messageService.markAsRead(conv.id, conv.last_seq));
|
} catch {
|
||||||
|
// 降级:逐个调用
|
||||||
|
const promises = unreadConversations.map(conv =>
|
||||||
|
messageService.markAsRead(conv.id, conv.lastSeq)
|
||||||
|
);
|
||||||
|
await Promise.all(promises);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ReadReceiptManager] 标记所有已读失败:', error);
|
console.error('[ReadReceiptManager] 标记所有已读失败:', error);
|
||||||
// 回滚
|
// 回滚:恢复原始会话和未读数
|
||||||
store.setUnreadCount(prevTotalUnread, currentSystem);
|
store.setConversationsWithUnread(new Map(conversationsMap), prevTotalUnread, currentSystem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* WebSocket 消息处理器
|
* WebSocket 消息处理器
|
||||||
* 处理所有来自 WebSocket 的消息事件
|
* 处理所有来自 WebSocket 的消息事件
|
||||||
*
|
|
||||||
* 重构说明:
|
|
||||||
* - 直接使用 zustand store 替代 IMessageStateManager
|
|
||||||
* - 移除了 SubscriptionManager 调用,Zustand 的 set() 会自动触发组件重渲染
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -39,11 +35,15 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
private fetchConversationsCallback: (forceRefresh: boolean, source: string) => Promise<void>;
|
private fetchConversationsCallback: (forceRefresh: boolean, source: string) => Promise<void>;
|
||||||
private fetchUnreadCountCallback: () => Promise<void>;
|
private fetchUnreadCountCallback: () => Promise<void>;
|
||||||
private fetchMessagesCallback: (conversationId: string) => Promise<void>;
|
private fetchMessagesCallback: (conversationId: string) => Promise<void>;
|
||||||
|
private syncBySeqCallback: () => Promise<boolean>;
|
||||||
|
|
||||||
private sseUnsubscribe: (() => void) | null = null;
|
private sseUnsubscribe: (() => void) | null = null;
|
||||||
private isBootstrapping: boolean = false;
|
private isBootstrapping: boolean = false;
|
||||||
private lastReconnectSyncAt: number = 0;
|
private lastReconnectSyncAt: number = 0;
|
||||||
|
|
||||||
|
// 系统通知去重(防止重连时重复递增系统未读)
|
||||||
|
private processedNotificationIds: Set<string> = new Set();
|
||||||
|
|
||||||
// 初始化阶段缓冲来自 SSE 的消息事件
|
// 初始化阶段缓冲来自 SSE 的消息事件
|
||||||
private bufferedChatMessages: Array<(WSChatMessage | WSGroupChatMessage) & { _isAck?: boolean }> = [];
|
private bufferedChatMessages: Array<(WSChatMessage | WSGroupChatMessage) & { _isAck?: boolean }> = [];
|
||||||
private bufferedReadReceipts: Array<WSReadMessage | WSGroupReadMessage> = [];
|
private bufferedReadReceipts: Array<WSReadMessage | WSGroupReadMessage> = [];
|
||||||
@@ -57,6 +57,7 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
fetchConversations: (forceRefresh: boolean, source: string) => Promise<void>;
|
fetchConversations: (forceRefresh: boolean, source: string) => Promise<void>;
|
||||||
fetchUnreadCount: () => Promise<void>;
|
fetchUnreadCount: () => Promise<void>;
|
||||||
fetchMessages: (conversationId: string) => Promise<void>;
|
fetchMessages: (conversationId: string) => Promise<void>;
|
||||||
|
syncBySeq: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
this.deduplication = deduplication;
|
this.deduplication = deduplication;
|
||||||
@@ -66,6 +67,7 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
this.fetchConversationsCallback = callbacks.fetchConversations;
|
this.fetchConversationsCallback = callbacks.fetchConversations;
|
||||||
this.fetchUnreadCountCallback = callbacks.fetchUnreadCount;
|
this.fetchUnreadCountCallback = callbacks.fetchUnreadCount;
|
||||||
this.fetchMessagesCallback = callbacks.fetchMessages;
|
this.fetchMessagesCallback = callbacks.fetchMessages;
|
||||||
|
this.syncBySeqCallback = callbacks.syncBySeq;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,6 +136,21 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
if (message.is_read) {
|
if (message.is_read) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 按通知 ID 去重,防止重连时重复递增
|
||||||
|
const notificationId = (message as any).id || (message as any).message_id;
|
||||||
|
if (notificationId) {
|
||||||
|
if (this.processedNotificationIds.has(String(notificationId))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.processedNotificationIds.add(String(notificationId));
|
||||||
|
// 防止内存泄漏:超过 500 条时淘汰最旧的 100 条
|
||||||
|
if (this.processedNotificationIds.size > 500) {
|
||||||
|
const firstIds = Array.from(this.processedNotificationIds).slice(0, 100);
|
||||||
|
firstIds.forEach(id => this.processedNotificationIds.delete(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.incrementSystemUnread();
|
this.incrementSystemUnread();
|
||||||
if (message.created_at) {
|
if (message.created_at) {
|
||||||
const store = useMessageStore.getState();
|
const store = useMessageStore.getState();
|
||||||
@@ -154,43 +171,62 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
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 = useMessageStore.getState().getActiveConversation();
|
|
||||||
|
|
||||||
if (!activeConversation) {
|
// 优先尝试 seq 驱动的轻量同步(内部会为 stale 的活动会话拉取消息)
|
||||||
this.fetchConversationsCallback(true, 'sse-reconnect').catch(error => {
|
this.syncBySeqCallback().then(ok => {
|
||||||
console.error('[WSMessageHandler] 连接后同步会话失败:', error);
|
if (!ok) {
|
||||||
});
|
// 增量同步失败或差异过大,退化为全量刷新
|
||||||
} else {
|
this.fetchConversationsCallback(true, 'sse-reconnect').catch(error => {
|
||||||
|
console.error('[WSMessageHandler] 连接后同步会话失败:', error);
|
||||||
|
});
|
||||||
|
// 退化场景下仍需同步活动会话消息
|
||||||
|
const activeConv = useMessageStore.getState().getActiveConversation();
|
||||||
|
if (activeConv) {
|
||||||
|
this.fetchMessagesCallback(activeConv).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
this.fetchUnreadCountCallback().catch(error => {
|
this.fetchUnreadCountCallback().catch(error => {
|
||||||
console.error('[WSMessageHandler] 连接后同步未读数失败:', error);
|
console.error('[WSMessageHandler] 连接后同步未读数失败:', error);
|
||||||
});
|
});
|
||||||
}
|
}).catch(() => {
|
||||||
|
// syncBySeq 异常,退化为全量
|
||||||
if (activeConversation) {
|
this.fetchConversationsCallback(true, 'sse-reconnect').catch(() => {});
|
||||||
this.fetchMessagesCallback(activeConversation).catch(error => {
|
const activeConv = useMessageStore.getState().getActiveConversation();
|
||||||
console.error('[WSMessageHandler] 连接后同步当前会话消息失败:', error);
|
if (activeConv) {
|
||||||
});
|
this.fetchMessagesCallback(activeConv).catch(() => {});
|
||||||
}
|
}
|
||||||
|
this.fetchUnreadCountCallback().catch(() => {});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听 sync_required 事件,触发增量同步
|
// 监听 sync_required 事件,触发增量同步
|
||||||
wsService.on('sync_required', () => {
|
wsService.on('sync_required', () => {
|
||||||
console.log('[WSMessageHandler] 收到 sync_required,开始增量同步');
|
console.log('[WSMessageHandler] 收到 sync_required,开始增量同步');
|
||||||
const activeConversation = useMessageStore.getState().getActiveConversation();
|
|
||||||
|
|
||||||
this.fetchConversationsCallback(true, 'sync_required').catch(error => {
|
// 先尝试 seq 驱动的轻量同步(内部会为 stale 的活动会话拉取消息)
|
||||||
console.error('[WSMessageHandler] sync_required 同步会话失败:', error);
|
this.syncBySeqCallback().then(ok => {
|
||||||
});
|
if (!ok) {
|
||||||
this.fetchUnreadCountCallback().catch(error => {
|
this.fetchConversationsCallback(true, 'sync_required').catch(error => {
|
||||||
console.error('[WSMessageHandler] sync_required 同步未读数失败:', error);
|
console.error('[WSMessageHandler] sync_required 同步会话失败:', error);
|
||||||
});
|
});
|
||||||
|
// 退化场景下仍需同步活动会话消息
|
||||||
if (activeConversation) {
|
const activeConv = useMessageStore.getState().getActiveConversation();
|
||||||
this.fetchMessagesCallback(activeConversation).catch(error => {
|
if (activeConv) {
|
||||||
console.error('[WSMessageHandler] sync_required 同步消息失败:', error);
|
this.fetchMessagesCallback(activeConv).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.fetchUnreadCountCallback().catch(error => {
|
||||||
|
console.error('[WSMessageHandler] sync_required 同步未读数失败:', error);
|
||||||
});
|
});
|
||||||
}
|
}).catch(() => {
|
||||||
|
this.fetchConversationsCallback(true, 'sync_required').catch(() => {});
|
||||||
|
const activeConv = useMessageStore.getState().getActiveConversation();
|
||||||
|
if (activeConv) {
|
||||||
|
this.fetchMessagesCallback(activeConv).catch(() => {});
|
||||||
|
}
|
||||||
|
this.fetchUnreadCountCallback().catch(() => {});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
wsService.onDisconnect(() => {
|
wsService.onDisconnect(() => {
|
||||||
@@ -206,6 +242,7 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
this.sseUnsubscribe();
|
this.sseUnsubscribe();
|
||||||
this.sseUnsubscribe = null;
|
this.sseUnsubscribe = null;
|
||||||
}
|
}
|
||||||
|
this.processedNotificationIds.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -510,19 +547,7 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private incrementUnreadCount(conversationId: string): void {
|
private incrementUnreadCount(conversationId: string): void {
|
||||||
const store = useMessageStore.getState();
|
useMessageStore.getState().incrementUnreadAtomic(conversationId);
|
||||||
const conversation = store.getConversation(conversationId);
|
|
||||||
if (conversation) {
|
|
||||||
const prevUnreadCount = conversation.unread_count || 0;
|
|
||||||
const updatedConv: ConversationResponse = {
|
|
||||||
...conversation,
|
|
||||||
unread_count: prevUnreadCount + 1,
|
|
||||||
};
|
|
||||||
store.updateConversation(updatedConv);
|
|
||||||
|
|
||||||
const currentUnread = store.getUnreadCount();
|
|
||||||
store.setUnreadCount(currentUnread.total + 1, currentUnread.system);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private incrementSystemUnread(): void {
|
private incrementSystemUnread(): void {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 消息服务层统一导出
|
* 消息服务层统一导出
|
||||||
*
|
|
||||||
* 重构说明:所有服务直接使用 zustand store,移除了 IMessageStateManager 接口依赖
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 消息去重服务
|
// 消息去重服务
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 消息状态 Zustand Store
|
* 消息状态 Zustand Store
|
||||||
* 使用 zustand 进行状态管理,提供响应式状态更新
|
* 使用 zustand 进行状态管理,提供响应式状态更新
|
||||||
*
|
|
||||||
* 重构说明:
|
|
||||||
* - 移除了 MessageStateManager 包装层
|
|
||||||
* - 直接使用 zustand store 进行状态管理
|
|
||||||
* - 移除了 SubscriptionManager,改用 Zustand selector 进行响应式订阅
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
@@ -78,12 +73,14 @@ export interface MessageActions {
|
|||||||
isNotificationMuted: (conversationId: string) => boolean;
|
isNotificationMuted: (conversationId: string) => boolean;
|
||||||
|
|
||||||
// 设置状态
|
// 设置状态
|
||||||
setConversations: (conversations: Map<string, ConversationResponse>) => void;
|
setConversationsWithUnread: (
|
||||||
|
conversations: Map<string, ConversationResponse>,
|
||||||
|
totalUnread: number,
|
||||||
|
systemUnread: number,
|
||||||
|
) => void;
|
||||||
updateConversation: (conversation: ConversationResponse) => void;
|
updateConversation: (conversation: ConversationResponse) => void;
|
||||||
removeConversation: (conversationId: string) => void;
|
removeConversation: (conversationId: string) => void;
|
||||||
setMessages: (conversationId: string, messages: MessageResponse[]) => void;
|
setMessages: (conversationId: string, messages: MessageResponse[]) => void;
|
||||||
addMessage: (conversationId: string, message: MessageResponse) => void;
|
|
||||||
updateMessage: (conversationId: string, messageId: string, updates: Partial<MessageResponse>) => void;
|
|
||||||
setUnreadCount: (total: number, system: number) => void;
|
setUnreadCount: (total: number, system: number) => void;
|
||||||
setLastSystemMessageAt: (time: string | null) => void;
|
setLastSystemMessageAt: (time: string | null) => void;
|
||||||
setSSEConnected: (connected: boolean) => void;
|
setSSEConnected: (connected: boolean) => void;
|
||||||
@@ -95,6 +92,14 @@ export interface MessageActions {
|
|||||||
setNotificationMuted: (conversationId: string, notificationMuted: boolean) => void;
|
setNotificationMuted: (conversationId: string, notificationMuted: boolean) => void;
|
||||||
setInitialized: (initialized: boolean) => void;
|
setInitialized: (initialized: boolean) => void;
|
||||||
|
|
||||||
|
// 原子性更新(避免中间渲染状态)
|
||||||
|
updateConversationWithUnread: (
|
||||||
|
conversation: ConversationResponse,
|
||||||
|
totalUnread: number,
|
||||||
|
systemUnread: number,
|
||||||
|
) => void;
|
||||||
|
incrementUnreadAtomic: (conversationId: string) => void;
|
||||||
|
|
||||||
// 重置
|
// 重置
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@@ -240,7 +245,11 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
|
|||||||
|
|
||||||
// ==================== 设置状态 ====================
|
// ==================== 设置状态 ====================
|
||||||
|
|
||||||
setConversations: (conversations: Map<string, ConversationResponse>) => {
|
setConversationsWithUnread: (
|
||||||
|
conversations: Map<string, ConversationResponse>,
|
||||||
|
totalUnread: number,
|
||||||
|
systemUnread: number,
|
||||||
|
) => {
|
||||||
const conversationList = sortConversationList(conversations);
|
const conversationList = sortConversationList(conversations);
|
||||||
const notificationMutedMap = new Map<string, boolean>();
|
const notificationMutedMap = new Map<string, boolean>();
|
||||||
conversations.forEach((conv, id) => {
|
conversations.forEach((conv, id) => {
|
||||||
@@ -252,6 +261,8 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
|
|||||||
conversations,
|
conversations,
|
||||||
conversationList,
|
conversationList,
|
||||||
notificationMutedMap: new Map([...state.notificationMutedMap, ...notificationMutedMap]),
|
notificationMutedMap: new Map([...state.notificationMutedMap, ...notificationMutedMap]),
|
||||||
|
totalUnreadCount: totalUnread,
|
||||||
|
systemUnreadCount: systemUnread,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -314,39 +325,6 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
addMessage: (conversationId: string, message: MessageResponse) => {
|
|
||||||
const normalizedId = normalizeConversationId(conversationId);
|
|
||||||
set(state => {
|
|
||||||
const existing = state.messagesMap.get(normalizedId) || [];
|
|
||||||
const exists = existing.some(m => String(m.id) === String(message.id));
|
|
||||||
|
|
||||||
if (exists) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = mergeMessagesById(existing, [message]);
|
|
||||||
const newMessagesMap = new Map(state.messagesMap);
|
|
||||||
newMessagesMap.set(normalizedId, updated);
|
|
||||||
return { messagesMap: newMessagesMap };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
updateMessage: (conversationId: string, messageId: string, updates: Partial<MessageResponse>) => {
|
|
||||||
const normalizedId = normalizeConversationId(conversationId);
|
|
||||||
set(state => {
|
|
||||||
const messages = state.messagesMap.get(normalizedId);
|
|
||||||
if (!messages) return state;
|
|
||||||
|
|
||||||
const updated = messages.map(m =>
|
|
||||||
String(m.id) === String(messageId) ? { ...m, ...updates } : m
|
|
||||||
);
|
|
||||||
|
|
||||||
const newMessagesMap = new Map(state.messagesMap);
|
|
||||||
newMessagesMap.set(normalizedId, updated);
|
|
||||||
return { messagesMap: newMessagesMap };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
setUnreadCount: (total: number, system: number) => {
|
setUnreadCount: (total: number, system: number) => {
|
||||||
set({ totalUnreadCount: total, systemUnreadCount: system });
|
set({ totalUnreadCount: total, systemUnreadCount: system });
|
||||||
},
|
},
|
||||||
@@ -413,6 +391,53 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
|
|||||||
set({ isInitialized: initialized });
|
set({ isInitialized: initialized });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateConversationWithUnread: (
|
||||||
|
conversation: ConversationResponse,
|
||||||
|
totalUnread: number,
|
||||||
|
systemUnread: number,
|
||||||
|
) => {
|
||||||
|
const id = normalizeConversationId(conversation.id);
|
||||||
|
set(state => {
|
||||||
|
const newConversations = new Map(state.conversations);
|
||||||
|
newConversations.set(id, { ...conversation, id });
|
||||||
|
const conversationList = sortConversationList(newConversations);
|
||||||
|
const newNotificationMutedMap = new Map(state.notificationMutedMap);
|
||||||
|
if (conversation.notification_muted) {
|
||||||
|
newNotificationMutedMap.set(id, true);
|
||||||
|
} else {
|
||||||
|
newNotificationMutedMap.delete(id);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
conversations: newConversations,
|
||||||
|
conversationList,
|
||||||
|
notificationMutedMap: newNotificationMutedMap,
|
||||||
|
totalUnreadCount: totalUnread,
|
||||||
|
systemUnreadCount: systemUnread,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
incrementUnreadAtomic: (conversationId: string) => {
|
||||||
|
const normalizedId = normalizeConversationId(conversationId);
|
||||||
|
set(state => {
|
||||||
|
const conversation = state.conversations.get(normalizedId);
|
||||||
|
if (!conversation) return state;
|
||||||
|
|
||||||
|
const newConversations = new Map(state.conversations);
|
||||||
|
newConversations.set(normalizedId, {
|
||||||
|
...conversation,
|
||||||
|
unread_count: (conversation.unread_count || 0) + 1,
|
||||||
|
});
|
||||||
|
const conversationList = sortConversationList(newConversations);
|
||||||
|
|
||||||
|
return {
|
||||||
|
conversations: newConversations,
|
||||||
|
conversationList,
|
||||||
|
totalUnreadCount: state.totalUnreadCount + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// ==================== 重置 ====================
|
// ==================== 重置 ====================
|
||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
|
|||||||
@@ -1,21 +1,6 @@
|
|||||||
import type { ConversationResponse, MessageResponse, UserDTO } from '../../types/dto';
|
import type { ConversationResponse, MessageResponse, UserDTO } from '../../types/dto';
|
||||||
import type { IConversationListPagedSource } from './sources';
|
import type { IConversationListPagedSource } from './sources';
|
||||||
|
|
||||||
export interface MessageManagerState {
|
|
||||||
conversations: Map<string, ConversationResponse>;
|
|
||||||
conversationList: ConversationResponse[];
|
|
||||||
messagesMap: Map<string, MessageResponse[]>;
|
|
||||||
totalUnreadCount: number;
|
|
||||||
systemUnreadCount: number;
|
|
||||||
isWSConnected: boolean;
|
|
||||||
currentConversationId: string | null;
|
|
||||||
isLoadingConversations: boolean;
|
|
||||||
loadingMessagesSet: Set<string>;
|
|
||||||
isInitialized: boolean;
|
|
||||||
typingUsersMap: Map<string, string[]>;
|
|
||||||
mutedStatusMap: Map<string, boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReadStateRecord {
|
export interface ReadStateRecord {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
version: number;
|
version: number;
|
||||||
@@ -68,7 +53,6 @@ export interface IConversationOperations {
|
|||||||
removeConversation(conversationId: string): void;
|
removeConversation(conversationId: string): void;
|
||||||
clearConversations(): void;
|
clearConversations(): void;
|
||||||
updateUnreadCount(conversationId: string, count: number): void;
|
updateUnreadCount(conversationId: string, count: number): void;
|
||||||
incrementUnreadCount(conversationId: string): void;
|
|
||||||
setSystemUnreadCount(count: number): void;
|
setSystemUnreadCount(count: number): void;
|
||||||
incrementSystemUnreadCount(): void;
|
incrementSystemUnreadCount(): void;
|
||||||
decrementSystemUnreadCount(count?: number): void;
|
decrementSystemUnreadCount(count?: number): void;
|
||||||
@@ -89,7 +73,9 @@ export interface IMessageSyncService {
|
|||||||
fetchMessages(conversationId: string, afterSeq?: number): Promise<void>;
|
fetchMessages(conversationId: string, afterSeq?: number): Promise<void>;
|
||||||
loadMoreMessages(conversationId: string, beforeSeq: number, limit?: number): Promise<MessageResponse[]>;
|
loadMoreMessages(conversationId: string, beforeSeq: number, limit?: number): Promise<MessageResponse[]>;
|
||||||
fetchUnreadCount(): Promise<void>;
|
fetchUnreadCount(): Promise<void>;
|
||||||
|
syncBySeq(): Promise<boolean>;
|
||||||
canLoadMoreConversations(): boolean;
|
canLoadMoreConversations(): boolean;
|
||||||
|
restartSources(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWSMessageHandler {
|
export interface IWSMessageHandler {
|
||||||
|
|||||||
Reference in New Issue
Block a user