Compare commits
16 Commits
feature/pu
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b589f1f32b | ||
|
|
995e0664d5 | ||
|
|
45df579b72 | ||
|
|
f9b0999112 | ||
|
|
d8386b5f76 | ||
|
|
be77a9d04c | ||
|
|
2bad59afbb | ||
|
|
761f315a60 | ||
|
|
488323377c | ||
|
|
705b365536 | ||
|
|
94a202506b | ||
|
|
c1f8acd4fc | ||
|
|
1018cd7ea2 | ||
|
|
ce0cb75248 | ||
|
|
9cba25da00 | ||
|
|
3c452cfbd3 |
@@ -36,6 +36,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -48,7 +50,7 @@ jobs:
|
||||
- name: Resolve runtime version
|
||||
id: runtime
|
||||
run: |
|
||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.runtimeVersion")"
|
||||
RUNTIME_VERSION="$(node -p "require('./app.config.js').runtimeVersion")"
|
||||
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
||||
|
||||
@@ -108,6 +110,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v4
|
||||
@@ -133,134 +137,65 @@ jobs:
|
||||
# Decode Android signing keystore
|
||||
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > app/withyou-release-key.keystore
|
||||
|
||||
# Update settings.gradle
|
||||
cat > settings.gradle << 'SETTINGS_EOF'
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven { url 'https://developer.huawei.com/repo/' }
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
def reactNativeGradlePlugin = new File(
|
||||
providers.exec {
|
||||
workingDir(rootDir)
|
||||
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
|
||||
}.standardOutput.asText.get().trim()
|
||||
).getParentFile().absolutePath
|
||||
includeBuild(reactNativeGradlePlugin)
|
||||
# Append signing properties to gradle.properties (secrets appended after prebuild for better cache hit rate).
|
||||
# IMPORTANT 1: each echo line writes a column-0 string — gradle.properties keys cannot have
|
||||
# leading whitespace, otherwise hasProperty() returns false and release signing breaks
|
||||
# (see app/build.gradle signingConfigs.release).
|
||||
# IMPORTANT 2: prebuild emits gradle.properties WITHOUT a trailing newline (last line is
|
||||
# `expo.inlineModules.watchedDirectories=[]`). A naive `>>` append concatenates the first
|
||||
# new key onto that last line, corrupting MYAPP_UPLOAD_STORE_FILE. We unconditionally
|
||||
# prepend a newline before the appended block to guarantee a clean line break.
|
||||
# NB: this block runs with `set -e`; if any echo fails the step aborts.
|
||||
{
|
||||
printf '\n'
|
||||
echo "MYAPP_UPLOAD_STORE_FILE=withyou-release-key.keystore"
|
||||
echo "MYAPP_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}"
|
||||
echo "MYAPP_UPLOAD_KEY_ALIAS=withyou-key"
|
||||
echo "MYAPP_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}"
|
||||
} >> gradle.properties
|
||||
|
||||
def expoPluginsPath = new File(
|
||||
providers.exec {
|
||||
workingDir(rootDir)
|
||||
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
|
||||
}.standardOutput.asText.get().trim(),
|
||||
"../android/expo-gradle-plugin"
|
||||
).absolutePath
|
||||
includeBuild(expoPluginsPath)
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.facebook.react.settings")
|
||||
id("expo-autolinking-settings")
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
maven { url 'https://developer.huawei.com/repo/' }
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url 'https://www.jitpack.io' }
|
||||
}
|
||||
}
|
||||
|
||||
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
||||
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
|
||||
ex.autolinkLibrariesFromCommand()
|
||||
} else {
|
||||
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
|
||||
}
|
||||
}
|
||||
expoAutolinking.useExpoModules()
|
||||
|
||||
rootProject.name = '威友'
|
||||
|
||||
expoAutolinking.useExpoVersionCatalog()
|
||||
|
||||
include ':app'
|
||||
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
||||
SETTINGS_EOF
|
||||
|
||||
# Update build.gradle
|
||||
cat > build.gradle << 'BUILD_EOF'
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
maven { url 'https://developer.huawei.com/repo/' }
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath('com.huawei.agconnect:agcp:1.9.1.301') {
|
||||
exclude group: 'com.huawei.agconnect', module: 'agconnect-apms-plugin'
|
||||
}
|
||||
classpath('com.google.gms:google-services:4.4.4')
|
||||
classpath('com.android.tools.build:gradle:8.12.0')
|
||||
classpath('com.facebook.react:react-native-gradle-plugin')
|
||||
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
maven { url 'https://developer.huawei.com/repo/' }
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url 'https://www.jitpack.io' }
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: "expo-root-project"
|
||||
apply plugin: "com.facebook.react.rootproject"
|
||||
BUILD_EOF
|
||||
|
||||
# Update gradle.properties (without secrets for better cache hit rate)
|
||||
cat > gradle.properties << 'PROPS_EOF'
|
||||
org.gradle.daemon=false
|
||||
org.gradle.parallel=false
|
||||
org.gradle.configureondemand=false
|
||||
org.gradle.workers.max=1
|
||||
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
kotlin.daemon.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m
|
||||
android.enableJetifier=false
|
||||
android.useAndroidX=true
|
||||
hermesEnabled=true
|
||||
reactNativeArchitectures=arm64-v8a
|
||||
newArchEnabled=true
|
||||
edgeToEdgeEnabled=true
|
||||
expo.gif.enabled=true
|
||||
expo.webp.enabled=true
|
||||
expo.webp.animated=false
|
||||
ndkVersion=27.1.12297006
|
||||
expo.useLegacyPackaging=false
|
||||
systemProp.org.gradle.internal.http.connectionTimeout=30000
|
||||
systemProp.org.gradle.internal.http.socketTimeout=30000
|
||||
PROPS_EOF
|
||||
|
||||
# Append signing properties (secrets appended, not cached)
|
||||
cat >> gradle.properties << SIGNING_PROPS
|
||||
MYAPP_UPLOAD_STORE_FILE=withyou-release-key.keystore
|
||||
MYAPP_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
MYAPP_UPLOAD_KEY_ALIAS=withyou-key
|
||||
MYAPP_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
SIGNING_PROPS
|
||||
# CI build tuning (low-memory container): force single-worker, reduce JVM heap,
|
||||
# plus restore properties that prebuild does NOT generate but the build needs.
|
||||
# printf '\n' below is defensive — even though the signing block above already
|
||||
# ensured a trailing newline, both blocks should be independently safe.
|
||||
{
|
||||
printf '\n'
|
||||
echo "org.gradle.daemon=false"
|
||||
echo "org.gradle.parallel=false"
|
||||
echo "org.gradle.configureondemand=false"
|
||||
echo "org.gradle.workers.max=1"
|
||||
echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError"
|
||||
echo "kotlin.daemon.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m"
|
||||
echo "ndkVersion=27.1.12297006"
|
||||
echo "systemProp.org.gradle.internal.http.connectionTimeout=30000"
|
||||
echo "systemProp.org.gradle.internal.http.socketTimeout=30000"
|
||||
# ExpoAutolinkingPlugin requires this; without it Gradle calls
|
||||
# `node ... mirror-kotlin-inline-modules --watched-directories-serialized ''`
|
||||
# and node JSON.parse('') throws SyntaxError, exiting 1.
|
||||
# See ExpoAutolinkingPlugin.kt:51 (findProperty fallback to emptyList)
|
||||
# and ExpoAutolinkingPlugin.kt:67 (passes value to node as-is).
|
||||
echo "expo.inlineModules.watchedDirectories=[]"
|
||||
} >> gradle.properties
|
||||
|
||||
# Verify signing config in app/build.gradle
|
||||
echo "=== app/build.gradle signing section ==="
|
||||
grep -A 20 'signingConfigs' app/build.gradle || echo "signingConfigs not found"
|
||||
|
||||
# Diagnostic: dump full gradle.properties + verify all 4 signing keys are at column 0.
|
||||
# Each grep -c MUST print 1; 0 means the key was concatenated onto a previous line
|
||||
# (typically because prebuild's gradle.properties had no trailing newline — see fix above).
|
||||
echo "=== gradle.properties (final) ==="
|
||||
cat gradle.properties
|
||||
echo "=== signing keys column-0 check (each must be 1) ==="
|
||||
for k in MYAPP_UPLOAD_STORE_FILE MYAPP_UPLOAD_STORE_PASSWORD MYAPP_UPLOAD_KEY_ALIAS MYAPP_UPLOAD_KEY_PASSWORD; do
|
||||
count=$(grep -c "^${k}=" gradle.properties || true)
|
||||
echo "${k}=${count}"
|
||||
if [ "${count}" != "1" ]; then
|
||||
echo "FATAL: ${k} not found at column 0 — release signing will fail."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Build Android release APK (arm64 only)
|
||||
run: |
|
||||
cd android
|
||||
@@ -276,7 +211,7 @@ jobs:
|
||||
- name: Resolve runtime version
|
||||
id: runtime
|
||||
run: |
|
||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.runtimeVersion")"
|
||||
RUNTIME_VERSION="$(node -p "require('./app.config.js').runtimeVersion")"
|
||||
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
||||
|
||||
|
||||
@@ -66,9 +66,10 @@ const filteredPlugins = isWeb
|
||||
module.exports = {
|
||||
...expo,
|
||||
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
||||
// runtimeVersion 用 build number(commit count + 偏移):单调递增、纯数字、与 version (语义版本) 解耦
|
||||
// 字符串形式,等价于 policy: 'custom'
|
||||
runtimeVersion: buildNumber,
|
||||
// runtimeVersion 用语义版本(expo.version):同一版本号的所有 build 共享 OTA 通道,
|
||||
// 改原生代码/配置后应升级 version 来切换通道。
|
||||
// versionCode / buildNumber 仍用 commit count + 偏移,保证应用商店版本号单调递增。
|
||||
runtimeVersion: appJson.expo.version,
|
||||
updates: {
|
||||
...(expo.updates || {}),
|
||||
url: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function ProfileStackLayout() {
|
||||
<Stack.Screen name="blocked-users" />
|
||||
<Stack.Screen name="chat-settings" />
|
||||
<Stack.Screen name="about" />
|
||||
<Stack.Screen name="help" />
|
||||
<Stack.Screen name="verification" />
|
||||
<Stack.Screen name="data-storage" />
|
||||
<Stack.Screen name="privacy-settings" />
|
||||
|
||||
5
app/(app)/(tabs)/profile/help.tsx
Normal file
5
app/(app)/(tabs)/profile/help.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { HelpScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function HelpRoute() {
|
||||
return <HelpScreen />;
|
||||
}
|
||||
@@ -128,6 +128,13 @@ function NotificationBootstrap() {
|
||||
console.log('[NotificationBootstrap] JPush notification:', message.notificationEventType, message);
|
||||
if (message.notificationEventType !== 'notificationOpened') return;
|
||||
|
||||
// 点击任意一条通知即清除通知栏所有通知(与 QQ 一致:点一条清全部)
|
||||
// JPush 的 notificationOpened 在通知被点击时触发,此时清除系统通知栏中
|
||||
// 本应用的所有通知,避免用户需要逐条点击消除。
|
||||
systemNotificationService.clearAllNotifications().catch((error) => {
|
||||
console.warn('[NotificationBootstrap] clearAllNotifications failed:', error);
|
||||
});
|
||||
|
||||
const extras = message.extras || {};
|
||||
const notifType = extras.notification_type || message.notificationType;
|
||||
|
||||
@@ -141,7 +148,10 @@ function NotificationBootstrap() {
|
||||
|
||||
if (conversationId) {
|
||||
const isGroup = conversationType === 'group';
|
||||
router.push(
|
||||
// 使用 navigate 而非 push,避免:
|
||||
// 1. 在已有聊天页上创建重复实例(用户需要退出两次)
|
||||
// 2. 从帖子等页面打开聊天通知后返回到帖子(而非消息列表)
|
||||
router.navigate(
|
||||
hrefs.hrefChat({
|
||||
conversationId,
|
||||
userId: isGroup ? undefined : senderId,
|
||||
@@ -152,10 +162,10 @@ function NotificationBootstrap() {
|
||||
})
|
||||
);
|
||||
} else {
|
||||
router.push(hrefs.hrefNotifications());
|
||||
router.navigate(hrefs.hrefNotifications());
|
||||
}
|
||||
} else {
|
||||
router.push(hrefs.hrefNotifications());
|
||||
router.navigate(hrefs.hrefNotifications());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
46
jest.config.js
Normal file
46
jest.config.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Jest 配置
|
||||
*
|
||||
* 仅对消息状态核心逻辑(store 幂等性 / 实时事件管道 / 未读计数原子性)做单测,
|
||||
* 不覆盖 RN / Expo UI。所有平台依赖(@/services/core、@/database、@/core/events、
|
||||
* @/stores/auth、@/services/message)在测试中通过 moduleNameMapper 映射到 mock。
|
||||
*/
|
||||
const { pathsToModuleNameMapper } = require('ts-jest');
|
||||
|
||||
const tsconfig = require('./tsconfig.json');
|
||||
const tsconfigBase = require('expo/tsconfig.base.json');
|
||||
|
||||
// 合并 base + 项目 paths
|
||||
const paths = {
|
||||
...(tsconfigBase.compilerOptions.paths || {}),
|
||||
...(tsconfig.compilerOptions.paths || {}),
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
moduleNameMapper: {
|
||||
// 平台依赖统一映射到 mock(必须放在 @/* 通配之前,否则会被通配吞掉)
|
||||
'^@/services/core$': '<rootDir>/src/stores/message/__tests__/mocks/coreMock.ts',
|
||||
'^@/services/message$': '<rootDir>/src/stores/message/__tests__/mocks/messageServiceMock.ts',
|
||||
'^@/database$': '<rootDir>/src/stores/message/__tests__/mocks/databaseMock.ts',
|
||||
'^@/core/events/EventBus$': '<rootDir>/src/stores/message/__tests__/mocks/eventBusMock.ts',
|
||||
'^@/stores/auth/authStore$': '<rootDir>/src/stores/message/__tests__/mocks/authStoreMock.ts',
|
||||
'^@react-native-async-storage/async-storage$':
|
||||
'<rootDir>/src/stores/message/__tests__/mocks/asyncStorageMock.ts',
|
||||
// @/* 路径别名 → 真实 src(通配兜底,放最后)
|
||||
...pathsToModuleNameMapper(paths, { prefix: '<rootDir>/' }),
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: '<rootDir>/src/stores/message/__tests__/tsconfig.json',
|
||||
isolatedModules: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
clearMocks: true,
|
||||
};
|
||||
2629
package-lock.json
generated
2629
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -11,10 +11,12 @@
|
||||
"ios:simulator": "eas build --platform ios --profile ios-simulator",
|
||||
"ios:preview": "eas build --platform ios --profile preview",
|
||||
"ios:prod": "eas build --platform ios --profile production",
|
||||
"ios:submit": "eas submit --platform ios --profile production"
|
||||
"ios:submit": "eas submit --platform ios --profile production",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/ui": "~56.0.17",
|
||||
"@expo/ui": "~56.0.18",
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@livekit/react-native": "^2.11.0",
|
||||
"@livekit/react-native-webrtc": "^144.1.0",
|
||||
@@ -24,31 +26,31 @@
|
||||
"@types/react": "~19.2.14",
|
||||
"axios": "^1.16.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"expo": "~56.0.11",
|
||||
"expo-background-task": "~56.0.18",
|
||||
"expo": "~56.0.12",
|
||||
"expo-background-task": "~56.0.19",
|
||||
"expo-callkit-telecom": "^0.3.9",
|
||||
"expo-camera": "~56.0.8",
|
||||
"expo-constants": "~56.0.18",
|
||||
"expo-dev-client": "~56.0.20",
|
||||
"expo-document-picker": "~56.0.4",
|
||||
"expo-file-system": "~56.0.8",
|
||||
"expo-font": "~56.0.6",
|
||||
"expo-font": "~56.0.7",
|
||||
"expo-haptics": "~56.0.3",
|
||||
"expo-image": "~56.0.6",
|
||||
"expo-image-picker": "~56.0.17",
|
||||
"expo-image-picker": "~56.0.18",
|
||||
"expo-intent-launcher": "~56.0.4",
|
||||
"expo-linear-gradient": "~56.0.4",
|
||||
"expo-linking": "~56.0.14",
|
||||
"expo-media-library": "~56.0.7",
|
||||
"expo-notifications": "~56.0.17",
|
||||
"expo-router": "~56.2.10",
|
||||
"expo-notifications": "~56.0.18",
|
||||
"expo-router": "~56.2.11",
|
||||
"expo-splash-screen": "~56.0.10",
|
||||
"expo-sqlite": "~56.0.5",
|
||||
"expo-status-bar": "~56.0.4",
|
||||
"expo-system-ui": "~56.0.5",
|
||||
"expo-task-manager": "~56.0.18",
|
||||
"expo-task-manager": "~56.0.19",
|
||||
"expo-updates": "~56.0.19",
|
||||
"expo-video": "~56.1.3",
|
||||
"expo-video": "~56.1.4",
|
||||
"jcore-react-native": "^2.3.6",
|
||||
"jpush-react-native": "^3.2.7",
|
||||
"katex": "^0.17.0",
|
||||
@@ -75,16 +77,24 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-native-community/cli": "^20.1.3",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/pako": "^2.0.4",
|
||||
"@types/react": "~19.2.16",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.2",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"private": true,
|
||||
"expo": {
|
||||
"install": {
|
||||
"exclude": [
|
||||
"expo-image"
|
||||
"expo-image",
|
||||
"@react-native-async-storage/async-storage",
|
||||
"@shopify/flash-list",
|
||||
"react-native-pager-view",
|
||||
"react-native-gesture-handler",
|
||||
"react-native-safe-area-context"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
const {
|
||||
withProjectBuildGradle,
|
||||
withSettingsGradle,
|
||||
withDangerousMod,
|
||||
} = require('@expo/config-plugins');
|
||||
const { withDangerousMod } = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
@@ -11,14 +7,15 @@ const path = require('path');
|
||||
// 客户端职责(依据极光官方文档 https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide):
|
||||
// 1) 添加 cn.jiguang.sdk.plugin:honor 依赖
|
||||
// 2) 注入 manifestPlaceholders: HONOR_APPID
|
||||
// 3) 添加荣耀 Maven 仓库 https://developer.hihonor.com/repo (v5.9.0+ 必需)
|
||||
// 3) 添加荣耀 Maven 仓库 https://developer.hihonor.com/repo 到 settings.gradle 和根 build.gradle
|
||||
// 关键: 必须同时加到 settings.gradle 的 dependencyResolutionManagement 块(CI build 走这条路径),
|
||||
// 和根 build.gradle 的 buildscript+allprojects(本地 build 兜底)。
|
||||
// 4) 添加 Proguard 规则 (com.hihonor.push.** 保留 + 5 条 -keepattributes)
|
||||
//
|
||||
// 实现说明:app/build.gradle 改动用 withDangerousMod 直接读写磁盘文件。
|
||||
// 原因:Expo SDK 56+ 的 withAppBuildGradle hook 链中,第二个以后的 plugin 写回
|
||||
// modResults.contents 会被静默忽略,导致多个 vendor plugin 只能写入第一个的修改。
|
||||
// 仓库(settings.gradle / build.gradle)改动用 withProjectBuildGradle / withSettingsGradle
|
||||
// 没有这个问题。
|
||||
// 实现说明:所有文件改动统一用 withDangerousMod 直接读写磁盘。
|
||||
// 原因:Expo SDK 56+ 的 withSettingsGradle / withProjectBuildGradle / withAppBuildGradle hook 链中,
|
||||
// 第二个以后的 plugin 写回 modResults.contents 会被静默忽略。withDangerousMod 走文件系统串行执行,
|
||||
// 多 vendor plugin 共存可靠。
|
||||
const withHonorPush = (config, options = {}) => {
|
||||
const {
|
||||
jpushVersion = '6.1.0',
|
||||
@@ -27,71 +24,17 @@ const withHonorPush = (config, options = {}) => {
|
||||
|
||||
const honorRepoUrl = 'https://developer.hihonor.com/repo';
|
||||
|
||||
// 1. settings.gradle: 添加荣耀 Maven 仓库
|
||||
config = withSettingsGradle(config, (config) => {
|
||||
let contents = config.modResults.contents;
|
||||
|
||||
if (!contents.includes('developer.hihonor.com/repo')) {
|
||||
const repoLine = ` maven { url '${honorRepoUrl}' }`;
|
||||
const hasRepositoriesInPM = /pluginManagement\s*\{[\s\S]*?repositories\s*\{/.test(contents);
|
||||
|
||||
if (hasRepositoriesInPM) {
|
||||
config.modResults.contents = contents.replace(
|
||||
/(pluginManagement\s*\{[\s\S]*?repositories\s*\{)/,
|
||||
`$1\n ${repoLine}`
|
||||
);
|
||||
} else if (contents.includes('pluginManagement')) {
|
||||
config.modResults.contents = contents.replace(
|
||||
/(pluginManagement\s*\{)/,
|
||||
`$1\n repositories {\n gradlePluginPortal()\n google()\n mavenCentral()\n ${repoLine}\n }`
|
||||
);
|
||||
} else {
|
||||
config.modResults.contents =
|
||||
`pluginManagement {\n repositories {\n ${repoLine}\n }\n}\n\n` +
|
||||
config.modResults.contents;
|
||||
}
|
||||
}
|
||||
|
||||
// Gradle 9+ layout
|
||||
if (
|
||||
!config.modResults.contents.match(
|
||||
/dependencyResolutionManagement[\s\S]*developer\.hihonor\.com\/repo/
|
||||
)
|
||||
) {
|
||||
const depMgmtPattern = /dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{/;
|
||||
if (depMgmtPattern.test(config.modResults.contents)) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
depMgmtPattern,
|
||||
`$&\n maven { url '${honorRepoUrl}' }`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// 2. 根 build.gradle: 添加荣耀 Maven 仓库到 buildscript.repositories
|
||||
config = withProjectBuildGradle(config, (config) => {
|
||||
let contents = config.modResults.contents;
|
||||
|
||||
if (!contents.includes('developer.hihonor.com/repo')) {
|
||||
const bsRepoPattern = /(buildscript\s*\{[\s\S]*?repositories\s*\{)/;
|
||||
if (bsRepoPattern.test(contents)) {
|
||||
contents = contents.replace(
|
||||
bsRepoPattern,
|
||||
`$1\n maven { url '${honorRepoUrl}' }`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
config.modResults.contents = contents;
|
||||
return config;
|
||||
});
|
||||
|
||||
// 3. app/build.gradle: 注入 AAR 依赖 + manifestPlaceholders (用 withDangerousMod 直接读文件)
|
||||
config = withDangerousMod(config, [
|
||||
'android',
|
||||
async (config) => {
|
||||
const settingsGradlePath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
'settings.gradle'
|
||||
);
|
||||
const rootBuildGradlePath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
'build.gradle'
|
||||
);
|
||||
const appBuildGradlePath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
'app',
|
||||
@@ -103,12 +46,99 @@ const withHonorPush = (config, options = {}) => {
|
||||
'proguard-rules.pro'
|
||||
);
|
||||
|
||||
// --- 3a. 处理 build.gradle ---
|
||||
// --- 1. settings.gradle: pluginManagement + dependencyResolutionManagement ---
|
||||
if (fs.existsSync(settingsGradlePath)) {
|
||||
let settings = fs.readFileSync(settingsGradlePath, 'utf-8');
|
||||
let settingsChanged = false;
|
||||
const repoLine = ` maven { url '${honorRepoUrl}' }`;
|
||||
|
||||
// pluginManagement.repositories
|
||||
if (!settings.match(/pluginManagement\s*\{[\s\S]*?developer\.hihonor\.com\/repo/)) {
|
||||
const hasRepositoriesInPM = /pluginManagement\s*\{[\s\S]*?repositories\s*\{/.test(settings);
|
||||
if (hasRepositoriesInPM) {
|
||||
settings = settings.replace(
|
||||
/(pluginManagement\s*\{[\s\S]*?repositories\s*\{)/,
|
||||
`$1\n ${repoLine}`
|
||||
);
|
||||
settingsChanged = true;
|
||||
} else if (settings.includes('pluginManagement')) {
|
||||
settings = settings.replace(
|
||||
/(pluginManagement\s*\{)/,
|
||||
`$1\n repositories {\n gradlePluginPortal()\n google()\n mavenCentral()\n ${repoLine}\n }`
|
||||
);
|
||||
settingsChanged = true;
|
||||
} else {
|
||||
settings =
|
||||
`pluginManagement {\n repositories {\n ${repoLine}\n }\n}\n\n` + settings;
|
||||
settingsChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// dependencyResolutionManagement (Gradle 7+, takes precedence over allprojects in CI)
|
||||
if (!settings.match(/dependencyResolutionManagement[\s\S]*developer\.hihonor\.com\/repo/)) {
|
||||
const depMgmtPattern = /dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{/;
|
||||
if (depMgmtPattern.test(settings)) {
|
||||
settings = settings.replace(
|
||||
depMgmtPattern,
|
||||
`$&\n maven { url '${honorRepoUrl}' }`
|
||||
);
|
||||
settingsChanged = true;
|
||||
} else {
|
||||
// Block doesn't exist — fallback: create with Huawei + Honor + standard repos.
|
||||
const block = `\ndependencyResolutionManagement {\n repositories {\n maven { url 'https://developer.huawei.com/repo/' }\n maven { url '${honorRepoUrl}' }\n google()\n mavenCentral()\n maven { url 'https://www.jitpack.io' }\n }\n}\n`;
|
||||
const pluginsBlockEnd = /(\nplugins\s*\{[\s\S]*?\n\})/;
|
||||
if (pluginsBlockEnd.test(settings)) {
|
||||
settings = settings.replace(pluginsBlockEnd, `$1\n${block}`);
|
||||
} else {
|
||||
settings = settings + block;
|
||||
}
|
||||
settingsChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (settingsChanged) {
|
||||
fs.writeFileSync(settingsGradlePath, settings);
|
||||
console.log('[withHonorPush] updated settings.gradle (pluginManagement + dependencyResolutionManagement)');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. 根 build.gradle: buildscript + allprojects repositories ---
|
||||
if (fs.existsSync(rootBuildGradlePath)) {
|
||||
let gradle = fs.readFileSync(rootBuildGradlePath, 'utf-8');
|
||||
let changed = false;
|
||||
const repoLine = ` maven { url '${honorRepoUrl}' }`;
|
||||
|
||||
if (!gradle.match(/buildscript\s*\{[\s\S]*?developer\.hihonor\.com\/repo/)) {
|
||||
const bsRepoPattern = /(buildscript\s*\{[\s\S]*?repositories\s*\{)/;
|
||||
if (bsRepoPattern.test(gradle)) {
|
||||
gradle = gradle.replace(bsRepoPattern, `$1\n${repoLine}`);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!gradle.match(/allprojects\s*\{[\s\S]*?developer\.hihonor\.com\/repo/)) {
|
||||
const allProjectsPattern = /(allprojects\s*\{[\s\S]*?repositories\s*\{)/;
|
||||
if (allProjectsPattern.test(gradle)) {
|
||||
gradle = gradle.replace(allProjectsPattern, `$1\n${repoLine}`);
|
||||
changed = true;
|
||||
} else {
|
||||
const allProjectsBlock = `\nallprojects {\n repositories {${repoLine}\n }\n}\n`;
|
||||
gradle = gradle + allProjectsBlock;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(rootBuildGradlePath, gradle);
|
||||
console.log('[withHonorPush] updated root build.gradle (buildscript + allprojects repos)');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. app/build.gradle: AAR 依赖 + manifestPlaceholders ---
|
||||
if (fs.existsSync(appBuildGradlePath)) {
|
||||
let gradle = fs.readFileSync(appBuildGradlePath, 'utf-8');
|
||||
let changed = false;
|
||||
|
||||
// 注入 cn.jiguang.sdk.plugin:honor 依赖
|
||||
if (!gradle.includes('cn.jiguang.sdk.plugin:honor')) {
|
||||
const depsPattern = /dependencies\s*\{/;
|
||||
if (depsPattern.test(gradle)) {
|
||||
@@ -120,7 +150,6 @@ const withHonorPush = (config, options = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 注入 manifestPlaceholders: HONOR_APPID
|
||||
const manifestPlaceholderRegex = /manifestPlaceholders\s*=\s*\[([\s\S]*?)\]/;
|
||||
const match = gradle.match(manifestPlaceholderRegex);
|
||||
|
||||
@@ -154,7 +183,6 @@ const withHonorPush = (config, options = {}) => {
|
||||
changed = true;
|
||||
}
|
||||
} else if (gradle.includes('REACT_NATIVE_RELEASE_LEVEL')) {
|
||||
// 没有 manifestPlaceholders block 时新建
|
||||
const entriesStr = Object.entries(requiredPlaceholders)
|
||||
.map(([k, v]) => `${k}: "${v}"`)
|
||||
.join(',\n ');
|
||||
@@ -176,7 +204,7 @@ const withHonorPush = (config, options = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3b. 处理 proguard-rules.pro ---
|
||||
// --- 4. proguard-rules.pro ---
|
||||
if (fs.existsSync(proguardFilePath)) {
|
||||
let proguard = fs.readFileSync(proguardFilePath, 'utf-8');
|
||||
if (!proguard.includes('-keep class com.hihonor.push.**')) {
|
||||
|
||||
@@ -42,14 +42,33 @@ const withHuaweiPush = (config, options = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure Huawei Maven repo in dependencyResolutionManagement (Gradle 9+)
|
||||
// Ensure Huawei Maven repo in dependencyResolutionManagement (Gradle 7+).
|
||||
// CI build paths require this block (Gradle dependencyResolutionManagement
|
||||
// takes precedence over allprojects.repositories when configured).
|
||||
// Create the block if it doesn't exist, otherwise inject Huawei into it.
|
||||
if (!config.modResults.contents.match(/dependencyResolutionManagement[\s\S]*developer\.huawei\.com\/repo/)) {
|
||||
const depMgmtPattern = /dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{/;
|
||||
if (depMgmtPattern.test(config.modResults.contents)) {
|
||||
// Block exists — append Huawei to its repositories
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
depMgmtPattern,
|
||||
`$&\n maven { url 'https://developer.huawei.com/repo/' }`
|
||||
);
|
||||
} else {
|
||||
// Block doesn't exist — create it after the `plugins { ... }` block
|
||||
// (or after pluginManagement if no plugins block) so it sits where
|
||||
// Gradle expects it.
|
||||
const block = `\ndependencyResolutionManagement {\n repositories {\n maven { url 'https://developer.huawei.com/repo/' }\n google()\n mavenCentral()\n maven { url 'https://www.jitpack.io' }\n }\n}\n`;
|
||||
const pluginsBlockEnd = /(\nplugins\s*\{[\s\S]*?\n\})/;
|
||||
if (pluginsBlockEnd.test(config.modResults.contents)) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
pluginsBlockEnd,
|
||||
`$1\n${block}`
|
||||
);
|
||||
} else {
|
||||
// Fallback: append to end
|
||||
config.modResults.contents = config.modResults.contents + block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +198,43 @@ const withHuaweiPush = (config, options = {}) => {
|
||||
config = withDangerousMod(config, [
|
||||
'android',
|
||||
async (config) => {
|
||||
// Ensure Huawei Maven repo is in allprojects.repositories (not just buildscript).
|
||||
// Runtime dependencies like com.huawei.hms:push must be resolved via allprojects,
|
||||
// and the earlier withProjectBuildGradle hook may be silently dropped in Expo SDK 56+.
|
||||
const rootBuildGradlePath = path.join(config.modRequest.platformProjectRoot, 'build.gradle');
|
||||
if (fs.existsSync(rootBuildGradlePath)) {
|
||||
let rootGradle = fs.readFileSync(rootBuildGradlePath, 'utf-8');
|
||||
const huaweiRepoLine = ` maven { url 'https://developer.huawei.com/repo/' }`;
|
||||
let rootChanged = false;
|
||||
|
||||
// buildscript.repositories
|
||||
if (!rootGradle.match(/buildscript\s*\{[\s\S]*?developer\.huawei\.com\/repo/)) {
|
||||
const bsPattern = /(buildscript\s*\{[\s\S]*?repositories\s*\{)/;
|
||||
if (bsPattern.test(rootGradle)) {
|
||||
rootGradle = rootGradle.replace(bsPattern, `$1\n${huaweiRepoLine}`);
|
||||
rootChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// allprojects.repositories (runtime deps resolution)
|
||||
if (!rootGradle.match(/allprojects\s*\{[\s\S]*?developer\.huawei\.com\/repo/)) {
|
||||
const allProjectsPattern = /(allprojects\s*\{[\s\S]*?repositories\s*\{)/;
|
||||
if (allProjectsPattern.test(rootGradle)) {
|
||||
rootGradle = rootGradle.replace(allProjectsPattern, `$1\n${huaweiRepoLine}`);
|
||||
rootChanged = true;
|
||||
} else {
|
||||
const allProjectsBlock = `\nallprojects {\n repositories {${huaweiRepoLine}\n }\n}\n`;
|
||||
rootGradle = rootGradle + allProjectsBlock;
|
||||
rootChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (rootChanged) {
|
||||
fs.writeFileSync(rootBuildGradlePath, rootGradle);
|
||||
console.log('[withHuaweiPush] ensured Huawei Maven repo in buildscript + allprojects');
|
||||
}
|
||||
}
|
||||
|
||||
const appDir = path.join(config.modRequest.platformProjectRoot, 'app');
|
||||
const srcFile = path.join(config.modRequest.projectRoot, 'plugins', 'agconnect-services.json');
|
||||
const destFile = path.join(appDir, 'agconnect-services.json');
|
||||
|
||||
@@ -40,8 +40,6 @@ function createCommentItemStyles(colors: AppColors) {
|
||||
paddingBottom: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
@@ -84,7 +82,7 @@ function createCommentItemStyles(colors: AppColors) {
|
||||
paddingVertical: 0,
|
||||
},
|
||||
authorBadge: {
|
||||
backgroundColor: colors.primary.main,
|
||||
backgroundColor: `${colors.primary.main}18`,
|
||||
},
|
||||
adminBadge: {
|
||||
backgroundColor: colors.error.main,
|
||||
@@ -94,11 +92,21 @@ function createCommentItemStyles(colors: AppColors) {
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
authorBadgeText: {
|
||||
color: colors.primary.main,
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '700',
|
||||
},
|
||||
smallBadgeText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: 9,
|
||||
fontWeight: '600',
|
||||
},
|
||||
smallAuthorBadgeText: {
|
||||
color: colors.primary.main,
|
||||
fontSize: 9,
|
||||
fontWeight: '700',
|
||||
},
|
||||
timeText: {
|
||||
fontSize: fontSizes.xs,
|
||||
flexShrink: 0,
|
||||
@@ -304,7 +312,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
if (isAuthor) {
|
||||
badges.push(
|
||||
<View key="author" style={[styles.badge, styles.authorBadge]}>
|
||||
<Text variant="caption" style={styles.badgeText}>楼主</Text>
|
||||
<Text variant="caption" style={styles.authorBadgeText}>楼主</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -460,7 +468,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
</Text>
|
||||
{replyAuthorId === commentAuthorId && (
|
||||
<View style={[styles.badge, styles.authorBadge, styles.smallBadge]}>
|
||||
<Text variant="caption" style={styles.smallBadgeText}>楼主</Text>
|
||||
<Text variant="caption" style={styles.smallAuthorBadgeText}>楼主</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* 显示回复引用:aaa 回复 bbb */}
|
||||
|
||||
115
src/components/business/SearchHeader.tsx
Normal file
115
src/components/business/SearchHeader.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* SearchHeader 搜索顶栏组件
|
||||
* 统一的「搜索框 + 取消按钮」顶栏,用于内嵌式搜索页面。
|
||||
*
|
||||
* - 内置 Android 硬件返回键拦截:存在 onCancel 时,按下返回键调用 onCancel
|
||||
* 而非退出 App(解决 Tab 根页面内嵌搜索时返回键直接退出的问题)。
|
||||
* - 样式与 HomeScreen 搜索页保持一致。
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, BackHandler, StyleProp, ViewStyle } from 'react-native';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import { Text } from '../common';
|
||||
import SearchBar from './SearchBar';
|
||||
|
||||
interface SearchHeaderProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSubmit: () => void;
|
||||
/** 取消回调:同时用于取消按钮与硬件返回键。必传以启用内嵌模式返回拦截。 */
|
||||
onCancel: () => void;
|
||||
placeholder?: string;
|
||||
/** 透传给 SearchBar 的 compact 模式 */
|
||||
compact?: boolean;
|
||||
/** 搜索框最大宽度(宽屏限宽) */
|
||||
searchBarMaxWidth?: number;
|
||||
/** 水平外边距,默认 spacing.md */
|
||||
horizontalPadding?: number;
|
||||
/** 顶部内边距,默认 spacing.sm */
|
||||
paddingTop?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
function createSearchHeaderStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
searchShell: {
|
||||
flex: 1,
|
||||
},
|
||||
cancelButton: {
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
cancelText: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const SearchHeader: React.FC<SearchHeaderProps> = ({
|
||||
value,
|
||||
onChangeText,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
placeholder,
|
||||
compact = false,
|
||||
searchBarMaxWidth,
|
||||
horizontalPadding = spacing.md,
|
||||
paddingTop = spacing.sm,
|
||||
style,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createSearchHeaderStyles(colors), [colors]);
|
||||
|
||||
// 内嵌模式:拦截 Android 物理返回键,调用 onCancel 而非退出 App
|
||||
useEffect(() => {
|
||||
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
onCancel();
|
||||
return true; // 阻止默认行为(退出 App)
|
||||
});
|
||||
return () => backHandler.remove();
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
paddingTop,
|
||||
paddingHorizontal: horizontalPadding,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<View style={[styles.searchShell, searchBarMaxWidth != null && { maxWidth: searchBarMaxWidth }]}>
|
||||
<SearchBar
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onSubmit={onSubmit}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
compact={compact}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.cancelButton, { marginLeft: spacing.sm }]}
|
||||
onPress={onCancel}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text variant="body" color={colors.primary.main} style={styles.cancelText}>
|
||||
取消
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchHeader;
|
||||
@@ -10,7 +10,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import Text from '../common/Text';
|
||||
|
||||
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern';
|
||||
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern' | 'home';
|
||||
|
||||
interface TabBarProps {
|
||||
tabs: string[];
|
||||
@@ -20,6 +20,8 @@ interface TabBarProps {
|
||||
rightContent?: ReactNode;
|
||||
variant?: TabBarVariant;
|
||||
icons?: string[];
|
||||
/** home 变体的字号,默认 18(HomeScreen 可传 20) */
|
||||
fontSize?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
@@ -176,6 +178,33 @@ function createTabBarStyles(colors: AppColors) {
|
||||
borderTopLeftRadius: borderRadius.sm,
|
||||
borderTopRightRadius: borderRadius.sm,
|
||||
},
|
||||
// home 变体:与 HomeScreen/SearchScreen 一致的文字下划线风格
|
||||
homeContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
homeTab: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
marginRight: 20,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: 'transparent',
|
||||
},
|
||||
homeTabActive: {
|
||||
borderBottomColor: colors.text.primary,
|
||||
},
|
||||
homeTabText: {
|
||||
fontSize: fontSizes.xl,
|
||||
fontWeight: '400',
|
||||
color: colors.text.hint,
|
||||
},
|
||||
homeTabTextActive: {
|
||||
fontSize: fontSizes.xl,
|
||||
fontWeight: '700',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -187,6 +216,7 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
rightContent,
|
||||
variant = 'default',
|
||||
icons,
|
||||
fontSize,
|
||||
style,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
@@ -278,6 +308,29 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'home') {
|
||||
const homeTextStyle = [
|
||||
isActive ? styles.homeTabTextActive : styles.homeTabText,
|
||||
fontSize != null ? { fontSize } : undefined,
|
||||
];
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[styles.homeTab, isActive && styles.homeTabActive]}
|
||||
onPress={() => onTabChange(index)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={isActive ? colors.text.primary : colors.text.hint}
|
||||
style={homeTextStyle}
|
||||
>
|
||||
{tab}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
@@ -306,6 +359,8 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
return styles.segmentedContainer;
|
||||
case 'modern':
|
||||
return styles.modernContainer;
|
||||
case 'home':
|
||||
return styles.homeContainer;
|
||||
default:
|
||||
return styles.container;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export { default as CommentItem } from './CommentItem';
|
||||
export { default as UserProfileHeader } from './UserProfileHeader';
|
||||
export { default as SystemMessageItem } from './SystemMessageItem';
|
||||
export { default as SearchBar } from './SearchBar';
|
||||
export { default as SearchHeader } from './SearchHeader';
|
||||
export { default as TabBar } from './TabBar';
|
||||
export { default as VoteCard } from './VoteCard';
|
||||
export { default as VoteEditor } from './VoteEditor';
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface Message {
|
||||
seq: number;
|
||||
segments: MessageSegment[];
|
||||
createdAt: string;
|
||||
status: 'normal' | 'recalled' | 'deleted';
|
||||
status: 'normal' | 'pending' | 'failed' | 'recalled' | 'deleted';
|
||||
category?: string;
|
||||
sender?: {
|
||||
id: string;
|
||||
|
||||
@@ -5,9 +5,7 @@ export type AppEvent =
|
||||
| { 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 } };
|
||||
| { type: 'AUTH_LOGOUT' };
|
||||
|
||||
class EventBusClass {
|
||||
private subscribers: Set<Subscriber<AppEvent>> = new Set();
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
/**
|
||||
* WSClient - WebSocket连接管理
|
||||
* 只负责WebSocket连接管理,提供事件驱动架构
|
||||
*/
|
||||
|
||||
import {
|
||||
wsService,
|
||||
WSChatMessage,
|
||||
WSGroupChatMessage,
|
||||
WSReadMessage,
|
||||
WSGroupReadMessage,
|
||||
WSRecallMessage,
|
||||
WSGroupRecallMessage,
|
||||
WSGroupTypingMessage,
|
||||
WSGroupNoticeMessage,
|
||||
WSMessageType,
|
||||
} from '@/services/core';
|
||||
|
||||
// 事件处理器类型
|
||||
type EventHandler<T> = (data: T) => void;
|
||||
|
||||
// WS事件类型
|
||||
export interface WSEvents {
|
||||
'chat': WSChatMessage;
|
||||
'group_message': WSGroupChatMessage;
|
||||
'read': WSReadMessage;
|
||||
'group_read': WSGroupReadMessage;
|
||||
'recall': WSRecallMessage;
|
||||
'group_recall': WSGroupRecallMessage;
|
||||
'group_typing': WSGroupTypingMessage;
|
||||
'group_notice': WSGroupNoticeMessage;
|
||||
'connected': void;
|
||||
'disconnected': void;
|
||||
'error': Error;
|
||||
}
|
||||
|
||||
export type WSEventType = keyof WSEvents;
|
||||
|
||||
class WSClient {
|
||||
private handlers: Map<WSEventType, Set<EventHandler<any>>> = new Map();
|
||||
private unsubscribeFns: Array<() => void> = [];
|
||||
private isInitialized = false;
|
||||
|
||||
/**
|
||||
* 初始化WebSocket监听
|
||||
*/
|
||||
initialize(): void {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
// 监听私聊消息
|
||||
const unsubChat = wsService.on('chat', (message) => {
|
||||
this.emit('chat', message);
|
||||
});
|
||||
|
||||
// 监听群聊消息
|
||||
const unsubGroupMessage = wsService.on('group_message', (message) => {
|
||||
this.emit('group_message', message);
|
||||
});
|
||||
|
||||
// 监听私聊已读回执
|
||||
const unsubRead = wsService.on('read', (message) => {
|
||||
this.emit('read', message);
|
||||
});
|
||||
|
||||
// 监听群聊已读回执
|
||||
const unsubGroupRead = wsService.on('group_read', (message) => {
|
||||
this.emit('group_read', message);
|
||||
});
|
||||
|
||||
// 监听私聊消息撤回
|
||||
const unsubRecall = wsService.on('recall', (message) => {
|
||||
this.emit('recall', message);
|
||||
});
|
||||
|
||||
// 监听群聊消息撤回
|
||||
const unsubGroupRecall = wsService.on('group_recall', (message) => {
|
||||
this.emit('group_recall', message);
|
||||
});
|
||||
|
||||
// 监听群聊输入状态
|
||||
const unsubGroupTyping = wsService.on('group_typing', (message) => {
|
||||
this.emit('group_typing', message);
|
||||
});
|
||||
|
||||
// 监听群通知
|
||||
const unsubGroupNotice = wsService.on('group_notice', (message) => {
|
||||
this.emit('group_notice', message);
|
||||
});
|
||||
|
||||
// 监听连接状态
|
||||
const unsubConnect = wsService.onConnect(() => {
|
||||
this.emit('connected', undefined);
|
||||
});
|
||||
|
||||
const unsubDisconnect = wsService.onDisconnect(() => {
|
||||
this.emit('disconnected', undefined);
|
||||
});
|
||||
|
||||
this.unsubscribeFns = [
|
||||
unsubChat,
|
||||
unsubGroupMessage,
|
||||
unsubRead,
|
||||
unsubGroupRead,
|
||||
unsubRecall,
|
||||
unsubGroupRecall,
|
||||
unsubGroupTyping,
|
||||
unsubGroupNotice,
|
||||
unsubConnect,
|
||||
unsubDisconnect,
|
||||
];
|
||||
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁WebSocket监听
|
||||
*/
|
||||
destroy(): void {
|
||||
this.unsubscribeFns.forEach((fn) => fn());
|
||||
this.unsubscribeFns = [];
|
||||
this.handlers.clear();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅事件
|
||||
*/
|
||||
on<T extends WSEventType>(
|
||||
event: T,
|
||||
handler: EventHandler<WSEvents[T]>
|
||||
): () => void {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, new Set());
|
||||
}
|
||||
this.handlers.get(event)!.add(handler);
|
||||
|
||||
return () => {
|
||||
this.handlers.get(event)?.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件
|
||||
*/
|
||||
private emit<T extends WSEventType>(
|
||||
event: T,
|
||||
data: WSEvents[T]
|
||||
): void {
|
||||
const handlers = this.handlers.get(event);
|
||||
if (handlers) {
|
||||
handlers.forEach((handler) => {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (error) {
|
||||
console.error(`[WSClient] 事件处理器执行失败: ${event}`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查连接状态
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return wsService.isConnected();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动WebSocket连接
|
||||
*/
|
||||
async connect(): Promise<boolean> {
|
||||
return wsService.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开WebSocket连接
|
||||
*/
|
||||
disconnect(): void {
|
||||
wsService.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export const wsClient = new WSClient();
|
||||
export default wsClient;
|
||||
@@ -9,6 +9,16 @@ export interface IMessageRepository {
|
||||
getByConversation(conversationId: string, limit?: number): Promise<CachedMessage[]>;
|
||||
getMaxSeq(conversationId: string): Promise<number>;
|
||||
getMinSeq(conversationId: string): Promise<number>;
|
||||
/**
|
||||
* 返回本地某会话从最大 seq 往下的「连续区间」。
|
||||
* - maxSeq: 该会话本地最大的 seq(0 表示无消息)
|
||||
* - minSeq: 从 maxSeq 往下连续递减遇到第一个缺口时的下沿(即连续段的最早 seq)
|
||||
*
|
||||
* 用于 hydration 管线判断「最新端往前是否存在历史缺口」:
|
||||
* 若 minSeq > 1,说明 [minSeq-1, ...] 更早的消息本地缺失,需要回填。
|
||||
* 应用层遍历而非 SQL 窗口函数,保证跨 SQLite 版本兼容、易测试。
|
||||
*/
|
||||
getContiguousRange(conversationId: string): Promise<{ maxSeq: number; minSeq: number }>;
|
||||
getBeforeSeq(conversationId: string, beforeSeq: number, limit?: number): Promise<CachedMessage[]>;
|
||||
getCount(conversationId: string): Promise<number>;
|
||||
getCountBeforeSeq(conversationId: string, beforeSeq: number): Promise<number>;
|
||||
@@ -17,6 +27,12 @@ export interface IMessageRepository {
|
||||
delete(messageId: string): Promise<void>;
|
||||
updateStatus(messageId: string, status: string, clearContent?: boolean): Promise<void>;
|
||||
clearConversation(conversationId: string): Promise<void>;
|
||||
/**
|
||||
* 查询所有待发送(pending)或发送失败(failed)的消息(Outbox 恢复用)。
|
||||
* 按 createdAt 升序,保证恢复重试时按发送顺序处理。
|
||||
* 用于 App 启动时扫描本地未完成发送的消息,读入 store 并触发重试。
|
||||
*/
|
||||
getPendingOrFailed(): Promise<CachedMessage[]>;
|
||||
search(keyword: string): Promise<CachedMessage[]>;
|
||||
searchByConversation(conversationId: string, keyword: string, limit?: number, offset?: number): Promise<CachedMessage[]>;
|
||||
getStats(): Promise<{ totalMessages: number; totalConversations: number }>;
|
||||
@@ -79,6 +95,27 @@ export class MessageRepository implements IMessageRepository {
|
||||
return r?.minSeq || 0;
|
||||
}
|
||||
|
||||
async getContiguousRange(conversationId: string): Promise<{ maxSeq: number; minSeq: number }> {
|
||||
// 单次查询拉出该会话所有有效 seq,按 DESC 排序;应用层从最大值向下找第一个缺口。
|
||||
// 会话消息量通常可控,且仅对未 hydrated 的会话执行一次,开销可接受。
|
||||
const rows = await this.dataSource.query<{ seq: number }>(
|
||||
`SELECT seq FROM messages WHERE conversationId = ? AND seq > 0 ORDER BY seq DESC`,
|
||||
[conversationId]
|
||||
);
|
||||
if (rows.length === 0) return { maxSeq: 0, minSeq: 0 };
|
||||
|
||||
let maxSeq = rows[0].seq;
|
||||
let minSeq = maxSeq;
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
if (rows[i].seq === minSeq - 1) {
|
||||
minSeq = rows[i].seq; // 连续,向下扩展
|
||||
} else {
|
||||
break; // 遇到缺口,停止
|
||||
}
|
||||
}
|
||||
return { maxSeq, minSeq };
|
||||
}
|
||||
|
||||
async getBeforeSeq(conversationId: string, beforeSeq: number, limit: number = 20): Promise<CachedMessage[]> {
|
||||
const rows = await this.dataSource.query<any>(
|
||||
`SELECT * FROM messages WHERE conversationId = ? AND seq < ? ORDER BY seq DESC LIMIT ?`,
|
||||
@@ -87,6 +124,19 @@ export class MessageRepository implements IMessageRepository {
|
||||
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined })).reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按消息 ID 获取单条缓存消息(用于引用预览的离线命中优先查询)。
|
||||
* 不限定 conversationId——引用消息可能来自任意会话,按主键 id 直接查即可。
|
||||
*/
|
||||
async getById(messageId: string): Promise<CachedMessage | null> {
|
||||
const r = await this.dataSource.getFirst<any>(
|
||||
`SELECT * FROM messages WHERE id = ?`,
|
||||
[messageId]
|
||||
);
|
||||
if (!r) return null;
|
||||
return { ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined };
|
||||
}
|
||||
|
||||
async getCount(conversationId: string): Promise<number> {
|
||||
const r = await this.dataSource.getFirst<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ?`, [conversationId]
|
||||
@@ -138,6 +188,18 @@ export class MessageRepository implements IMessageRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async getPendingOrFailed(): Promise<CachedMessage[]> {
|
||||
const rows = await this.dataSource.query<any>(
|
||||
`SELECT * FROM messages WHERE status IN ('pending', 'failed') ORDER BY createdAt ASC`,
|
||||
[]
|
||||
);
|
||||
return rows.map(r => ({
|
||||
...r,
|
||||
isRead: r.isRead === 1,
|
||||
segments: r.segments ? JSON.parse(r.segments) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async search(keyword: string): Promise<CachedMessage[]> {
|
||||
const rows = await this.dataSource.query<any>(
|
||||
`SELECT * FROM messages WHERE content LIKE ? ORDER BY createdAt DESC LIMIT 50`, [`%${keyword}%`]
|
||||
|
||||
@@ -23,6 +23,42 @@ import {
|
||||
CREATE_POSTS_CACHE_TABLE,
|
||||
} from './PostSchema';
|
||||
|
||||
/**
|
||||
* 迁移版本号管理(对标 expo-sqlite 官方推荐用 PRAGMA user_version)。
|
||||
*
|
||||
* 设计:
|
||||
* - version 0:基线(建表 + 建索引),所有老库都已执行过(靠 CREATE IF NOT EXISTS 幂等)
|
||||
* - version 1:列级迁移(补 seq/status/segments/lastSeq 列),即原 MIGRATE_* 函数
|
||||
*
|
||||
* 向后兼容:老库 user_version 默认为 0,会重新跑 version 1 的列迁移,
|
||||
* 但 MIGRATE_* 靠 PRAGMA table_info 列检查幂等,重复执行无副作用。
|
||||
*
|
||||
* 扩展方式(未来破坏性迁移):在 MIGRATIONS 注册表新增 version 2/3/...,
|
||||
* 每个 step 靠 transaction 包裹保证原子性(失败回滚,不污染 user_version)。
|
||||
*/
|
||||
export const CURRENT_SCHEMA_VERSION = 1;
|
||||
|
||||
/** 读取当前 user_version */
|
||||
async function getUserVersion(db: SQLiteDatabase): Promise<number> {
|
||||
const result = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
|
||||
return result?.user_version ?? 0;
|
||||
}
|
||||
|
||||
/** 设置 user_version */
|
||||
async function setUserVersion(db: SQLiteDatabase, version: number): Promise<void> {
|
||||
await execWithRetry(db, `PRAGMA user_version = ${version};`);
|
||||
}
|
||||
|
||||
/** 版本化迁移注册表:key = 目标版本,value = 该版本要执行的迁移逻辑 */
|
||||
const MIGRATIONS: Map<number, (db: SQLiteDatabase) => Promise<void>> = new Map([
|
||||
// version 1:列级迁移(补 seq/status/segments/lastSeq 列)
|
||||
// 历史背景:最初表结构不含这些列,靠 MIGRATE_* 增量 ALTER。现在纳入版本管理。
|
||||
[1, async (db: SQLiteDatabase) => {
|
||||
await MIGRATE_MESSAGES_TABLE(db);
|
||||
await MIGRATE_CONVERSATIONS_TABLE(db);
|
||||
}],
|
||||
]);
|
||||
|
||||
async function execWithRetry(db: SQLiteDatabase, sql: string, maxRetries = 5): Promise<void> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
@@ -41,9 +77,19 @@ async function execWithRetry(db: SQLiteDatabase, sql: string, maxRetries = 5): P
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行数据库迁移。
|
||||
*
|
||||
* 流程:
|
||||
* 1. 设置 busy_timeout
|
||||
* 2. 基线建表 + 建索引(version 0,CREATE IF NOT EXISTS 幂等,每次都执行以保证新表存在)
|
||||
* 3. 按 user_version 顺序执行版本化迁移,每步成功后递增 user_version
|
||||
*/
|
||||
export async function runMigrations(db: SQLiteDatabase): Promise<void> {
|
||||
await execWithRetry(db, 'PRAGMA busy_timeout = 10000;');
|
||||
|
||||
// ---- 基线:version 0(建表 + 建索引)----
|
||||
// 保留每次执行,确保新库/损坏恢复时表结构就位;对老库靠 IF NOT EXISTS 幂等。
|
||||
await execWithRetry(db, CREATE_MESSAGES_TABLE);
|
||||
await execWithRetry(db, CREATE_CONVERSATIONS_TABLE);
|
||||
await execWithRetry(db, CREATE_CONVERSATION_CACHE_TABLE);
|
||||
@@ -61,6 +107,32 @@ export async function runMigrations(db: SQLiteDatabase): Promise<void> {
|
||||
await execWithRetry(db, indexSql);
|
||||
}
|
||||
|
||||
await MIGRATE_MESSAGES_TABLE(db);
|
||||
await MIGRATE_CONVERSATIONS_TABLE(db);
|
||||
// ---- 版本化迁移:version 1 ~ CURRENT_SCHEMA_VERSION ----
|
||||
const currentVersion = await getUserVersion(db);
|
||||
|
||||
// 当前版本已是最新(含未来更高版本),跳过
|
||||
if (currentVersion >= CURRENT_SCHEMA_VERSION) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 从 currentVersion+1 开始,逐版本执行迁移
|
||||
for (let target = currentVersion + 1; target <= CURRENT_SCHEMA_VERSION; target++) {
|
||||
const migration = MIGRATIONS.get(target);
|
||||
if (!migration) {
|
||||
console.warn(`[MigrationManager] 未找到 version ${target} 的迁移逻辑,跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await migration(db);
|
||||
// 每个版本迁移成功后立即持久化 user_version,
|
||||
// 即使后续版本失败,已完成的版本不会重复执行
|
||||
await setUserVersion(db, target);
|
||||
} catch (error) {
|
||||
console.error(`[MigrationManager] 迁移到 version ${target} 失败:`, error);
|
||||
// 不抛出致命错误以避免阻塞 App 启动;降级运行(表可能缺列,MIGRATE_* 内部会兜底)
|
||||
// 但记录版本未推进,下次启动会重试该版本
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,10 +76,18 @@ export function hrefProfileBookmarks(): string {
|
||||
return '/profile/bookmarks';
|
||||
}
|
||||
|
||||
export function hrefCreatePost(mode?: 'create' | 'edit', postId?: string): string {
|
||||
export function hrefCreatePost(
|
||||
mode?: 'create' | 'edit',
|
||||
postId?: string,
|
||||
postMode?: 'moment' | 'long',
|
||||
): string {
|
||||
if (mode === 'edit' && postId) {
|
||||
return `/posts/create?mode=edit&postId=${encodeURIComponent(postId)}`;
|
||||
}
|
||||
// 新建时可指定入口模式:moment=瞬间(普通),long=长文(含投票)。
|
||||
if (postMode) {
|
||||
return `/posts/create?postMode=${postMode}`;
|
||||
}
|
||||
return '/posts/create';
|
||||
}
|
||||
|
||||
@@ -202,6 +210,10 @@ export function hrefProfileAbout(): string {
|
||||
return '/profile/about';
|
||||
}
|
||||
|
||||
export function hrefProfileHelp(): string {
|
||||
return '/profile/help';
|
||||
}
|
||||
|
||||
export function hrefProfileTerms(): string {
|
||||
return '/terms';
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
140
src/screens/create/composers/ChannelPicker.tsx
Normal file
140
src/screens/create/composers/ChannelPicker.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 频道选择(瞬间 / 长文共用)
|
||||
* 包含「发表至」入口行 + 展开后的频道单选面板。
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Text } from '../../../components/common';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../../theme';
|
||||
|
||||
export type ChannelOption = { id: string; name: string };
|
||||
|
||||
interface ChannelPickerProps {
|
||||
channels: ChannelOption[];
|
||||
selectedId: string | null;
|
||||
onSelect: (channelId: string) => void;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function ChannelPicker({
|
||||
channels,
|
||||
selectedId,
|
||||
onSelect,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: ChannelPickerProps) {
|
||||
const colors = useAppColors();
|
||||
const styles = React.useMemo(() => createChannelPickerStyles(colors), [colors]);
|
||||
|
||||
const selectedName = React.useMemo(() => {
|
||||
if (!selectedId) return '';
|
||||
return channels.find(c => c.id === selectedId)?.name || '';
|
||||
}, [selectedId, channels]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 入口行 */}
|
||||
<TouchableOpacity style={styles.channelEntryRow} onPress={onToggle} activeOpacity={0.8}>
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.channelEntryLabel}>
|
||||
发表至:
|
||||
</Text>
|
||||
<View style={styles.channelEntryValueWrap}>
|
||||
<Text
|
||||
variant="body"
|
||||
color={selectedName ? colors.text.primary : colors.text.hint}
|
||||
style={styles.channelEntryValue}
|
||||
>
|
||||
{selectedName || '选择频道'}
|
||||
</Text>
|
||||
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.text.hint} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 展开面板 */}
|
||||
{expanded && (
|
||||
<View style={styles.tagsSection}>
|
||||
<View style={styles.tagsContainer}>
|
||||
{channels.map((channel) => {
|
||||
const isSelected = selectedId === channel.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={channel.id}
|
||||
style={[styles.tag, isSelected && styles.channelSelected]}
|
||||
onPress={() => onSelect(channel.id)}
|
||||
>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={isSelected ? colors.primary.contrast : colors.text.secondary}
|
||||
style={styles.tagText}
|
||||
>
|
||||
{channel.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{channels.length === 0 && (
|
||||
<Text variant="caption" color={colors.text.hint}>暂无可用频道</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function createChannelPickerStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
channelEntryRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.divider,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
channelEntryLabel: {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
channelEntryValueWrap: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
channelEntryValue: {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
tagsSection: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.divider,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
tag: {
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
channelSelected: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
tagText: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
}
|
||||
121
src/screens/create/composers/EmojiPanel.tsx
Normal file
121
src/screens/create/composers/EmojiPanel.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 表情面板(瞬间 / 长文共用)
|
||||
* 从 CreatePostScreen 抽出,数据 + FlatList 渲染整体迁入。
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, FlatList, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { Text } from '../../../components/common';
|
||||
import { spacing, useAppColors, type AppColors } from '../../../theme';
|
||||
|
||||
// 常用表情列表
|
||||
const EMOJIS = [
|
||||
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂',
|
||||
'🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩',
|
||||
'😘', '😗', '😚', '😙', '🥲', '😋', '😛', '😜',
|
||||
'🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐',
|
||||
'🤨', '😐', '😑', '😶', '😏', '😒', '🙄',
|
||||
'😬', '🤥', '😌', '😔', '😪', '🤤', '😴', '😷',
|
||||
'🤒', '🤕', '🤢', '🤮', '🤧', '🥵', '🥶', '🥴',
|
||||
'😵', '🤯', '🤠', '🥳', '🥸', '😎', '🤓',
|
||||
'🧐', '😕', '😟', '🙁', '☹️', '😮', '😯', '😲',
|
||||
'😳', '🥺', '😦', '😧', '😨', '😰', '😥', '😢',
|
||||
'😭', '😱', '😖', '😣', '😞', '😓', '😩', '😫',
|
||||
'🥱', '😤', '😡', '😠', '🤬', '😈', '👿', '💀',
|
||||
'👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤌', '🤏',
|
||||
'✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆',
|
||||
'👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌',
|
||||
'👐', '🤲', '🤝', '🙏', '✍️', '💪', '🦾', '🦵',
|
||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍',
|
||||
'🤎', '💔', '🩹', '💕', '💞', '💓', '💗', '💖',
|
||||
'💘', '💝', '🎉', '🎊', '🎁', '🎈', '✨', '🔥',
|
||||
'💯', '💢', '💥', '💫', '💦', '💨', '🕳️',
|
||||
];
|
||||
|
||||
const EMOJIS_PER_ROW = 8;
|
||||
const EMOJI_ROW_HEIGHT = 52;
|
||||
|
||||
const EMOJI_ROWS = (() => {
|
||||
const rows: { id: string; emojis: string[] }[] = [];
|
||||
for (let i = 0; i < EMOJIS.length; i += EMOJIS_PER_ROW) {
|
||||
rows.push({ id: `emoji-row-${i}`, emojis: EMOJIS.slice(i, i + EMOJIS_PER_ROW) });
|
||||
}
|
||||
return rows;
|
||||
})();
|
||||
|
||||
interface EmojiPanelProps {
|
||||
visible: boolean;
|
||||
onPick: (emoji: string) => void;
|
||||
isWideScreen?: boolean;
|
||||
}
|
||||
|
||||
export function EmojiPanel({ visible, onPick, isWideScreen }: EmojiPanelProps) {
|
||||
const colors = useAppColors();
|
||||
const styles = React.useMemo(() => createEmojiPanelStyles(colors), [colors]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<View style={[styles.emojiPanel, isWideScreen && styles.emojiPanelWide]}>
|
||||
<FlatList
|
||||
data={EMOJI_ROWS}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.emojiRow}>
|
||||
{item.emojis.map((emoji, index) => (
|
||||
<TouchableOpacity
|
||||
key={`${item.id}-${index}`}
|
||||
style={styles.emojiItem}
|
||||
onPress={() => onPick(emoji)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.emojiText}>{emoji}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
initialNumToRender={8}
|
||||
maxToRenderPerBatch={8}
|
||||
windowSize={5}
|
||||
getItemLayout={(_data, index) => ({
|
||||
length: EMOJI_ROW_HEIGHT,
|
||||
offset: EMOJI_ROW_HEIGHT * index,
|
||||
index,
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function createEmojiPanelStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
emojiPanel: {
|
||||
height: 280,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.divider,
|
||||
},
|
||||
emojiPanelWide: {
|
||||
height: 320,
|
||||
},
|
||||
emojiRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
height: EMOJI_ROW_HEIGHT,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
emojiItem: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emojiText: {
|
||||
fontSize: 26,
|
||||
lineHeight: 32,
|
||||
},
|
||||
});
|
||||
}
|
||||
135
src/screens/create/composers/LongPostComposer.tsx
Normal file
135
src/screens/create/composers/LongPostComposer.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 短链 Composer(原长文模式,含投票)
|
||||
*
|
||||
* 富文本块编辑器 + 条件投票编辑器。
|
||||
* 通过 forwardRef 暴露 LongPostComposerHandle(含 getVote),供外壳发布逻辑分流。
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import BlockEditor, { BlockEditorHandle } from '../../../components/business/BlockEditor';
|
||||
import VoteEditor from '../../../components/business/VoteEditor';
|
||||
import { Text } from '../../../components/common';
|
||||
import { spacing, useAppColors, type AppColors } from '../../../theme';
|
||||
import { useResponsive } from '../../../hooks';
|
||||
import { MAX_CONTENT_LENGTH, type LongPostComposerHandle, type LongPostComposerProps } from './types';
|
||||
|
||||
export const LongPostComposer = React.forwardRef<LongPostComposerHandle, LongPostComposerProps>(function LongPostComposer(
|
||||
props,
|
||||
ref,
|
||||
) {
|
||||
const colors = useAppColors();
|
||||
const styles = React.useMemo(() => createLongStyles(colors), [colors]);
|
||||
const { isWideScreen } = useResponsive();
|
||||
const { onContentChange, voteToggleSignal } = props;
|
||||
|
||||
const blockEditorRef = React.useRef<BlockEditorHandle>(null);
|
||||
const [isVotePost, setIsVotePost] = React.useState(false);
|
||||
const [voteOptions, setVoteOptions] = React.useState<string[]>(['', '']);
|
||||
|
||||
// 投票选项增删改
|
||||
const handleAddVoteOption = () => {
|
||||
if (voteOptions.length < 10) setVoteOptions([...voteOptions, '']);
|
||||
};
|
||||
const handleRemoveVoteOption = (index: number) => {
|
||||
if (voteOptions.length > 2) setVoteOptions(voteOptions.filter((_, i) => i !== index));
|
||||
};
|
||||
const handleUpdateVoteOption = (index: number, value: string) => {
|
||||
setVoteOptions(prev => {
|
||||
const next = [...prev];
|
||||
next[index] = value;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 工具栏投票按钮点击信号:每次自增切换投票开关(跳过初始挂载)
|
||||
const firstSignal = React.useRef(true);
|
||||
React.useEffect(() => {
|
||||
if (firstSignal.current) {
|
||||
firstSignal.current = false;
|
||||
return;
|
||||
}
|
||||
setIsVotePost(prev => !prev);
|
||||
}, [voteToggleSignal]);
|
||||
|
||||
// 暴露给外壳的统一能力 + 额外的投票数据桥
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
getContent: () => blockEditorRef.current?.getContent() || '',
|
||||
getSegments: () => blockEditorRef.current?.getSegments() || [],
|
||||
getUploadedImages: () => [], // 长文图片以 segment 形式上传后回填到块内,不单独返回
|
||||
uploadImages: async () => {
|
||||
const ok = await blockEditorRef.current?.uploadPendingImages();
|
||||
return ok !== false;
|
||||
},
|
||||
insertEmoji: (text: string) => blockEditorRef.current?.insertTextAtCursor(text),
|
||||
insertImage: async () => { await blockEditorRef.current?.insertImage(); },
|
||||
insertCameraPhoto: async () => { await blockEditorRef.current?.insertCameraPhoto(); },
|
||||
getVote: () => ({ isVotePost, voteOptions }),
|
||||
}), [isVotePost, voteOptions]);
|
||||
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<BlockEditor
|
||||
ref={blockEditorRef}
|
||||
placeholder="添加正文"
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
textStyle={[styles.contentInput, isWideScreen && styles.contentInputWide]}
|
||||
onContentChange={onContentChange}
|
||||
initialBlocks={props.initialBlocks}
|
||||
/>
|
||||
|
||||
{/* 投票编辑器 */}
|
||||
{isVotePost && (
|
||||
<View style={isWideScreen && styles.voteEditorWide}>
|
||||
<View style={styles.voteEditorHeaderRow}>
|
||||
<Text variant="caption" color={colors.text.secondary}>已插入投票</Text>
|
||||
<TouchableOpacity onPress={() => setIsVotePost(false)} activeOpacity={0.7}>
|
||||
<Text variant="caption" color={colors.primary.main}>移除投票</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<VoteEditor
|
||||
options={voteOptions}
|
||||
onAddOption={handleAddVoteOption}
|
||||
onRemoveOption={handleRemoveVoteOption}
|
||||
onUpdateOption={handleUpdateVoteOption}
|
||||
maxOptions={10}
|
||||
minOptions={2}
|
||||
maxLength={50}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function createLongStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
section: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
contentInput: {
|
||||
flexGrow: 1,
|
||||
fontSize: 16,
|
||||
color: colors.text.primary,
|
||||
lineHeight: 24,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
contentInputWide: {
|
||||
fontSize: 18,
|
||||
lineHeight: 28,
|
||||
},
|
||||
voteEditorWide: {
|
||||
maxWidth: 600,
|
||||
alignSelf: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
voteEditorHeaderRow: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.md,
|
||||
marginBottom: -spacing.sm,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
});
|
||||
}
|
||||
223
src/screens/create/composers/MomentComposer.tsx
Normal file
223
src/screens/create/composers/MomentComposer.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 瞬间 Composer(原普通模式)
|
||||
*
|
||||
* 单输入框(支持 @提及)+ 独立图片网格。
|
||||
* 通过 forwardRef 暴露 ComposerHandle,供外壳无差别驱动。
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import PostMentionInput from '../../../components/business/PostMentionInput';
|
||||
import {
|
||||
spacing,
|
||||
borderRadius,
|
||||
useAppColors,
|
||||
type AppColors,
|
||||
} from '../../../theme';
|
||||
import { useResponsive, useResponsiveValue } from '../../../hooks';
|
||||
import { MessageSegment } from '../../../types';
|
||||
import {
|
||||
type PendingOrRemoteImage,
|
||||
makePendingImageFromAsset,
|
||||
getImageDisplayUri,
|
||||
uploadAllPendingImages,
|
||||
} from '../../../utils/pendingImages';
|
||||
import { MAX_CONTENT_LENGTH, type ComposerHandle, type ComposerProps } from './types';
|
||||
|
||||
export const MomentComposer = React.forwardRef<ComposerHandle, ComposerProps>(function MomentComposer(
|
||||
props,
|
||||
ref,
|
||||
) {
|
||||
const colors = useAppColors();
|
||||
const styles = React.useMemo(() => createMomentStyles(colors), [colors]);
|
||||
const { isWideScreen, width } = useResponsive();
|
||||
const { onContentChange } = props;
|
||||
|
||||
const [content, setContent] = React.useState(props.initialContent ?? '');
|
||||
const [segments, setSegments] = React.useState<MessageSegment[]>(props.initialSegments ?? []);
|
||||
const [images, setImages] = React.useState<PendingOrRemoteImage[]>(props.initialImages ?? []);
|
||||
// 表情插入位置(与原实现一致:默认末尾追加,插入表情后更新)
|
||||
const [selection, setSelection] = React.useState({ start: content.length, end: content.length });
|
||||
const [uploadedUrls, setUploadedUrls] = React.useState<string[]>([]);
|
||||
|
||||
// 响应式图片网格
|
||||
const imagesPerRow = useResponsiveValue({ xs: 3, sm: 3, md: 4, lg: 5, xl: 6 });
|
||||
const imageGap = 4;
|
||||
const availableWidth = isWideScreen ? Math.min(width, 800) - spacing.lg * 2 : width - spacing.lg * 2;
|
||||
const imageSize = Math.floor((availableWidth - imageGap * (imagesPerRow - 1)) / imagesPerRow);
|
||||
|
||||
const emitContent = React.useCallback((text: string) => {
|
||||
setContent(text);
|
||||
onContentChange?.(text);
|
||||
}, [onContentChange]);
|
||||
|
||||
// —— 选图 / 拍照(工具栏按钮与网格「+」按钮共用)——
|
||||
const pickImage = React.useCallback(async () => {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert('权限不足', '需要访问相册权限来选择图片');
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: 'images',
|
||||
allowsMultipleSelection: true,
|
||||
selectionLimit: 0,
|
||||
quality: 1,
|
||||
});
|
||||
if (!result.canceled && result.assets) {
|
||||
setImages(prev => [...prev, ...result.assets.map(a => makePendingImageFromAsset(a))]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const takePhoto = React.useCallback(async () => {
|
||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert('权限不足', '需要访问相机权限来拍照');
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchCameraAsync({ allowsEditing: true, quality: 0.8 });
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
setImages(prev => [...prev, makePendingImageFromAsset(result.assets[0])]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 暴露给外壳的统一能力
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
getContent: () => content,
|
||||
getSegments: () => segments,
|
||||
getUploadedImages: () => uploadedUrls,
|
||||
uploadImages: async () => {
|
||||
if (images.length === 0) {
|
||||
setUploadedUrls([]);
|
||||
return true;
|
||||
}
|
||||
const result = await uploadAllPendingImages(images, (id, url) => {
|
||||
setImages(prev => prev.map(i => i.id === id ? { id, kind: 'remote' as const, url } : i));
|
||||
});
|
||||
if (!result.success) {
|
||||
return false;
|
||||
}
|
||||
setUploadedUrls(result.urls);
|
||||
return true;
|
||||
},
|
||||
insertEmoji: (text: string) => {
|
||||
const newContent = content.slice(0, selection.start) + text + content.slice(selection.end);
|
||||
emitContent(newContent);
|
||||
const newPosition = selection.start + text.length;
|
||||
setSelection({ start: newPosition, end: newPosition });
|
||||
},
|
||||
insertImage: pickImage,
|
||||
insertCameraPhoto: takePhoto,
|
||||
}), [content, segments, images, selection, uploadedUrls, emitContent, pickImage, takePhoto]);
|
||||
|
||||
const handleRemoveImage = (index: number) => {
|
||||
setImages(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<PostMentionInput
|
||||
value={content}
|
||||
onChangeText={emitContent}
|
||||
onSegmentsChange={setSegments}
|
||||
placeholder="添加正文"
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
minHeight={160}
|
||||
autoExpand
|
||||
style={styles.contentInput}
|
||||
/>
|
||||
|
||||
{/* 图片网格 */}
|
||||
{images.length > 0 && (
|
||||
<View style={styles.imageGrid}>
|
||||
{images.map((img, index) => (
|
||||
<View key={img.id} style={[styles.imageGridItem, { width: imageSize, height: imageSize }]}>
|
||||
<Image
|
||||
source={{ uri: getImageDisplayUri(img) }}
|
||||
style={styles.gridImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.removeImageButton}
|
||||
onPress={() => handleRemoveImage(index)}
|
||||
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
||||
>
|
||||
<View style={styles.removeImageButtonInner}>
|
||||
<MaterialCommunityIcons name="close" size={12} color={colors.text.inverse} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
{/* 添加图片按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[styles.addImageGridButton, { width: imageSize, height: imageSize }]}
|
||||
onPress={pickImage}
|
||||
>
|
||||
<MaterialCommunityIcons name="plus" size={32} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function createMomentStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
section: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
contentInput: {
|
||||
flexGrow: 1,
|
||||
fontSize: 16,
|
||||
color: colors.text.primary,
|
||||
lineHeight: 24,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
imageGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
paddingTop: spacing.md,
|
||||
gap: 4,
|
||||
},
|
||||
imageGridItem: {
|
||||
borderRadius: borderRadius.md,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
gridImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
removeImageButton: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
zIndex: 10,
|
||||
},
|
||||
removeImageButtonInner: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
addImageGridButton: {
|
||||
borderRadius: borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
borderStyle: 'dashed',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
});
|
||||
}
|
||||
57
src/screens/create/composers/types.ts
Normal file
57
src/screens/create/composers/types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { MessageSegment } from '../../../types';
|
||||
import type { EditorBlock } from '../../../components/business/BlockEditor/blockEditorTypes';
|
||||
import type { RemoteImage } from '../../../utils/pendingImages';
|
||||
|
||||
/**
|
||||
* 两种 Composer(瞬间 / 长文)共同实现的对外能力。
|
||||
* 发帖外壳 CreatePostScreen 通过 ref 无差别地驱动两种模式,
|
||||
* 从而消除主文件里的 isLongPostMode 分支。
|
||||
*/
|
||||
export interface ComposerHandle {
|
||||
/** 纯文本内容(标题/正文校验、字数统计用) */
|
||||
getContent(): string;
|
||||
/** 提交正文段(长文含 image/at 段;瞬间可能含 at 段) */
|
||||
getSegments(): MessageSegment[];
|
||||
/** 发布前上传所有 pending 图片,成功返回 true */
|
||||
uploadImages(): Promise<boolean>;
|
||||
/** 上传后得到的图片 URL(普通模式独立 images 字段;长文返回空数组) */
|
||||
getUploadedImages(): string[];
|
||||
/** 表情面板点击插入 */
|
||||
insertEmoji(text: string): void;
|
||||
/** 工具栏「相册」 */
|
||||
insertImage(): Promise<void>;
|
||||
/** 工具栏「相机」 */
|
||||
insertCameraPhoto(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 长文 Composer 额外暴露的投票数据,供外壳发布逻辑决定走 voteService。
|
||||
*/
|
||||
export interface LongPostComposerHandle extends ComposerHandle {
|
||||
/** 当前投票态与选项 */
|
||||
getVote(): { isVotePost: boolean; voteOptions: string[] };
|
||||
}
|
||||
|
||||
export interface ComposerProps {
|
||||
isEditMode: boolean;
|
||||
/** 编辑态回填:纯文本 */
|
||||
initialContent?: string;
|
||||
/** 编辑态回填:普通模式已有图片 */
|
||||
initialImages?: RemoteImage[];
|
||||
/** 编辑态回填:长文含 image 段的 segments */
|
||||
initialSegments?: MessageSegment[];
|
||||
/** 编辑态回填:长文块(由 segments 转换) */
|
||||
initialBlocks?: EditorBlock[];
|
||||
/** 正文变化回调——同步外壳顶栏字数统计 */
|
||||
onContentChange?: (text: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 长文 Composer 额外接受的 props。
|
||||
* voteToggleSignal:外壳工具栏投票按钮每次点击自增;长文 Composer 监听其变化以切换投票开关。
|
||||
*/
|
||||
export interface LongPostComposerProps extends ComposerProps {
|
||||
voteToggleSignal?: number;
|
||||
}
|
||||
|
||||
export const MAX_CONTENT_LENGTH = 2000;
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
RefreshControl,
|
||||
StatusBar,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
NativeSyntheticEvent,
|
||||
NativeScrollEvent,
|
||||
Alert,
|
||||
@@ -24,7 +25,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter, useFocusEffect } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme';
|
||||
import { useAppColors, useResolvedColorScheme, spacing, borderRadius, type AppColors } from '../../theme';
|
||||
import { Post } from '../../types';
|
||||
import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores';
|
||||
import { useCurrentUser, useIsAuthenticated, useIsVerified, useVerificationStore } from '../../stores/auth';
|
||||
@@ -186,10 +187,11 @@ function createHomeStyles(colors: AppColors, responsivePadding: number) {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: colors.primary.main,
|
||||
backgroundColor: `${colors.primary.main}10`,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.primary.main}35`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...shadows.lg,
|
||||
},
|
||||
floatingButtonDesktop: {
|
||||
right: 40,
|
||||
@@ -205,6 +207,54 @@ function createHomeStyles(colors: AppColors, responsivePadding: number) {
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
},
|
||||
// 展开后两个小按钮所在的列容器,底部对齐主 FAB 顶部上方
|
||||
fabSubGroup: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
gap: 10,
|
||||
},
|
||||
fabSubGroupDesktop: {
|
||||
right: 40,
|
||||
},
|
||||
fabSubGroupWide: {
|
||||
right: 60,
|
||||
},
|
||||
// 两个小按钮:胶囊按钮 + 图标 + 文字
|
||||
fabSubButton: {
|
||||
minWidth: 92,
|
||||
height: 44,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: `${colors.primary.main}24`,
|
||||
},
|
||||
fabSubButtonWide: {
|
||||
minWidth: 104,
|
||||
height: 48,
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
fabSubLabel: {
|
||||
fontSize: 13,
|
||||
lineHeight: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.primary.main,
|
||||
},
|
||||
// 全屏透明遮罩:用于点击空白收起
|
||||
fabOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
loadingMoreFooter: {
|
||||
paddingVertical: 20,
|
||||
alignItems: 'center',
|
||||
@@ -266,6 +316,10 @@ export const HomeScreen: React.FC = () => {
|
||||
|
||||
// 发帖弹窗状态
|
||||
const [showCreatePost, setShowCreatePost] = useState(false);
|
||||
// 发帖模式:moment=瞬间(普通),long=长文(含投票)。由 FAB 展开选择后决定。
|
||||
const [postMode, setPostMode] = useState<'moment' | 'long'>('moment');
|
||||
// 加号是否展开为「瞬间/长文」两个小按钮
|
||||
const [fabExpanded, setFabExpanded] = useState(false);
|
||||
|
||||
// 广场/市集 Tab
|
||||
const [homeTab, setHomeTab] = useState<'square' | 'market'>('square');
|
||||
@@ -713,7 +767,7 @@ export const HomeScreen: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 跳转到发帖页面(使用 Modal 方式)
|
||||
// 点击加号:校验登录/实名后展开「瞬间/长文」两个小按钮
|
||||
const handleCreatePost = () => {
|
||||
if (!isAuthenticated) {
|
||||
router.push(hrefs.hrefAuthLogin());
|
||||
@@ -723,6 +777,13 @@ export const HomeScreen: React.FC = () => {
|
||||
router.push(hrefs.hrefVerificationGuide());
|
||||
return;
|
||||
}
|
||||
setFabExpanded(prev => !prev);
|
||||
};
|
||||
|
||||
// 选择发帖模式并打开发帖弹窗
|
||||
const openCreatePostWithMode = (mode: 'moment' | 'long') => {
|
||||
setPostMode(mode);
|
||||
setFabExpanded(false);
|
||||
setShowCreatePost(true);
|
||||
};
|
||||
|
||||
@@ -795,7 +856,7 @@ export const HomeScreen: React.FC = () => {
|
||||
const authorId = item.author?.id || '';
|
||||
const isPostAuthor = currentUser?.id === authorId;
|
||||
return (
|
||||
<View style={{ marginBottom: 2, paddingHorizontal: 1 }}>
|
||||
<View style={{ marginBottom: 2, paddingHorizontal: responsiveGap / 2 }}>
|
||||
<PostCard
|
||||
post={item}
|
||||
variant="grid"
|
||||
@@ -804,7 +865,7 @@ export const HomeScreen: React.FC = () => {
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}, [currentUser?.id, stableOnPostAction]);
|
||||
}, [currentUser?.id, stableOnPostAction, responsiveGap]);
|
||||
|
||||
// 渲染响应式网格布局(所有端统一使用 FlashList masonry 模式)
|
||||
const renderResponsiveGrid = () => {
|
||||
@@ -820,7 +881,7 @@ export const HomeScreen: React.FC = () => {
|
||||
masonry
|
||||
optimizeItemArrangement
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: responsivePadding,
|
||||
paddingHorizontal: responsiveGap / 2,
|
||||
paddingBottom: 80 + responsivePadding,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -1043,6 +1104,51 @@ export const HomeScreen: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 惰性全屏遮罩:点击空白处收起 FAB */}
|
||||
{fabExpanded && (
|
||||
<TouchableWithoutFeedback onPress={() => setFabExpanded(false)}>
|
||||
<View style={styles.fabOverlay} />
|
||||
</TouchableWithoutFeedback>
|
||||
)}
|
||||
|
||||
{/* 发帖入口:加号 / 展开后的「瞬间/长文」两个小按钮 */}
|
||||
{fabExpanded && homeTab !== 'market' && (() => {
|
||||
// 子按钮组整体置于主 FAB 上方:bottom = 主FAB底部 + 主FAB高度 + 间距
|
||||
const fabBottom = floatingButtonBottom !== undefined
|
||||
? floatingButtonBottom
|
||||
: (isWideScreen ? 60 : isDesktop ? 40 : 20);
|
||||
const fabHeight = isWideScreen ? 72 : isDesktop ? 64 : 56;
|
||||
const subGroupBottom = fabBottom + fabHeight + 14;
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.fabSubGroup,
|
||||
isDesktop && styles.fabSubGroupDesktop,
|
||||
isWideScreen && styles.fabSubGroupWide,
|
||||
{ bottom: subGroupBottom },
|
||||
]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[styles.fabSubButton, isWideScreen && styles.fabSubButtonWide]}
|
||||
onPress={() => openCreatePostWithMode('long')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialCommunityIcons name="text-box" size={18} color={colors.primary.main} />
|
||||
<Text variant="caption" style={styles.fabSubLabel}>长文</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.fabSubButton, isWideScreen && styles.fabSubButtonWide]}
|
||||
onPress={() => openCreatePostWithMode('moment')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialCommunityIcons name="lightning-bolt" size={18} color={colors.primary.main} />
|
||||
<Text variant="caption" style={styles.fabSubLabel}>瞬间</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 漂浮发帖/发布按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
@@ -1058,7 +1164,11 @@ export const HomeScreen: React.FC = () => {
|
||||
} : handleCreatePost}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialCommunityIcons name="plus" size={28} color={colors.text.inverse} />
|
||||
<MaterialCommunityIcons
|
||||
name={(fabExpanded && homeTab !== 'market') ? 'close' : 'plus'}
|
||||
size={28}
|
||||
color={colors.primary.main}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 图片查看器 */}
|
||||
@@ -1078,6 +1188,8 @@ export const HomeScreen: React.FC = () => {
|
||||
onRequestClose={() => setShowCreatePost(false)}
|
||||
>
|
||||
<CreatePostScreen
|
||||
key={postMode}
|
||||
postMode={postMode}
|
||||
onClose={() => setShowCreatePost(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from '../../theme';
|
||||
import { Post, Comment, VoteResultDTO, VoteOptionDTO, MessageSegment } from '../../types';
|
||||
import { useUserStore } from '../../stores';
|
||||
import { userManager } from '../../stores/user';
|
||||
import { useCurrentUser } from '../../stores/auth';
|
||||
import { postService, commentService, authService, showPrompt, voteService } from '../../services';
|
||||
import { postSyncService } from '@/services/post';
|
||||
@@ -262,6 +263,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
const [showEmojiPanel, setShowEmojiPanel] = useState(false);
|
||||
const [isFollowing, setIsFollowing] = useState(false);
|
||||
const [isFollowingMe, setIsFollowingMe] = useState(false);
|
||||
const [authorFollowersCount, setAuthorFollowersCount] = useState<number | null>(null);
|
||||
const [isFollowLoading, setIsFollowLoading] = useState(false);
|
||||
const flatListRef = useRef<FlashListRef<Comment> | null>(null);
|
||||
const hasRecordedView = useRef(false); // 是否已记录浏览量
|
||||
@@ -277,6 +279,35 @@ export const PostDetailScreen: React.FC = () => {
|
||||
const [voteResult, setVoteResult] = useState<VoteResultDTO | null>(null);
|
||||
const [isVoteLoading, setIsVoteLoading] = useState(false);
|
||||
|
||||
// 同步作者主页资料,帖子详情接口可能只返回作者基础信息,粉丝数需要以用户主页数据为准。
|
||||
useEffect(() => {
|
||||
const author = post?.author;
|
||||
if (!author?.id) {
|
||||
setAuthorFollowersCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const authorId = author.id;
|
||||
let cancelled = false;
|
||||
setAuthorFollowersCount(author.followers_count ?? null);
|
||||
|
||||
userManager.getUserById(authorId, true)
|
||||
.then((user) => {
|
||||
if (!cancelled && user) {
|
||||
setAuthorFollowersCount(user.followers_count ?? 0);
|
||||
setIsFollowing(user.is_following || false);
|
||||
setIsFollowingMe(user.is_following_me || false);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('加载作者主页资料失败:', error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [post?.author?.id]);
|
||||
|
||||
// 桌面端侧边栏宽度
|
||||
const sidebarWidth = useResponsiveValue({
|
||||
xs: 0,
|
||||
@@ -385,7 +416,10 @@ export const PostDetailScreen: React.FC = () => {
|
||||
router.replace(hrefs.hrefHome());
|
||||
}, [router]);
|
||||
|
||||
const renderCustomHeader = () => (
|
||||
const renderCustomHeader = () => {
|
||||
const followersCount = authorFollowersCount ?? post?.author?.followers_count ?? 0;
|
||||
|
||||
return (
|
||||
<View style={styles.customHeader}>
|
||||
<AppBackButton onPress={handleBackPress} style={styles.headerBackButton} />
|
||||
{post?.author ? (
|
||||
@@ -393,17 +427,23 @@ export const PostDetailScreen: React.FC = () => {
|
||||
<TouchableOpacity
|
||||
onPress={() => handleUserPress(post.author!.id)}
|
||||
style={styles.headerAvatarWrapper}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<Avatar source={post.author.avatar} size={32} name={post.author.nickname} />
|
||||
<Avatar source={post.author.avatar} size={42} name={post.author.nickname} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.headerUserInfo}>
|
||||
<TouchableOpacity onPress={() => handleUserPress(post.author!.id)}>
|
||||
<TouchableOpacity onPress={() => handleUserPress(post.author!.id)} activeOpacity={0.75}>
|
||||
<View style={styles.headerTitleRow}>
|
||||
<Text style={styles.headerNickname} numberOfLines={1}>
|
||||
{post.author.nickname && post.author.nickname.length > 10
|
||||
? `${post.author.nickname.slice(0, 10)}...`
|
||||
: post.author.nickname}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerMetaText} numberOfLines={1}>
|
||||
{formatNumber(followersCount)} 粉丝
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
@@ -414,6 +454,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 监听键盘事件
|
||||
useEffect(() => {
|
||||
@@ -1008,12 +1049,14 @@ export const PostDetailScreen: React.FC = () => {
|
||||
const success = await authService.unfollowUser(post.author.id);
|
||||
if (success) {
|
||||
setIsFollowing(false);
|
||||
setAuthorFollowersCount(prev => prev == null ? prev : Math.max(0, prev - 1));
|
||||
}
|
||||
} else {
|
||||
// 关注
|
||||
const success = await authService.followUser(post.author.id);
|
||||
if (success) {
|
||||
setIsFollowing(true);
|
||||
setAuthorFollowersCount(prev => prev == null ? prev : prev + 1);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1358,32 +1401,42 @@ export const PostDetailScreen: React.FC = () => {
|
||||
);
|
||||
}, [currentUser?.id, handleDeleteComment, handleImagePress, handleLikeComment, handleLoadMoreReplies, post?.user_id, handleReportComment]);
|
||||
|
||||
// 渲染空评论 - 类似图片中的样式
|
||||
const renderEmptyComments = useCallback(() => (
|
||||
<View style={styles.emptyCommentsContainer}>
|
||||
{/* 四个方块图标 */}
|
||||
<View style={styles.emptyCommentsGrid}>
|
||||
<View style={styles.emptyCommentsGridItem} />
|
||||
<View style={styles.emptyCommentsGridItem} />
|
||||
<View style={styles.emptyCommentsGridItem} />
|
||||
<View style={[styles.emptyCommentsGridItem, styles.emptyCommentsGridItemActive]} />
|
||||
</View>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.emptyCommentsSubtitle}>
|
||||
这里空空如也,邀请好友来互动吧!
|
||||
</Text>
|
||||
</View>
|
||||
), []);
|
||||
|
||||
const openComposer = () => {
|
||||
const openComposer = useCallback(() => {
|
||||
setIsComposerVisible(true);
|
||||
focusCommentInput();
|
||||
};
|
||||
}, [focusCommentInput]);
|
||||
|
||||
const closeComposer = () => {
|
||||
const closeComposer = useCallback(() => {
|
||||
setIsComposerVisible(false);
|
||||
setShowEmojiPanel(false);
|
||||
setReplyingTo(null);
|
||||
Keyboard.dismiss();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 渲染空评论 - 简洁留白状态
|
||||
const renderEmptyComments = useCallback(() => (
|
||||
<View style={styles.emptyCommentsContainer}>
|
||||
<View style={styles.emptyCommentsIllustration}>
|
||||
<View style={styles.emptyCommentsIconCircle}>
|
||||
<MaterialCommunityIcons name="forum-outline" size={40} color={colors.primary.main} />
|
||||
</View>
|
||||
<View style={styles.emptyCommentsBubbleSmall} />
|
||||
<View style={styles.emptyCommentsBubbleTiny} />
|
||||
</View>
|
||||
<Text style={styles.emptyCommentsTitle}>还没有评论</Text>
|
||||
<Text variant="caption" style={styles.emptyCommentsSubtitle}>
|
||||
评论区空空如也,来发表第一条评论吧~
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.emptyCommentsAction}
|
||||
onPress={openComposer}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<Text style={styles.emptyCommentsActionText}>抢沙发</Text>
|
||||
<MaterialCommunityIcons name="chevron-right" size={16} color={colors.primary.main} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
), [colors, openComposer, styles]);
|
||||
|
||||
const handleAtMention = () => {
|
||||
setCommentText(prev => prev + '@');
|
||||
@@ -1522,6 +1575,16 @@ export const PostDetailScreen: React.FC = () => {
|
||||
{/* Toolbar */}
|
||||
<View style={styles.expandedToolbar}>
|
||||
<View style={styles.expandedToolbarLeft}>
|
||||
<TouchableOpacity
|
||||
style={styles.expandedToolbarItem}
|
||||
onPress={closeComposer}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-down"
|
||||
size={22}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.expandedToolbarItem}
|
||||
onPress={handlePickCommentImage}
|
||||
@@ -1882,9 +1945,17 @@ function createPostDetailStyles(colors: AppColors) {
|
||||
customHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
height: 52,
|
||||
backgroundColor: colors.background.paper,
|
||||
minHeight: 66,
|
||||
paddingRight: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.divider,
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
zIndex: 10,
|
||||
},
|
||||
headerPlaceholder: {
|
||||
flex: 1,
|
||||
@@ -1894,24 +1965,55 @@ function createPostDetailStyles(colors: AppColors) {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
marginLeft: spacing.sm,
|
||||
marginLeft: spacing.xs,
|
||||
minWidth: 0,
|
||||
},
|
||||
headerAvatarWrapper: {
|
||||
marginRight: spacing.sm,
|
||||
padding: 2,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.primary.main}10`,
|
||||
},
|
||||
headerUserInfo: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
minWidth: 0,
|
||||
},
|
||||
headerTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
},
|
||||
headerNickname: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
fontWeight: '700',
|
||||
color: colors.text.primary,
|
||||
maxWidth: 150,
|
||||
},
|
||||
headerAuthorPill: {
|
||||
marginLeft: spacing.xs,
|
||||
paddingHorizontal: 5,
|
||||
paddingVertical: 1,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: `${colors.primary.main}18`,
|
||||
},
|
||||
headerAuthorPillText: {
|
||||
fontSize: 9,
|
||||
lineHeight: 12,
|
||||
color: colors.primary.main,
|
||||
fontWeight: '700',
|
||||
},
|
||||
headerMetaText: {
|
||||
marginTop: 3,
|
||||
fontSize: fontSizes.sm,
|
||||
lineHeight: fontSizes.sm + 4,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
headerRightContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
marginRight: spacing.xs,
|
||||
marginLeft: spacing.xs,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
headerBackButton: {
|
||||
@@ -1922,38 +2024,42 @@ function createPostDetailStyles(colors: AppColors) {
|
||||
// 帖子容器
|
||||
postContainer: {
|
||||
backgroundColor: colors.background.paper,
|
||||
paddingBottom: spacing.md,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
// 关注按钮样式
|
||||
followButton: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 4,
|
||||
borderRadius: borderRadius.lg,
|
||||
minWidth: 64,
|
||||
paddingVertical: 6,
|
||||
borderRadius: borderRadius.full,
|
||||
minWidth: 72,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
followButtonPrimary: {
|
||||
backgroundColor: colors.primary.main,
|
||||
backgroundColor: `${colors.primary.main}10`,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.primary.main}35`,
|
||||
},
|
||||
followButtonOutline: {
|
||||
backgroundColor: 'transparent',
|
||||
backgroundColor: `${colors.text.primary}04`,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
followButtonMutual: {
|
||||
backgroundColor: 'transparent',
|
||||
backgroundColor: `${colors.primary.main}08`,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
borderColor: `${colors.primary.main}24`,
|
||||
},
|
||||
followButtonLoading: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
followButtonPlaceholder: {
|
||||
minWidth: 64,
|
||||
minWidth: 72,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 4,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
followButtonText: {
|
||||
@@ -1961,7 +2067,7 @@ function createPostDetailStyles(colors: AppColors) {
|
||||
fontWeight: '600',
|
||||
},
|
||||
followButtonTextPrimary: {
|
||||
color: colors.text.inverse,
|
||||
color: colors.primary.main,
|
||||
},
|
||||
followButtonTextOutline: {
|
||||
color: colors.text.secondary,
|
||||
@@ -2294,33 +2400,73 @@ function createPostDetailStyles(colors: AppColors) {
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
// 空评论状态样式(类似图片中的四个方块样式)
|
||||
// 空评论状态样式
|
||||
emptyCommentsContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.xl * 2.5,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingTop: spacing['3xl'],
|
||||
paddingBottom: spacing['4xl'],
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
emptyCommentsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
width: 48,
|
||||
height: 48,
|
||||
marginBottom: spacing.md,
|
||||
emptyCommentsIllustration: {
|
||||
position: 'relative',
|
||||
width: 112,
|
||||
height: 96,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
emptyCommentsGridItem: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
backgroundColor: colors.background.disabled,
|
||||
margin: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
emptyCommentsIconCircle: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: `${colors.primary.main}10`,
|
||||
},
|
||||
emptyCommentsGridItemActive: {
|
||||
backgroundColor: colors.text.hint,
|
||||
emptyCommentsBubbleSmall: {
|
||||
position: 'absolute',
|
||||
right: 14,
|
||||
top: 10,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
backgroundColor: `${colors.primary.light}28`,
|
||||
},
|
||||
emptyCommentsBubbleTiny: {
|
||||
position: 'absolute',
|
||||
left: 18,
|
||||
bottom: 18,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: `${colors.primary.main}18`,
|
||||
},
|
||||
emptyCommentsTitle: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '800',
|
||||
color: colors.text.primary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyCommentsSubtitle: {
|
||||
marginTop: spacing.sm,
|
||||
fontSize: fontSizes.sm,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center',
|
||||
color: colors.text.hint,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
emptyCommentsAction: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
emptyCommentsActionText: {
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '700',
|
||||
color: colors.primary.main,
|
||||
},
|
||||
// 侧边栏样式
|
||||
sidebar: {
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useUserStore } from '../../stores';
|
||||
import { postService, authService } from '../../services';
|
||||
import { tradeService } from '../../services/trade/tradeService';
|
||||
import { postSyncService } from '@/services/post';
|
||||
import { PostCard, SearchBar } from '../../components/business';
|
||||
import { PostCard, SearchHeader, TabBar } from '../../components/business';
|
||||
import { TradeCard } from '../../components/business/TradeCard/TradeCard';
|
||||
import type { PostCardAction } from '../../components/business/PostCard';
|
||||
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
|
||||
@@ -601,66 +601,32 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
{/* 搜索输入框 */}
|
||||
<View
|
||||
style={[
|
||||
styles.searchHeader,
|
||||
{
|
||||
paddingTop: spacing.sm,
|
||||
paddingHorizontal: responsivePadding,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.searchShell, { maxWidth: searchBarMaxWidth }]}>
|
||||
<SearchBar
|
||||
{/* 搜索顶栏(统一组件,内置硬件返回键拦截) */}
|
||||
<SearchHeader
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
onSubmit={handleSearch}
|
||||
onCancel={onBack ? onBack : () => router.back()}
|
||||
placeholder={isMarket ? "搜索商品、用户" : "搜索帖子、用户"}
|
||||
autoFocus
|
||||
compact
|
||||
searchBarMaxWidth={searchBarMaxWidth}
|
||||
horizontalPadding={responsivePadding}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.cancelButton, { marginLeft: responsiveGap }]}
|
||||
onPress={() => (onBack ? onBack() : router.back())}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.primary.main}
|
||||
style={styles.cancelText}
|
||||
>
|
||||
取消
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Tab切换 - 与主页风格一致的下划线样式 */}
|
||||
<View style={styles.tabWrapper}>
|
||||
<View style={[styles.homeTabSwitcher, { paddingHorizontal: responsivePadding }]}>
|
||||
{TABS.map((tab, index) => {
|
||||
const isActive = activeIndex === index;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tab}
|
||||
activeOpacity={0.7}
|
||||
style={[styles.homeTabItem, isActive && styles.homeTabItemActive]}
|
||||
onPress={() => {
|
||||
<TabBar
|
||||
tabs={TABS}
|
||||
activeIndex={activeIndex}
|
||||
onTabChange={(index) => {
|
||||
setActiveIndex(index);
|
||||
if (currentKeyword && hasSearched) {
|
||||
performSearch(currentKeyword);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={isActive ? styles.homeTabTextActive : styles.homeTabText}>
|
||||
{tab}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
variant="home"
|
||||
style={{ paddingHorizontal: responsivePadding }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 内容区域 */}
|
||||
@@ -675,52 +641,10 @@ function createSearchScreenStyles(colors: AppColors) {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
searchHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
searchShell: {
|
||||
flex: 1,
|
||||
},
|
||||
cancelButton: {
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
cancelText: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
tabWrapper: {
|
||||
backgroundColor: colors.background.default,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
homeTabSwitcher: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 20,
|
||||
},
|
||||
homeTabItem: {
|
||||
paddingVertical: spacing.sm,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: 'transparent',
|
||||
},
|
||||
homeTabItemActive: {
|
||||
borderBottomColor: colors.text.primary,
|
||||
},
|
||||
homeTabText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '400',
|
||||
color: colors.text.hint,
|
||||
},
|
||||
homeTabTextActive: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
suggestionsContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
TouchableOpacity,
|
||||
BackHandler,
|
||||
StatusBar,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { FlashList, ListRenderItem } from '@shopify/flash-list';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
@@ -173,6 +174,10 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
effectiveGroupName,
|
||||
otherUserLastReadSeq,
|
||||
messageMap,
|
||||
// 引用消息回填(会话级缓存:内存 → 本地 SQLite → 服务端)
|
||||
getCachedReply,
|
||||
ensureReplyMessage,
|
||||
jumpToMessageSeq,
|
||||
loadingMore,
|
||||
hasMoreHistory,
|
||||
conversationId,
|
||||
@@ -200,6 +205,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
handleDeleteMessage,
|
||||
handleReplyMessage,
|
||||
handleCancelReply,
|
||||
handleRetrySend,
|
||||
handleAvatarPress,
|
||||
handleAvatarLongPress,
|
||||
handleSelectMention,
|
||||
@@ -214,6 +220,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
handleReachLatestEdge,
|
||||
jumpToLatestMessages,
|
||||
setBrowsingHistory,
|
||||
clearHistoryLock,
|
||||
} = useChatScreen(props);
|
||||
|
||||
const stableOnBack = useCallback(() => router.back(), [router]);
|
||||
@@ -279,20 +286,43 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleReplyPreviewPress = useCallback((messageId: string) => {
|
||||
const handleReplyPreviewPress = useCallback(async (messageId: string) => {
|
||||
const targetId = String(messageId);
|
||||
const targetIndex = displayMessages.findIndex(msg => String(msg.id) === targetId);
|
||||
if (targetIndex < 0) return;
|
||||
|
||||
// 1. 内存命中:直接滚动定位(与原逻辑一致,最快路径)
|
||||
const targetIndex = displayMessages.findIndex(msg => String(msg.id) === targetId);
|
||||
if (targetIndex >= 0) {
|
||||
replyTargetMessageIdRef.current = targetId;
|
||||
setBrowsingHistory(true);
|
||||
|
||||
try {
|
||||
flatListRef.current?.scrollToIndex({
|
||||
index: targetIndex,
|
||||
animated: true,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, [displayMessages, flatListRef, setBrowsingHistory]);
|
||||
} catch {
|
||||
// FlashList 远距离跳转可能失败,降级到 offset 滚动
|
||||
flatListRef.current?.scrollToOffset?.({ offset: 0, animated: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 内存未命中:回填被引用消息(内存缓存 → 本地 SQLite → 服务端),拿到 seq
|
||||
if (!ensureReplyMessage) return;
|
||||
const filled = await ensureReplyMessage(targetId);
|
||||
if (!filled || !filled.seq || filled.seq <= 0) {
|
||||
// 已被删除/不可见,或无有效 seq
|
||||
Alert.alert('该引用消息已被删除或不可见');
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 拿到 seq 后复用 scrollToSeq 范式:内存命中定位,未命中循环 loadMoreHistory
|
||||
replyTargetMessageIdRef.current = targetId;
|
||||
const accepted = jumpToMessageSeq ? jumpToMessageSeq(filled.seq) : false;
|
||||
if (!accepted) {
|
||||
Alert.alert('无法定位该引用消息');
|
||||
}
|
||||
}, [displayMessages, flatListRef, setBrowsingHistory, ensureReplyMessage, jumpToMessageSeq]);
|
||||
|
||||
// 监听返回事件:面板/键盘打开时先关闭它们,否则刷新会话列表后返回
|
||||
useEffect(() => {
|
||||
@@ -357,6 +387,8 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
otherUserLastReadSeq={otherUserLastReadSeq}
|
||||
selectedMessageId={selectedMessageId}
|
||||
messageMap={messageMap}
|
||||
getCachedReply={getCachedReply}
|
||||
ensureReplyMessage={ensureReplyMessage}
|
||||
onLongPress={handleLongPressMessage}
|
||||
onAvatarPress={handleAvatarPress}
|
||||
onAvatarLongPress={handleAvatarLongPress}
|
||||
@@ -365,6 +397,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
onImagePress={handleImagePress}
|
||||
onReply={handleReplyMessage}
|
||||
onReplyPress={handleReplyPreviewPress}
|
||||
onRetrySend={handleRetrySend}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
@@ -377,6 +410,8 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
otherUserLastReadSeq,
|
||||
selectedMessageId,
|
||||
messageMap,
|
||||
getCachedReply,
|
||||
ensureReplyMessage,
|
||||
handleLongPressMessage,
|
||||
handleAvatarPress,
|
||||
handleAvatarLongPress,
|
||||
@@ -385,6 +420,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
handleImagePress,
|
||||
handleReplyMessage,
|
||||
handleReplyPreviewPress,
|
||||
handleRetrySend,
|
||||
]);
|
||||
|
||||
const keyExtractor = useCallback((item: GroupMessage) => String(item.id), []);
|
||||
@@ -412,15 +448,12 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
setBrowsingHistory(true);
|
||||
}
|
||||
|
||||
// 仅用户手势主动回到底部时才解除历史阅读锁(避免加载阶段误解锁)
|
||||
const isScrollingTowardLatest = contentOffset.y < lastScrollYRef.current;
|
||||
if (
|
||||
isUserDraggingRef.current &&
|
||||
!loadingMore &&
|
||||
isScrollingTowardLatest &&
|
||||
contentOffset.y <= 40
|
||||
) {
|
||||
handleReachLatestEdge();
|
||||
// 接近最新消息端时退出历史阅读模式
|
||||
// 修复:不再要求 isUserDraggingRef — 动量滚动结束后、layout settle 后、
|
||||
// 或用户轻触回到底部时都能正确解锁,防止浏览历史锁永久为 true
|
||||
// 导致新消息不自动跟随(仅自己发的才显示的问题)
|
||||
if (!loadingMore && contentOffset.y <= 100) {
|
||||
clearHistoryLock();
|
||||
}
|
||||
|
||||
// inverted 下历史端在“列表尾部”,对应 offset 增大且距离尾部变小
|
||||
@@ -460,14 +493,18 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
loading,
|
||||
messages.length,
|
||||
loadMoreHistory,
|
||||
handleReachLatestEdge,
|
||||
showEdgeLoadingIndicator,
|
||||
setBrowsingHistory,
|
||||
]);
|
||||
|
||||
const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
|
||||
handleMessageListContentSizeChange(contentWidth, contentHeight);
|
||||
}, [handleMessageListContentSizeChange]);
|
||||
// 安全检查:loadMoreHistory 完成后,如果用户仍在底部附近,清除浏览历史锁
|
||||
// 防止 layout settle 后没有触发滚动事件导致锁无法释放
|
||||
if (!loadingMore && scrollPositionRef.current.scrollY <= 100) {
|
||||
clearHistoryLock();
|
||||
}
|
||||
}, [handleMessageListContentSizeChange, loadingMore, clearHistoryLock]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={containerStyle} edges={props.isEmbedded ? [] : ['top']}>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* GroupInfoScreen 群组信息/设置界面
|
||||
* 显示群组详细信息,提供群组管理功能
|
||||
* 支持响应式布局
|
||||
* QQ 风格:移除装饰性图标,使用简洁的文字列表
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
@@ -23,13 +24,11 @@ import {
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useFocusEffect } from "expo-router/react-navigation";
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import {
|
||||
spacing,
|
||||
fontSizes,
|
||||
borderRadius,
|
||||
shadows,
|
||||
useAppColors,
|
||||
type AppColors,
|
||||
} from '../../theme';
|
||||
@@ -38,7 +37,7 @@ import { groupService } from '@/services/message';
|
||||
import { uploadService } from '@/services/upload';
|
||||
import { messageService } from '@/services/message';
|
||||
import { Avatar, Text, Button, Loading, Divider } from '../../components/common';
|
||||
import { useResponsive, useBreakpointGTE } from '../../hooks';
|
||||
import { useResponsive } from '../../hooks';
|
||||
import {
|
||||
GroupResponse,
|
||||
GroupMemberResponse,
|
||||
@@ -58,10 +57,10 @@ import { AppBackButton } from '../../components/common';
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
|
||||
// 加群方式选项
|
||||
const JOIN_TYPE_OPTIONS: { value: JoinType; label: string; icon: string; desc: string }[] = [
|
||||
{ value: 0, label: '允许任何人加入', icon: 'earth', desc: '任何人都可以直接加入群聊' },
|
||||
{ value: 1, label: '需要管理员审批', icon: 'shield-check', desc: '新成员需要管理员审核' },
|
||||
{ value: 2, label: '不允许任何人加入', icon: 'lock', desc: '只能通过邀请加入群聊' },
|
||||
const JOIN_TYPE_OPTIONS: { value: JoinType; label: string; desc: string }[] = [
|
||||
{ value: 0, label: '允许任何人加入', desc: '任何人都可以直接加入群聊' },
|
||||
{ value: 1, label: '需要管理员审批', desc: '新成员需要管理员审核' },
|
||||
{ value: 2, label: '不允许任何人加入', desc: '只能通过邀请加入群聊' },
|
||||
];
|
||||
|
||||
const GroupInfoScreen: React.FC = () => {
|
||||
@@ -77,16 +76,7 @@ const GroupInfoScreen: React.FC = () => {
|
||||
const { currentUser } = useAuthStore();
|
||||
|
||||
// 响应式布局
|
||||
const { isDesktop, isTablet, width } = useResponsive();
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
|
||||
// 计算成员网格列数
|
||||
const memberColumns = useMemo(() => {
|
||||
if (width >= 1200) return 6;
|
||||
if (width >= 900) return 5;
|
||||
if (width >= 768) return 4;
|
||||
return 5; // 移动端默认
|
||||
}, [width]);
|
||||
const { width } = useResponsive();
|
||||
|
||||
// 群组信息状态
|
||||
const [group, setGroup] = useState<GroupResponse | null>(null);
|
||||
@@ -512,12 +502,6 @@ const GroupInfoScreen: React.FC = () => {
|
||||
return option?.label || '未知';
|
||||
};
|
||||
|
||||
// 获取加群方式图标
|
||||
const getJoinTypeIcon = (joinType: JoinType): string => {
|
||||
const option = JOIN_TYPE_OPTIONS.find(o => o.value === joinType);
|
||||
return option?.icon || 'help-circle';
|
||||
};
|
||||
|
||||
const handleCopyGroupNo = () => {
|
||||
if (!group) return;
|
||||
Clipboard.setString(String(group.id));
|
||||
@@ -530,47 +514,64 @@ const GroupInfoScreen: React.FC = () => {
|
||||
return `${raw.slice(0, 6)}...${raw.slice(-4)}`;
|
||||
};
|
||||
|
||||
// 渲染设置项
|
||||
// 渲染设置项:QQ 风格,左侧标题,右侧值/箭头
|
||||
const renderSettingItem = (
|
||||
icon: string,
|
||||
title: string,
|
||||
value?: string,
|
||||
onPress?: () => void,
|
||||
danger: boolean = false
|
||||
danger: boolean = false,
|
||||
subtitle?: string
|
||||
) => (
|
||||
<TouchableOpacity
|
||||
style={styles.settingItem}
|
||||
onPress={onPress}
|
||||
disabled={!onPress}
|
||||
activeOpacity={0.7}
|
||||
activeOpacity={onPress ? 0.6 : 1}
|
||||
>
|
||||
<View style={[styles.settingIconContainer, danger && styles.settingIconDanger]}>
|
||||
<MaterialCommunityIcons
|
||||
name={icon as any}
|
||||
size={20}
|
||||
color={danger ? colors.error.main : colors.primary.main}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text variant="body" style={danger ? styles.dangerText : undefined}>
|
||||
<View style={styles.settingItemLeft}>
|
||||
<Text variant="body" style={danger ? styles.dangerText : styles.settingItemTitle}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={2} style={styles.settingItemSubtitle}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.settingValueRow}>
|
||||
{value && (
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
|
||||
<Text variant="body" color={colors.text.secondary} numberOfLines={1} style={styles.settingValueText}>
|
||||
{value}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{onPress && (
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-right"
|
||||
size={22}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
<Text variant="body" color={colors.text.hint} style={styles.settingChevron}>
|
||||
›
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
// 渲染带开关的设置项
|
||||
const renderSwitchItem = (
|
||||
title: string,
|
||||
value: boolean,
|
||||
onValueChange: () => void,
|
||||
disabled?: boolean
|
||||
) => (
|
||||
<View style={styles.settingItem}>
|
||||
<Text variant="body" style={styles.settingItemTitle}>{title}</Text>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
disabled={disabled}
|
||||
trackColor={{ false: colors.divider, true: colors.primary.light }}
|
||||
thumbColor={value ? colors.primary.main : colors.background.paper}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
@@ -618,7 +619,7 @@ const GroupInfoScreen: React.FC = () => {
|
||||
<View style={styles.groupHeader}>
|
||||
<Avatar
|
||||
source={group.avatar}
|
||||
size={80}
|
||||
size={72}
|
||||
name={group.name}
|
||||
/>
|
||||
<View style={styles.groupInfo}>
|
||||
@@ -626,12 +627,10 @@ const GroupInfoScreen: React.FC = () => {
|
||||
{group.name}
|
||||
</Text>
|
||||
<View style={styles.groupMeta}>
|
||||
<MaterialCommunityIcons name="account-group" size={14} color={colors.text.secondary} />
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
{group.member_count} 人
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.hint}> · </Text>
|
||||
<MaterialCommunityIcons name={getJoinTypeIcon(group.join_type) as any} size={14} color={colors.text.secondary} />
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={1} style={{ flex: 1 }}>
|
||||
{getJoinTypeText(group.join_type)}
|
||||
</Text>
|
||||
@@ -641,7 +640,6 @@ const GroupInfoScreen: React.FC = () => {
|
||||
群号:{formatGroupNo(group.id)}
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.copyGroupNoBtn} onPress={handleCopyGroupNo} activeOpacity={0.7}>
|
||||
<MaterialCommunityIcons name="content-copy" size={14} color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.primary.main} style={styles.copyGroupNoBtnText}>
|
||||
复制
|
||||
</Text>
|
||||
@@ -651,14 +649,12 @@ const GroupInfoScreen: React.FC = () => {
|
||||
</View>
|
||||
{group.description ? (
|
||||
<View style={styles.descriptionContainer}>
|
||||
<MaterialCommunityIcons name="information-outline" size={16} color={colors.text.secondary} />
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.groupDesc}>
|
||||
{group.description}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.descriptionContainer}>
|
||||
<MaterialCommunityIcons name="information-outline" size={16} color={colors.text.hint} />
|
||||
<Text variant="body" color={colors.text.hint} style={styles.groupDesc}>
|
||||
暂无群描述
|
||||
</Text>
|
||||
@@ -669,37 +665,32 @@ const GroupInfoScreen: React.FC = () => {
|
||||
{/* 群公告 */}
|
||||
{announcements.length > 0 && (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.cardIconContainer}>
|
||||
<MaterialCommunityIcons name="bullhorn" size={18} color={colors.warning.main} />
|
||||
<TouchableOpacity
|
||||
style={styles.announcementRow}
|
||||
onPress={() => setAnnouncementModalVisible(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.announcementHeader}>
|
||||
<Text variant="body" style={styles.settingItemTitle}>群公告</Text>
|
||||
<Text variant="body" color={colors.text.hint} style={styles.settingChevron}>›</Text>
|
||||
</View>
|
||||
<Text variant="label" style={styles.cardTitle}>群公告</Text>
|
||||
</View>
|
||||
<View style={styles.announcementContent}>
|
||||
<Text variant="body" style={styles.announcementText}>
|
||||
<Text variant="body" color={colors.text.secondary} numberOfLines={2} style={styles.announcementPreviewText}>
|
||||
{announcements[0].content}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.announcementTime}>
|
||||
{(() => {
|
||||
const d = new Date(announcements[0].created_at);
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString();
|
||||
})()}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 成员预览 */}
|
||||
<View style={styles.card}>
|
||||
<TouchableOpacity style={styles.cardHeader} onPress={goToMembers} activeOpacity={0.7}>
|
||||
<View style={styles.cardIconContainer}>
|
||||
<MaterialCommunityIcons name="account-multiple" size={18} color={colors.info.main} />
|
||||
</View>
|
||||
<Text variant="label" style={styles.cardTitle}>群成员</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.memberCount}>
|
||||
<Text variant="body" style={styles.settingItemTitle}>群成员</Text>
|
||||
<View style={styles.cardValueRow}>
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.memberCount}>
|
||||
{group.member_count}人
|
||||
</Text>
|
||||
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
|
||||
<Text variant="body" color={colors.text.hint} style={styles.settingChevron}>›</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.memberPreview}>
|
||||
@@ -740,108 +731,42 @@ const GroupInfoScreen: React.FC = () => {
|
||||
|
||||
{/* 邀请成员按钮 */}
|
||||
<TouchableOpacity style={styles.inviteButton} onPress={openInviteModal} activeOpacity={0.8}>
|
||||
<View style={styles.inviteIconContainer}>
|
||||
<MaterialCommunityIcons name="account-plus" size={20} color={colors.primary.main} />
|
||||
</View>
|
||||
<Text variant="body" color={colors.primary.main} style={styles.inviteButtonText}>
|
||||
邀请新成员
|
||||
+ 邀请新成员
|
||||
</Text>
|
||||
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.primary.main} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 群名称 / 群描述 */}
|
||||
<View style={styles.card}>
|
||||
<View style={styles.settingsList}>
|
||||
{isAdmin && renderSettingItem('群名称', group.name, openEditModal)}
|
||||
{isAdmin && <Divider style={styles.settingDivider} />}
|
||||
{isOwner && renderSettingItem('加群方式', getJoinTypeText(group.join_type), () => setJoinTypeModalVisible(true))}
|
||||
{isOwner && <Divider style={styles.settingDivider} />}
|
||||
{isAdmin && renderSettingItem('群描述', group.description || '未设置', openEditModal)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 管理员设置 */}
|
||||
{isAdmin && (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={[styles.cardIconContainer, { backgroundColor: colors.primary.light + '20' }]}>
|
||||
<MaterialCommunityIcons name="shield-crown" size={18} color={colors.primary.main} />
|
||||
</View>
|
||||
<Text variant="label" style={styles.cardTitle}>群管理</Text>
|
||||
</View>
|
||||
<View style={styles.settingsList}>
|
||||
{renderSettingItem('pencil-outline', '修改群信息', undefined, openEditModal)}
|
||||
<Divider style={styles.settingDivider} />
|
||||
{isOwner && renderSettingItem('earth', '加群方式', getJoinTypeText(group.join_type), () => setJoinTypeModalVisible(true))}
|
||||
{renderSettingItem('发布群公告', undefined, () => setAnnouncementModalVisible(true))}
|
||||
{isOwner && <Divider style={styles.settingDivider} />}
|
||||
{isOwner && (
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingIconContainer}>
|
||||
<MaterialCommunityIcons name="microphone-off" size={20} color={colors.primary.main} />
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text variant="body">全员禁言</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
{group.mute_all ? '已开启' : '已关闭'}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={group.mute_all}
|
||||
onValueChange={handleToggleMuteAll}
|
||||
trackColor={{ false: colors.divider, true: colors.primary.light }}
|
||||
thumbColor={group.mute_all ? colors.primary.main : colors.background.paper}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<Divider style={styles.settingDivider} />
|
||||
{renderSettingItem('bullhorn-outline', '发布群公告', undefined, () => setAnnouncementModalVisible(true))}
|
||||
{isOwner && renderSwitchItem('全员禁言', group.mute_all, handleToggleMuteAll)}
|
||||
{isOwner && <Divider style={styles.settingDivider} />}
|
||||
{isOwner && renderSettingItem('account-switch', '转让群主', undefined, () => setTransferModalVisible(true))}
|
||||
{isOwner && renderSettingItem('转让群主', undefined, () => setTransferModalVisible(true))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 聊天设置 */}
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={[styles.cardIconContainer, { backgroundColor: colors.warning.light + '20' }]}>
|
||||
<MaterialCommunityIcons name="chat-outline" size={18} color={colors.warning.main} />
|
||||
</View>
|
||||
<Text variant="label" style={styles.cardTitle}>聊天设置</Text>
|
||||
</View>
|
||||
<View style={styles.settingsList}>
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingIconContainer}>
|
||||
<MaterialCommunityIcons name="pin-outline" size={20} color={colors.primary.main} />
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text variant="body">置顶群聊</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
{conversationId ? (isPinned ? '已置顶' : '未置顶') : '请从聊天页面进入后设置'}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isPinned}
|
||||
onValueChange={handleTogglePinned}
|
||||
disabled={!conversationId || pinLoading}
|
||||
trackColor={{ false: colors.divider, true: colors.primary.light }}
|
||||
thumbColor={isPinned ? colors.primary.main : colors.background.paper}
|
||||
/>
|
||||
</View>
|
||||
<Divider style={styles.settingDivider} />
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingIconContainer}>
|
||||
<MaterialCommunityIcons name="bell-off-outline" size={20} color={colors.primary.main} />
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text variant="body">消息免打扰</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
{conversationId ? (isNotificationMuted ? '已开启' : '未开启') : '请从聊天页面进入后设置'}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isNotificationMuted}
|
||||
onValueChange={handleToggleNotificationMuted}
|
||||
disabled={!conversationId}
|
||||
trackColor={{ false: colors.divider, true: colors.primary.light }}
|
||||
thumbColor={isNotificationMuted ? colors.primary.main : colors.background.paper}
|
||||
/>
|
||||
</View>
|
||||
<Divider style={styles.settingDivider} />
|
||||
{renderSettingItem(
|
||||
'magnify',
|
||||
'查找聊天记录',
|
||||
undefined,
|
||||
'图片、视频、文件等',
|
||||
conversationId
|
||||
? () => router.push(hrefs.hrefMessageSearch({
|
||||
conversationId,
|
||||
@@ -850,6 +775,10 @@ const GroupInfoScreen: React.FC = () => {
|
||||
}))
|
||||
: undefined,
|
||||
)}
|
||||
<Divider style={styles.settingDivider} />
|
||||
{renderSwitchItem('设为置顶', isPinned, handleTogglePinned, !conversationId || pinLoading)}
|
||||
<Divider style={styles.settingDivider} />
|
||||
{renderSwitchItem('消息免打扰', isNotificationMuted, handleToggleNotificationMuted, !conversationId)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -861,7 +790,6 @@ const GroupInfoScreen: React.FC = () => {
|
||||
onPress={handleDissolveGroup}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="delete-forever" size={22} color={colors.error.main} />
|
||||
<Text variant="body" color={colors.error.main} style={styles.dangerButtonText}>
|
||||
解散群组
|
||||
</Text>
|
||||
@@ -872,7 +800,6 @@ const GroupInfoScreen: React.FC = () => {
|
||||
onPress={handleLeaveGroup}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="logout" size={22} color={colors.error.main} />
|
||||
<Text variant="body" color={colors.error.main} style={styles.dangerButtonText}>
|
||||
退出群聊
|
||||
</Text>
|
||||
@@ -919,7 +846,7 @@ const GroupInfoScreen: React.FC = () => {
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.editAvatarButton} onPress={handleSelectGroupAvatar} disabled={uploadingAvatar}>
|
||||
<MaterialCommunityIcons name="camera" size={16} color={colors.background.paper} />
|
||||
<Text variant="caption" color={colors.background.paper}>更换</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -1032,13 +959,6 @@ const GroupInfoScreen: React.FC = () => {
|
||||
onPress={() => handleSetJoinType(option.value)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.joinTypeIcon, group.join_type === option.value && styles.joinTypeIconSelected]}>
|
||||
<MaterialCommunityIcons
|
||||
name={option.icon as any}
|
||||
size={24}
|
||||
color={group.join_type === option.value ? colors.primary.main : colors.text.secondary}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.joinTypeInfo}>
|
||||
<Text
|
||||
variant="body"
|
||||
@@ -1051,7 +971,7 @@ const GroupInfoScreen: React.FC = () => {
|
||||
</Text>
|
||||
</View>
|
||||
{group.join_type === option.value && (
|
||||
<MaterialCommunityIcons name="check-circle" size={24} color={colors.primary.main} />
|
||||
<Text variant="body" color={colors.primary.main} style={styles.joinTypeCheck}>✓</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
@@ -1109,7 +1029,7 @@ const GroupInfoScreen: React.FC = () => {
|
||||
</View>
|
||||
<View style={[styles.transferRadio, transferUserId === member.user_id && styles.transferRadioSelected]}>
|
||||
{transferUserId === member.user_id && (
|
||||
<MaterialCommunityIcons name="check" size={14} color={colors.background.paper} />
|
||||
<Text variant="caption" color={colors.background.paper}>✓</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
@@ -1145,16 +1065,17 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.paper,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
pageHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 0,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: colors.divider + '60',
|
||||
},
|
||||
pageTitle: {
|
||||
fontWeight: '600',
|
||||
@@ -1168,7 +1089,6 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.xl * 2,
|
||||
},
|
||||
errorContainer: {
|
||||
@@ -1177,16 +1097,17 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
// 头部区域 - 扁平化无卡片
|
||||
// 头部区域 - QQ 风格简洁白底
|
||||
headerCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
groupHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
groupInfo: {
|
||||
marginLeft: spacing.lg,
|
||||
@@ -1194,7 +1115,7 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
},
|
||||
groupName: {
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes['2xl'],
|
||||
fontSize: fontSizes.xl,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
groupMeta: {
|
||||
@@ -1209,86 +1130,68 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
copyGroupNoBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.light + '15',
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
copyGroupNoBtnText: {
|
||||
marginLeft: 4,
|
||||
fontWeight: '500',
|
||||
},
|
||||
descriptionContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.md,
|
||||
},
|
||||
groupDesc: {
|
||||
marginLeft: spacing.sm,
|
||||
flex: 1,
|
||||
lineHeight: 22,
|
||||
fontWeight: '400',
|
||||
},
|
||||
|
||||
// 通用卡片样式 - 扁平化:无阴影无圆角,使用分割线
|
||||
// 通用卡片样式 - 白底分隔(QQ 风格)
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
marginBottom: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
cardIconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.info.light + '15',
|
||||
justifyContent: 'center',
|
||||
cardValueRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
cardTitle: {
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.md,
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// 公告样式 - 扁平化
|
||||
announcementContent: {
|
||||
backgroundColor: colors.warning.light + '08',
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.warning.main,
|
||||
// 公告样式 - QQ 风格:标题行 + 内容预览
|
||||
announcementRow: {
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
announcementText: {
|
||||
lineHeight: 22,
|
||||
announcementHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.sm,
|
||||
fontWeight: '400',
|
||||
},
|
||||
announcementTime: {
|
||||
textAlign: 'right',
|
||||
fontWeight: '500',
|
||||
announcementPreviewText: {
|
||||
lineHeight: 22,
|
||||
fontWeight: '400',
|
||||
},
|
||||
|
||||
// 成员预览样式
|
||||
memberCount: {
|
||||
marginRight: spacing.xs,
|
||||
fontWeight: '600',
|
||||
color: colors.text.secondary,
|
||||
fontWeight: '500',
|
||||
},
|
||||
memberPreview: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'nowrap',
|
||||
marginBottom: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
paddingLeft: spacing.xs,
|
||||
},
|
||||
memberAvatar: {
|
||||
@@ -1320,8 +1223,6 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
backgroundColor: colors.background.default,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider + '60',
|
||||
marginLeft: -8,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
@@ -1331,88 +1232,81 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
|
||||
// 邀请按钮样式 - 扁平化
|
||||
// 邀请按钮样式
|
||||
inviteButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.primary.light + '10',
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.primary.light + '60',
|
||||
borderStyle: 'dashed',
|
||||
},
|
||||
inviteIconContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.background.default,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
inviteButtonText: {
|
||||
fontWeight: '700',
|
||||
marginRight: spacing.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// 设置项样式 - 扁平化:无圆角背景,简洁分割线
|
||||
// 设置项样式 - QQ 风格:无图标,标题在左,值/箭头在右
|
||||
settingsList: {
|
||||
marginTop: 0,
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: 0,
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
},
|
||||
settingIconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.background.default,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
settingIconDanger: {
|
||||
backgroundColor: colors.error.light + '15',
|
||||
},
|
||||
settingContent: {
|
||||
settingItemLeft: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
settingItemTitle: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '500',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
settingItemSubtitle: {
|
||||
marginTop: spacing.xs,
|
||||
fontWeight: '400',
|
||||
lineHeight: 20,
|
||||
},
|
||||
settingValueRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
settingValueText: {
|
||||
maxWidth: 180,
|
||||
},
|
||||
settingChevron: {
|
||||
fontSize: fontSizes.xl,
|
||||
marginLeft: spacing.xs,
|
||||
fontWeight: '300',
|
||||
},
|
||||
dangerText: {
|
||||
color: colors.error.main,
|
||||
fontWeight: '600',
|
||||
},
|
||||
settingDivider: {
|
||||
marginLeft: 52,
|
||||
width: 'auto',
|
||||
marginVertical: 0,
|
||||
opacity: 0.5,
|
||||
},
|
||||
|
||||
// 危险操作区域 - 扁平化
|
||||
// 危险操作区域
|
||||
dangerCard: {
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
marginTop: spacing.sm,
|
||||
marginTop: 0,
|
||||
},
|
||||
dangerButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
dangerButtonText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// 模态框样式 - 扁平化
|
||||
// 模态框样式
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.45)',
|
||||
@@ -1436,10 +1330,6 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: colors.divider + '60',
|
||||
},
|
||||
modalHeaderButton: {
|
||||
paddingVertical: spacing.sm,
|
||||
minWidth: 60,
|
||||
},
|
||||
modalTitle: {
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.xl,
|
||||
@@ -1447,12 +1337,8 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
saveButton: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
confirmButton: {
|
||||
fontWeight: '600',
|
||||
textAlign: 'right',
|
||||
},
|
||||
|
||||
// 编辑表单样式 - 扁平化
|
||||
// 编辑表单样式
|
||||
editForm: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
@@ -1481,11 +1367,9 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
backgroundColor: colors.primary.main,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: borderRadius.md,
|
||||
borderWidth: 3,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
@@ -1542,36 +1426,25 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// 加群方式样式 - 更现代的选中状态
|
||||
// 加群方式样式
|
||||
joinTypeList: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
joinTypeItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider + '80',
|
||||
backgroundColor: colors.background.default,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
joinTypeItemSelected: {
|
||||
borderColor: colors.primary.main,
|
||||
backgroundColor: colors.primary.light + '12',
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
joinTypeIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.background.paper,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
joinTypeIconSelected: {
|
||||
backgroundColor: colors.primary.light + '25',
|
||||
},
|
||||
joinTypeInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
@@ -1579,6 +1452,10 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
fontWeight: '700',
|
||||
color: colors.primary.main,
|
||||
},
|
||||
joinTypeCheck: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '700',
|
||||
},
|
||||
|
||||
// 转让群主样式
|
||||
transferDesc: {
|
||||
@@ -1632,79 +1509,6 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
transferButton: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
|
||||
// 邀请成员模态框样式
|
||||
tabContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.xs,
|
||||
marginBottom: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider + '60',
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm,
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider + '60',
|
||||
},
|
||||
tabTextActive: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
selectedBadge: {
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.sm,
|
||||
alignSelf: 'flex-start',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
friendListContent: {
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
friendItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginBottom: spacing.sm,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
friendItemSelected: {
|
||||
backgroundColor: colors.primary.light + '12',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary.light + '60',
|
||||
},
|
||||
friendInfo: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.md,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
nickname: {
|
||||
fontWeight: '600',
|
||||
marginBottom: 2,
|
||||
},
|
||||
checkbox: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.divider,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
checkboxSelected: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
* 显示群成员列表,支持管理员管理成员
|
||||
* 支持响应式网格布局
|
||||
* 使用游标分页
|
||||
* QQ 风格:移除装饰性图标,使用简洁的文字列表
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { hrefUserProfile } from '../../navigation/hrefs';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
@@ -33,8 +33,8 @@ import {
|
||||
ICursorGroupMemberListPagedSource,
|
||||
} from '../../stores/group';
|
||||
import { enrichGroupMembersWithUserProfiles } from '../../stores/group';
|
||||
import { Avatar, Text, Button, Loading, EmptyState, Divider, ResponsiveContainer, AppBackButton } from '../../components/common';
|
||||
import { useResponsive, useBreakpointGTE } from '../../hooks';
|
||||
import { Avatar, Text, Button, Loading, EmptyState, Divider, AppBackButton } from '../../components/common';
|
||||
import { useResponsive } from '../../hooks';
|
||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||
import {
|
||||
GroupMemberResponse,
|
||||
@@ -44,13 +44,6 @@ import { firstRouteParam } from '../../navigation/paramUtils';
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
const GROUP_MEMBER_REMOTE_LIST_KIND: GroupMemberListSourceKind = 'cursor';
|
||||
|
||||
// 网格布局配置
|
||||
const GRID_CONFIG = {
|
||||
mobile: { columns: 1, itemWidth: '100%' },
|
||||
tablet: { columns: 2, itemWidth: '48%' },
|
||||
desktop: { columns: 3, itemWidth: '31%' },
|
||||
};
|
||||
|
||||
// 成员分组
|
||||
interface MemberGroup {
|
||||
title: string;
|
||||
@@ -66,15 +59,7 @@ const GroupMembersScreen: React.FC = () => {
|
||||
const { currentUser } = useAuthStore();
|
||||
|
||||
// 响应式布局
|
||||
const { isDesktop, isTablet, width } = useResponsive();
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
|
||||
// 计算网格列数
|
||||
const gridConfig = useMemo(() => {
|
||||
if (width >= 1024) return GRID_CONFIG.desktop;
|
||||
if (width >= 768) return GRID_CONFIG.tablet;
|
||||
return GRID_CONFIG.mobile;
|
||||
}, [width]);
|
||||
const { width } = useResponsive();
|
||||
|
||||
const memberListSource = useMemo<ICursorGroupMemberListPagedSource>(() => {
|
||||
return createCursorGroupMemberListSource(GROUP_MEMBER_REMOTE_LIST_KIND, groupId, 50);
|
||||
@@ -431,17 +416,17 @@ const GroupMembersScreen: React.FC = () => {
|
||||
</Text>
|
||||
{item.muted && (
|
||||
<View style={styles.mutedBadge}>
|
||||
<MaterialCommunityIcons name="microphone-off" size={12} color={colors.error.main} />
|
||||
<Text variant="label" color={colors.error.main}>禁言中</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{canManage && (
|
||||
<TouchableOpacity
|
||||
style={styles.moreButton}
|
||||
onPress={() => openActionModal(item)}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<MaterialCommunityIcons name="dots-vertical" size={20} color={colors.text.hint} />
|
||||
<Text variant="body" color={colors.text.hint} style={styles.moreButtonText}>···</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
@@ -468,7 +453,6 @@ const GroupMembersScreen: React.FC = () => {
|
||||
<EmptyState
|
||||
title="暂无成员"
|
||||
description="群组还没有成员"
|
||||
icon="account-group-outline"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -509,11 +493,6 @@ const GroupMembersScreen: React.FC = () => {
|
||||
onPress={handleToggleAdmin}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={selectedMember.role === 'admin' ? 'account-remove' : 'account-plus'}
|
||||
size={22}
|
||||
color={colors.text.primary}
|
||||
/>
|
||||
<Text variant="body" style={styles.actionText}>
|
||||
{selectedMember.role === 'admin' ? '取消管理员' : '设为管理员'}
|
||||
</Text>
|
||||
@@ -526,11 +505,6 @@ const GroupMembersScreen: React.FC = () => {
|
||||
onPress={handleToggleMute}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={selectedMember.muted ? 'microphone' : 'microphone-off'}
|
||||
size={22}
|
||||
color={selectedMember.muted ? colors.success.main : colors.error.main}
|
||||
/>
|
||||
<Text
|
||||
variant="body"
|
||||
color={selectedMember.muted ? colors.success.main : colors.error.main}
|
||||
@@ -546,7 +520,6 @@ const GroupMembersScreen: React.FC = () => {
|
||||
onPress={handleRemoveMember}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<MaterialCommunityIcons name="account-remove" size={22} color={colors.error.main} />
|
||||
<Text variant="body" color={colors.error.main} style={styles.actionText}>
|
||||
移除成员
|
||||
</Text>
|
||||
@@ -690,21 +663,20 @@ function createGroupMembersStyles(colors: AppColors) {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
// Header 样式 - 扁平化
|
||||
// Header 样式
|
||||
pageHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: colors.divider + '60',
|
||||
},
|
||||
pageTitle: {
|
||||
fontWeight: '800',
|
||||
fontSize: fontSizes.xl + 1,
|
||||
letterSpacing: 0.5,
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.xl,
|
||||
},
|
||||
pageHeaderPlaceholder: {
|
||||
width: 40,
|
||||
@@ -712,6 +684,7 @@ function createGroupMembersStyles(colors: AppColors) {
|
||||
},
|
||||
section: {
|
||||
marginBottom: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
@@ -720,8 +693,6 @@ function createGroupMembersStyles(colors: AppColors) {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: colors.background.default,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: colors.divider + '40',
|
||||
},
|
||||
memberItem: {
|
||||
flexDirection: 'row',
|
||||
@@ -742,25 +713,31 @@ function createGroupMembersStyles(colors: AppColors) {
|
||||
marginBottom: 2,
|
||||
},
|
||||
memberName: {
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.md + 1,
|
||||
letterSpacing: 0.3,
|
||||
fontWeight: '500',
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
roleBadge: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
marginLeft: spacing.sm,
|
||||
fontWeight: '800',
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.background.paper,
|
||||
},
|
||||
mutedBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 2,
|
||||
},
|
||||
// 模态框样式 - 更现代
|
||||
moreButton: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
minWidth: 36,
|
||||
alignItems: 'center',
|
||||
},
|
||||
moreButtonText: {
|
||||
fontSize: fontSizes['2xl'],
|
||||
fontWeight: '700',
|
||||
lineHeight: fontSizes['2xl'],
|
||||
color: colors.text.hint,
|
||||
},
|
||||
// 模态框样式
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.45)',
|
||||
@@ -781,32 +758,25 @@ function createGroupMembersStyles(colors: AppColors) {
|
||||
borderBottomColor: colors.divider + '60',
|
||||
},
|
||||
modalTitle: {
|
||||
fontWeight: '800',
|
||||
fontSize: fontSizes.xl + 1,
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.xl,
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
actionItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
marginLeft: -spacing.sm,
|
||||
marginRight: -spacing.sm,
|
||||
borderRadius: borderRadius.md,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: colors.divider + '40',
|
||||
},
|
||||
actionText: {
|
||||
marginLeft: spacing.md,
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
inputLabel: {
|
||||
marginBottom: spacing.xs,
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.sm + 1,
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.background.default,
|
||||
@@ -815,7 +785,7 @@ function createGroupMembersStyles(colors: AppColors) {
|
||||
paddingVertical: spacing.md,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
borderWidth: 1.5,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider + '80',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
|
||||
@@ -61,7 +61,7 @@ import { ConversationListRow } from './components/ConversationListRow';
|
||||
// 导入 NotificationsScreen 用于在内部显示
|
||||
import { NotificationsScreen } from './NotificationsScreen';
|
||||
// 导入扫码组件
|
||||
import { SearchBar, TabBar } from '../../components/business';
|
||||
import { SearchBar, SearchHeader, TabBar } from '../../components/business';
|
||||
import { QRCodeScanner } from '../../components/business/QRCodeScanner';
|
||||
|
||||
// 系统通知会话特殊ID
|
||||
@@ -668,33 +668,27 @@ export const MessageListScreen: React.FC = () => {
|
||||
// 渲染搜索模式UI(扁平化现代化设计)
|
||||
const renderSearchMode = () => (
|
||||
<View style={styles.searchModeContainer}>
|
||||
{/* 搜索头部 */}
|
||||
<View style={[styles.searchHeader, { paddingTop: spacing.sm }]}>
|
||||
<View style={styles.searchShell}>
|
||||
<SearchBar
|
||||
{/* 搜索顶栏(统一组件,内置硬件返回键拦截) */}
|
||||
<SearchHeader
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
onSubmit={() => performSearch(searchText)}
|
||||
onCancel={handleCloseSearch}
|
||||
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
|
||||
autoFocus
|
||||
compact
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={handleCloseSearch}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.cancelText}>取消</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Tab切换 - 现代化风格 */}
|
||||
{/* Tab切换 - 与主页一致的下划线风格 */}
|
||||
<View style={styles.searchTabContainer}>
|
||||
<TabBar
|
||||
tabs={['聊天记录', '用户']}
|
||||
activeIndex={activeSearchTab === 'chat' ? 0 : 1}
|
||||
onTabChange={(index) => setActiveSearchTab(index === 0 ? 'chat' : 'user')}
|
||||
variant="modern"
|
||||
variant="home"
|
||||
style={{
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1048,32 +1042,10 @@ function createMessageListStyles(colors: AppColors) {
|
||||
},
|
||||
searchModeContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
searchHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: `${colors.divider}70`,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
searchShell: {
|
||||
flex: 1,
|
||||
},
|
||||
cancelButton: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
cancelText: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
color: colors.primary.main,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
searchTabContainer: {
|
||||
backgroundColor: colors.background.paper,
|
||||
backgroundColor: colors.background.default,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: `${colors.divider}50`,
|
||||
},
|
||||
|
||||
@@ -17,7 +17,6 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import {
|
||||
spacing,
|
||||
fontSizes,
|
||||
borderRadius,
|
||||
useAppColors,
|
||||
type AppColors,
|
||||
} from '../../theme';
|
||||
@@ -26,8 +25,8 @@ import { extractTextFromSegments, UserDTO } from '../../types/dto';
|
||||
import { messageRepository } from '../../database';
|
||||
import { userManager } from '../../stores/user';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { Avatar, Text, EmptyState, AppBackButton } from '../../components/common';
|
||||
import { SearchBar } from '../../components/business';
|
||||
import { Avatar, Text, EmptyState } from '../../components/common';
|
||||
import { SearchHeader } from '../../components/business';
|
||||
import HighlightText from '../../components/common/HighlightText';
|
||||
import { formatTime } from '../../utils/formatTime';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
@@ -233,24 +232,16 @@ export const MessageSearchScreen: React.FC = () => {
|
||||
}, [loading, results.length, styles, colors]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<View style={styles.header}>
|
||||
<AppBackButton onPress={() => router.back()} />
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
{conversationName}
|
||||
</Text>
|
||||
<View style={styles.headerSpacer} />
|
||||
</View>
|
||||
|
||||
<View style={styles.searchWrap}>
|
||||
<SearchBar
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
{/* 搜索顶栏(与 HomeScreen 搜索页一致的统一组件) */}
|
||||
<SearchHeader
|
||||
value={keyword}
|
||||
onChangeText={handleTextChanged}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder="搜索聊天记录"
|
||||
autoFocus
|
||||
onCancel={() => router.back()}
|
||||
placeholder={`搜索 ${conversationName} 的聊天记录`}
|
||||
compact
|
||||
/>
|
||||
</View>
|
||||
|
||||
<FlashList
|
||||
data={results}
|
||||
@@ -272,29 +263,6 @@ function createMessageSearchStyles(colors: AppColors) {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
headerTitle: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
textAlign: 'center',
|
||||
marginHorizontal: spacing.sm,
|
||||
},
|
||||
headerSpacer: {
|
||||
width: 40,
|
||||
},
|
||||
searchWrap: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
resultItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { spacing, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { authService } from '@/services/auth';
|
||||
import { messageService } from '@/services/message';
|
||||
@@ -133,6 +132,9 @@ const PrivateChatInfoScreen: React.FC = () => {
|
||||
onPress: async () => {
|
||||
try {
|
||||
await messageRepository.clearConversation(conversationId);
|
||||
// 同步清空内存消息 + hydrated 标志,保证返回聊天页时状态与 DB 一致。
|
||||
// 否则 hydrated 残留 + 内存旧消息会显示已清空的记录。
|
||||
useMessageStore.getState().clearMessages(conversationId);
|
||||
Alert.alert('成功', '聊天记录已清空');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '清空聊天记录失败');
|
||||
@@ -259,34 +261,28 @@ const PrivateChatInfoScreen: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染设置项(带开关)
|
||||
// 渲染开关设置项 - QQ 风格:左侧标题,右侧开关
|
||||
const renderSwitchItem = (
|
||||
icon: string,
|
||||
title: string,
|
||||
value: boolean,
|
||||
onValueChange: () => void
|
||||
) => (
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingIconContainer}>
|
||||
<MaterialCommunityIcons name={icon as any} size={20} color={colors.primary.main} />
|
||||
</View>
|
||||
<Text variant="body" style={styles.settingTitle}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text variant="body" style={styles.settingTitle}>{title}</Text>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ false: '#E0E0E0', true: colors.primary.light }}
|
||||
thumbColor={value ? colors.primary.main : colors.background.default}
|
||||
trackColor={{ false: colors.divider, true: colors.primary.light }}
|
||||
thumbColor={value ? colors.primary.main : colors.background.paper}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染设置项(带箭头)
|
||||
// 渲染操作项 - QQ 风格:左侧标题,右侧值/箭头
|
||||
const renderActionItem = (
|
||||
icon: string,
|
||||
title: string,
|
||||
onPress: () => void,
|
||||
value?: string,
|
||||
danger: boolean = false
|
||||
) => (
|
||||
<TouchableOpacity
|
||||
@@ -294,17 +290,17 @@ const PrivateChatInfoScreen: React.FC = () => {
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.settingIconContainer, danger && styles.settingIconDanger]}>
|
||||
<MaterialCommunityIcons
|
||||
name={icon as any}
|
||||
size={20}
|
||||
color={danger ? colors.error.main : colors.primary.main}
|
||||
/>
|
||||
</View>
|
||||
<Text variant="body" style={danger ? [styles.settingTitle, styles.dangerText] : styles.settingTitle}>
|
||||
<Text variant="body" style={danger ? styles.dangerText : styles.settingTitle}>
|
||||
{title}
|
||||
</Text>
|
||||
<MaterialCommunityIcons name="chevron-right" size={22} color={colors.text.hint} />
|
||||
<View style={styles.settingValueRow}>
|
||||
{value && (
|
||||
<Text variant="body" color={colors.text.secondary} numberOfLines={1} style={styles.settingValueText}>
|
||||
{value}
|
||||
</Text>
|
||||
)}
|
||||
<Text variant="body" color={colors.text.hint} style={styles.settingChevron}>›</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -348,7 +344,7 @@ const PrivateChatInfoScreen: React.FC = () => {
|
||||
>
|
||||
<Avatar
|
||||
source={displayUser.avatar}
|
||||
size={80}
|
||||
size={72}
|
||||
name={displayUser.nickname || ''}
|
||||
/>
|
||||
<View style={styles.userInfo}>
|
||||
@@ -361,59 +357,53 @@ const PrivateChatInfoScreen: React.FC = () => {
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<MaterialCommunityIcons name="chevron-right" size={24} color={colors.text.hint} />
|
||||
<Text variant="body" color={colors.text.hint} style={styles.settingChevron}>›</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 聊天设置 */}
|
||||
<View style={styles.section}>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
聊天设置
|
||||
</Text>
|
||||
<View style={styles.card}>
|
||||
{renderSwitchItem('bell-off-outline', '消息免打扰', isMuted, toggleMute)}
|
||||
<View style={styles.settingsList}>
|
||||
{renderSwitchItem('消息免打扰', isMuted, toggleMute)}
|
||||
<Divider style={styles.divider} />
|
||||
{renderSwitchItem('pin-outline', '置顶聊天', isPinned, togglePin)}
|
||||
{renderSwitchItem('置顶聊天', isPinned, togglePin)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 聊天记录管理 */}
|
||||
<View style={styles.section}>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
聊天记录
|
||||
</Text>
|
||||
<View style={styles.card}>
|
||||
{renderActionItem('magnify', '查找聊天记录', handleSearchMessages)}
|
||||
<View style={styles.settingsList}>
|
||||
{renderActionItem('查找聊天记录', handleSearchMessages)}
|
||||
<Divider style={styles.divider} />
|
||||
{renderActionItem('delete-sweep-outline', '清空聊天记录', handleClearHistory, true)}
|
||||
{renderActionItem('清空聊天记录', handleClearHistory, undefined, true)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 其他操作 */}
|
||||
<View style={styles.section}>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
其他
|
||||
</Text>
|
||||
<View style={styles.card}>
|
||||
{renderActionItem('shield-alert-outline', '投诉', handleReport, true)}
|
||||
<View style={styles.settingsList}>
|
||||
{renderActionItem('投诉', handleReport, undefined, true)}
|
||||
<Divider style={styles.divider} />
|
||||
{renderActionItem(
|
||||
isBlocked ? 'account-check-outline' : 'account-cancel-outline',
|
||||
isBlocked ? '取消拉黑' : '拉黑用户',
|
||||
handleBlockUser,
|
||||
undefined,
|
||||
true
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 删除聊天按钮 */}
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="删除并退出聊天"
|
||||
<View style={[styles.card, styles.dangerCard]}>
|
||||
<TouchableOpacity
|
||||
style={styles.dangerButton}
|
||||
onPress={handleDeleteAndExit}
|
||||
variant="danger"
|
||||
style={styles.deleteButton}
|
||||
/>
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text variant="body" color={colors.error.main} style={styles.dangerButtonText}>
|
||||
删除并退出聊天
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
@@ -424,16 +414,17 @@ function createPrivateChatInfoStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.paper,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
pageHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 0,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: colors.divider + '60',
|
||||
},
|
||||
pageTitle: {
|
||||
fontWeight: '600',
|
||||
@@ -447,11 +438,11 @@ function createPrivateChatInfoStyles(colors: AppColors) {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.xl,
|
||||
paddingBottom: spacing.xl * 2,
|
||||
},
|
||||
headerCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
@@ -465,57 +456,62 @@ function createPrivateChatInfoStyles(colors: AppColors) {
|
||||
},
|
||||
userName: {
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes['2xl'],
|
||||
fontSize: fontSizes.xl,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: spacing.sm,
|
||||
marginLeft: spacing.sm,
|
||||
fontWeight: '500',
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
overflow: 'hidden',
|
||||
marginBottom: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
settingsList: {
|
||||
marginTop: 0,
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: 0,
|
||||
},
|
||||
settingIconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.background.default,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
settingIconDanger: {
|
||||
backgroundColor: colors.error.light + '15',
|
||||
},
|
||||
settingTitle: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '500',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
settingValueRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
settingValueText: {
|
||||
maxWidth: 180,
|
||||
},
|
||||
settingChevron: {
|
||||
fontSize: fontSizes.xl,
|
||||
marginLeft: spacing.xs,
|
||||
fontWeight: '300',
|
||||
},
|
||||
dangerText: {
|
||||
color: colors.error.main,
|
||||
fontWeight: '600',
|
||||
},
|
||||
divider: {
|
||||
marginLeft: 52,
|
||||
width: 'auto',
|
||||
marginVertical: 0,
|
||||
opacity: 0.5,
|
||||
},
|
||||
deleteButton: {
|
||||
marginTop: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
height: 52,
|
||||
dangerCard: {
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
marginTop: 0,
|
||||
},
|
||||
dangerButton: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.lg,
|
||||
},
|
||||
dangerButtonText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -216,10 +216,9 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
<TouchableOpacity
|
||||
style={styles.sendButtonActive}
|
||||
onPress={onSend}
|
||||
activeOpacity={0.85}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<View style={styles.sendButtonShine} pointerEvents="none" />
|
||||
<MaterialCommunityIcons name="send" size={18} color="#FFF" />
|
||||
<Text style={styles.sendButtonText}>发送</Text>
|
||||
</TouchableOpacity>
|
||||
) : isComposerBusy && !isDisabled ? (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -4,21 +4,23 @@
|
||||
* 支持响应式布局(宽屏下优化显示)
|
||||
*/
|
||||
|
||||
import React, { useRef, useMemo, useCallback } from 'react';
|
||||
import React, { useRef, useMemo, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
GestureResponderEvent,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Avatar, Text, ImageGridItem } from '../../../../components/common';
|
||||
import { useChatScreenStyles } from './styles';
|
||||
import { useResponsive, useBreakpointGTE } from '../../../../hooks';
|
||||
import { MessageBubbleProps, SenderInfo, MenuPosition, GroupMessage } from './types';
|
||||
import { MessageBubbleProps, SenderInfo, MenuPosition } from './types';
|
||||
import { MessageSegmentsRenderer } from './SegmentRenderer';
|
||||
import { MessageSegment, ImageSegmentData, extractTextFromSegments } from '../../../../types/dto';
|
||||
import { MessageResponse } from '@/types/dto/message';
|
||||
import { SwipeableMessageBubble } from './SwipeableMessageBubble';
|
||||
// 获取屏幕宽度
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
@@ -41,6 +43,8 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
otherUserLastReadSeq,
|
||||
selectedMessageId,
|
||||
messageMap,
|
||||
getCachedReply,
|
||||
ensureReplyMessage,
|
||||
onLongPress,
|
||||
onAvatarPress,
|
||||
onAvatarLongPress,
|
||||
@@ -49,6 +53,7 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
onImagePress,
|
||||
onReply,
|
||||
onReplyPress,
|
||||
onRetrySend,
|
||||
}) => {
|
||||
const bubbleRef = useRef<View>(null);
|
||||
const pressPositionRef = useRef({ x: 0, y: 0 });
|
||||
@@ -93,6 +98,8 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
const isMe = message.sender_id === currentUserId;
|
||||
const showTime = shouldShowTime(index);
|
||||
const isRecalled = message.status === 'recalled';
|
||||
const isPending = message.status === 'pending';
|
||||
const isFailed = message.status === 'failed';
|
||||
const isRead = !isGroupChat && isMe && message.seq <= otherUserLastReadSeq;
|
||||
const isLastReadMessage = !isGroupChat && isMe && message.seq === otherUserLastReadSeq;
|
||||
|
||||
@@ -108,6 +115,24 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
segmentsWithoutReply.length > 0 &&
|
||||
segmentsWithoutReply.every(s => s.type === 'image');
|
||||
|
||||
// 引用消息回填:本条消息若含 reply segment 且被引用消息不在 messageMap 中,
|
||||
// 渲染时触发懒加载(内存缓存 → 本地 SQLite → 服务端),回填成功后由缓存版本变化驱动重渲染。
|
||||
const replySegmentId = useMemo(() => {
|
||||
const replySeg = segments.find(s => s.type === 'reply');
|
||||
if (!replySeg) return null;
|
||||
const replyId = String((replySeg.data as { id?: string } | undefined)?.id ?? '');
|
||||
return replyId || null;
|
||||
}, [segments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!replySegmentId || !ensureReplyMessage) return;
|
||||
// messageMap 命中则无需回填
|
||||
if (messageMap?.has(replySegmentId)) return;
|
||||
// 已回填过(含 null 不可见)则不重复请求
|
||||
if (getCachedReply?.(replySegmentId) !== undefined) return;
|
||||
ensureReplyMessage(replySegmentId);
|
||||
}, [replySegmentId, messageMap, ensureReplyMessage, getCachedReply]);
|
||||
|
||||
// 提取所有图片 segments
|
||||
const imageSegments = segments
|
||||
.filter(s => s.type === 'image')
|
||||
@@ -273,23 +298,27 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
}
|
||||
|
||||
// 使用消息链渲染(必须使用 segments 格式)
|
||||
// 从 segments 中获取被回复消息的 ID 和 seq,然后从 messageMap 中查找
|
||||
const getReplyMessage = (): GroupMessage | undefined => {
|
||||
// 从 segments 中获取被回复消息的 ID 和 seq,依次查 messageMap → 回填缓存
|
||||
const getReplyMessage = (): MessageResponse | undefined => {
|
||||
const replySegment = segments.find(s => s.type === 'reply');
|
||||
if (replySegment && messageMap) {
|
||||
if (!replySegment) return undefined;
|
||||
const replyData = replySegment.data as { id: string; seq?: number };
|
||||
const replyId = String(replyData.id);
|
||||
|
||||
// 首先尝试通过 ID 查找
|
||||
// 1. messageMap 命中(已加载到内存的消息)
|
||||
if (messageMap) {
|
||||
let found = messageMap.get(replyId);
|
||||
|
||||
// 如果通过 ID 找不到,尝试通过 seq 查找
|
||||
// 通过 ID 找不到,尝试通过 seq 查找
|
||||
if (!found && replyData.seq !== undefined) {
|
||||
found = Array.from(messageMap.values()).find(msg => msg.seq === replyData.seq);
|
||||
}
|
||||
|
||||
return found;
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
// 2. 回填缓存命中(懒加载回填的被引用消息,可能来自本地 SQLite 或服务端)
|
||||
const cached = getCachedReply?.(replyId);
|
||||
if (cached) return cached;
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -311,13 +340,21 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
// 检查是否有回复,用于调整气泡样式
|
||||
const hasReply = segments.some(s => s.type === 'reply');
|
||||
|
||||
// 计算引用回填状态,供占位区分"加载中"与"不可见"
|
||||
const replyMsg = getReplyMessage();
|
||||
// replyMsg 命中(含 messageMap 与缓存)即为 ready,无需 loading/notFound 标记
|
||||
const replyLoading = !replyMsg && replySegmentId ? getCachedReply?.(replySegmentId) === undefined : false;
|
||||
const replyNotFound = !replyMsg && replySegmentId ? getCachedReply?.(replySegmentId) === null : false;
|
||||
|
||||
return renderBubbleShell(
|
||||
<MessageSegmentsRenderer
|
||||
segments={segments}
|
||||
isMe={isMe}
|
||||
currentUserId={currentUserId}
|
||||
memberMap={memberMap}
|
||||
replyMessage={getReplyMessage()}
|
||||
replyMessage={replyMsg}
|
||||
replyLoading={replyLoading}
|
||||
replyNotFound={replyNotFound}
|
||||
getSenderInfo={getSenderInfo}
|
||||
onAtPress={() => undefined}
|
||||
onReplyPress={onReplyPress}
|
||||
@@ -432,9 +469,35 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
|
||||
{renderMessageContent()}
|
||||
|
||||
{/* 私聊模式:显示已读标记 */}
|
||||
{isLastReadMessage && (
|
||||
<View style={styles.readStatusContainer}>
|
||||
{/* 发送状态指示器 */}
|
||||
{isMe && (
|
||||
<View style={styles.sendStatusContainer}>
|
||||
{isPending && (
|
||||
<View style={styles.sendStatusRow}>
|
||||
<ActivityIndicator size="small" color="#999" style={styles.sendStatusIndicator} />
|
||||
<Text style={styles.pendingStatusText}>发送中</Text>
|
||||
</View>
|
||||
)}
|
||||
{isFailed && (
|
||||
// 点击「发送失败」重试:仅对自己发送的失败消息提供重试入口。
|
||||
// 用 Pressable 而非 TouchableOpacity,避免与外层长按手势冲突;
|
||||
// 失败状态本身是终态,重试期间会被替换为 pending。
|
||||
<TouchableOpacity
|
||||
style={styles.sendStatusRow}
|
||||
onPress={() => onRetrySend?.(message)}
|
||||
disabled={!onRetrySend}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="alert-circle"
|
||||
size={14}
|
||||
color="#FF3B30"
|
||||
/>
|
||||
<Text style={styles.failedStatusText}>发送失败,点击重试</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{!isPending && !isFailed && isLastReadMessage && (
|
||||
<View style={styles.sendStatusRow}>
|
||||
<MaterialCommunityIcons
|
||||
name="check-all"
|
||||
size={14}
|
||||
@@ -446,6 +509,8 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 自己的头像 */}
|
||||
{isMe && (
|
||||
@@ -509,6 +574,9 @@ export const MessageBubble = React.memo(MessageBubbleInner, (prev, next) => {
|
||||
if (prev.currentUser !== next.currentUser) return false;
|
||||
if (prev.otherUser !== next.otherUser) return false;
|
||||
if (prev.messageMap !== next.messageMap) return false;
|
||||
// getCachedReply 引用在缓存回填后变化,驱动引用预览从"加载中"→"显示内容"重渲染
|
||||
if (prev.getCachedReply !== next.getCachedReply) return false;
|
||||
if (prev.ensureReplyMessage !== next.ensureReplyMessage) return false;
|
||||
if (prev.onAvatarLongPress !== next.onAvatarLongPress) return false;
|
||||
if (prev.onAvatarPress !== next.onAvatarPress) return false;
|
||||
return true;
|
||||
|
||||
@@ -66,6 +66,9 @@ export interface SegmentRendererProps {
|
||||
export interface ReplyPreviewSegmentProps {
|
||||
replyData: ReplySegmentData;
|
||||
replyMessage?: MessageResponse;
|
||||
// 回填状态:区分"加载中"与"已被删除/不可见",避免占位文案误导
|
||||
replyLoading?: boolean;
|
||||
replyNotFound?: boolean;
|
||||
isMe: boolean;
|
||||
onPress?: () => void;
|
||||
getSenderInfo?: (senderId: string) => SenderInfo;
|
||||
@@ -668,6 +671,8 @@ const renderLinkSegment = (
|
||||
export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
|
||||
replyData,
|
||||
replyMessage,
|
||||
replyLoading,
|
||||
replyNotFound,
|
||||
isMe,
|
||||
onPress,
|
||||
getSenderInfo,
|
||||
@@ -677,7 +682,12 @@ export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
|
||||
const styles = useMemo(() => createSegmentStyles(themeColors, fontSize), [themeColors, fontSize]);
|
||||
|
||||
if (!replyMessage) {
|
||||
// 如果没有引用消息详情,只显示引用ID
|
||||
// 回填进行中:显示加载占位;已删除/不可见:显示提示文案
|
||||
const placeholder = replyNotFound
|
||||
? '引用消息已被删除或不可见'
|
||||
: replyLoading
|
||||
? '加载中…'
|
||||
: '引用消息';
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.replyPreview, isMe ? styles.replyMe : styles.replyOther]}
|
||||
@@ -687,7 +697,7 @@ export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
|
||||
<View style={styles.replyLine} />
|
||||
<View style={styles.replyContent}>
|
||||
<Text style={styles.replyText} numberOfLines={2}>
|
||||
引用消息
|
||||
{placeholder}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
@@ -766,6 +776,8 @@ const MessageSegmentsRendererInner: React.FC<{
|
||||
currentUserId?: string;
|
||||
memberMap?: Map<string, { nickname: string; user_id: string }>;
|
||||
replyMessage?: MessageResponse;
|
||||
replyLoading?: boolean;
|
||||
replyNotFound?: boolean;
|
||||
onAtPress?: (userId: string) => void;
|
||||
onReplyPress?: (messageId: string) => void;
|
||||
onImagePress?: (url: string) => void;
|
||||
@@ -778,6 +790,8 @@ const MessageSegmentsRendererInner: React.FC<{
|
||||
currentUserId,
|
||||
memberMap,
|
||||
replyMessage,
|
||||
replyLoading,
|
||||
replyNotFound,
|
||||
onAtPress,
|
||||
onReplyPress,
|
||||
onImagePress,
|
||||
@@ -811,6 +825,8 @@ const MessageSegmentsRendererInner: React.FC<{
|
||||
<ReplyPreviewSegment
|
||||
replyData={replySegment.data as ReplySegmentData}
|
||||
replyMessage={replyMessage}
|
||||
replyLoading={replyLoading}
|
||||
replyNotFound={replyNotFound}
|
||||
isMe={isMe}
|
||||
onPress={() => onReplyPress?.((replySegment.data as ReplySegmentData).id)}
|
||||
getSenderInfo={getSenderInfo}
|
||||
@@ -1063,7 +1079,10 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
// 文件 - QQ风格:卡片式设计
|
||||
// 文件 - 卡片式设计
|
||||
// 注意:文件卡片渲染在聊天气泡内部,因此卡片本身不再绘制背景与阴影。
|
||||
// 自带的底色会与气泡背景叠加出「白框」,阴影会被气泡的 overflow:'hidden'
|
||||
// 裁切成「黑框」,二者都会遮挡文件内容。这里改为透明,让内容直接落在气泡上。
|
||||
fileContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -1071,17 +1090,12 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
|
||||
borderRadius: 16,
|
||||
minWidth: 220,
|
||||
maxWidth: 300,
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
fileMe: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.25)',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
fileOther: {
|
||||
backgroundColor: colors.chat.surfaceRaised,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
fileIcon: {
|
||||
marginRight: spacing.md,
|
||||
@@ -1230,6 +1244,8 @@ export const MessageSegmentsRenderer = React.memo(MessageSegmentsRendererInner,
|
||||
if (prev.currentUserId !== next.currentUserId) return false;
|
||||
if (prev.memberMap !== next.memberMap) return false;
|
||||
if (prev.replyMessage !== next.replyMessage) return false;
|
||||
if (prev.replyLoading !== next.replyLoading) return false;
|
||||
if (prev.replyNotFound !== next.replyNotFound) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -417,6 +417,33 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
||||
color: colors.chat.success,
|
||||
},
|
||||
|
||||
// 发送状态(乐观更新)
|
||||
sendStatusContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignSelf: 'flex-end',
|
||||
marginTop: 2,
|
||||
marginRight: 2,
|
||||
},
|
||||
sendStatusRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
sendStatusIndicator: {
|
||||
marginRight: 2,
|
||||
},
|
||||
pendingStatusText: {
|
||||
fontSize: 10,
|
||||
color: colors.chat.textSecondary,
|
||||
fontWeight: '500',
|
||||
},
|
||||
failedStatusText: {
|
||||
fontSize: 10,
|
||||
color: '#FF3B30',
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// 输入框区域 - 与列表背景同色底栏,顶部细分隔
|
||||
inputWrapper: {
|
||||
backgroundColor: cardBgColor,
|
||||
@@ -489,34 +516,30 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
||||
transform: [{ translateY: -1 }],
|
||||
},
|
||||
|
||||
// 发送按钮 - QQ/微信风格
|
||||
// 发送按钮 - 微信风格文字按钮
|
||||
sendButtonActive: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18, // 正圆形
|
||||
minWidth: 56,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
// 微信风格:轻微阴影,不夸张
|
||||
paddingHorizontal: 12,
|
||||
// 轻微阴影
|
||||
shadowColor: colors.primary.main,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 3,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
sendButtonDisabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
// 发送按钮光泽层(叠加在按钮上模拟渐变)
|
||||
sendButtonShine: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '50%',
|
||||
borderTopLeftRadius: 18,
|
||||
borderTopRightRadius: 18,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
sendButtonText: {
|
||||
color: '#FFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
// 输入框
|
||||
|
||||
@@ -90,6 +90,10 @@ export interface MessageBubbleProps {
|
||||
selectedMessageId: string | null;
|
||||
// 消息映射,用于查找被回复的消息
|
||||
messageMap?: Map<string, GroupMessage>;
|
||||
// 引用消息回填:同步查缓存(渲染期)
|
||||
getCachedReply?: (messageId: string) => import('@/types/dto/message').MessageResponse | null | undefined;
|
||||
// 引用消息回填:异步回填(内存→本地→网络),用于懒加载预览
|
||||
ensureReplyMessage?: (messageId: string) => Promise<import('@/types/dto/message').MessageResponse | null>;
|
||||
onLongPress: (message: GroupMessage, position?: MenuPosition) => void;
|
||||
onAvatarPress: (userId: string) => void;
|
||||
onAvatarLongPress: (senderId: string, nickname: string) => void;
|
||||
@@ -101,6 +105,8 @@ export interface MessageBubbleProps {
|
||||
onReply?: (message: GroupMessage) => void;
|
||||
// 点击回复预览回调(定位到原消息)
|
||||
onReplyPress?: (messageId: string) => void;
|
||||
// 点击「发送失败」气泡重试发送(仅对当前用户自己发送的失败消息触发)
|
||||
onRetrySend?: (message: GroupMessage) => void;
|
||||
}
|
||||
|
||||
/** 输入框中待发送的本地图片(未上传) */
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from './types';
|
||||
import { TIME_SEPARATOR_INTERVAL, MEMBERS_PAGE_SIZE, MAX_PENDING_CHAT_IMAGES } from './constants';
|
||||
import { messageRepository } from '@/database';
|
||||
import { useReplyMessage } from './useReplyMessage';
|
||||
|
||||
interface MentionRange {
|
||||
start: number;
|
||||
@@ -153,6 +154,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
loadMoreMessages,
|
||||
refreshMessages,
|
||||
sendMessage: sendMessageViaManager,
|
||||
retrySendMessage: retrySendMessageViaManager,
|
||||
isSending: isSendingViaManager,
|
||||
markAsRead,
|
||||
conversation,
|
||||
@@ -606,11 +608,16 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
const ascendingIndex = messages.findIndex(m => m.seq === targetSeq);
|
||||
const displayIndex = messages.length - 1 - ascendingIndex;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
flatListRef.current?.scrollToIndex({
|
||||
index: displayIndex,
|
||||
animated: false,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
} catch {
|
||||
// FlashList 远距离跳转可能失败,降级到 offset 滚动
|
||||
flatListRef.current?.scrollToOffset?.({ offset: 0, animated: false });
|
||||
}
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
@@ -622,6 +629,45 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
}
|
||||
}, [loading, loadingMore, messages, hasMoreHistory, loadMoreHistory]);
|
||||
|
||||
/**
|
||||
* 跳转到指定 seq 的消息(供引用消息点击跳转复用 scrollToSeq 范式)。
|
||||
* 设置目标 seq 后由上方 effect 接管:内存命中则 scrollToIndex,
|
||||
* 未命中则循环 loadMoreHistory 直到目标进入视窗或历史耗尽。
|
||||
* @returns 是否已受理跳转(false 表示无更多历史且不在内存,无法定位)
|
||||
*/
|
||||
const jumpToMessageSeq = useCallback((seq: number): boolean => {
|
||||
if (!hasInitialAnchorDoneRef.current) return false;
|
||||
if (!Number.isFinite(seq) || seq <= 0) return false;
|
||||
|
||||
scrollToSeqRef.current = seq;
|
||||
isBrowsingHistoryRef.current = true;
|
||||
suppressAutoFollowRef.current = true;
|
||||
|
||||
// 目标已在内存:直接定位(effect 依赖未变不会自动重跑,这里同步处理命中情况)
|
||||
const ascendingIndex = messages.findIndex(m => m.seq === seq);
|
||||
if (ascendingIndex >= 0) {
|
||||
scrollToSeqRef.current = null;
|
||||
const displayIndex = messages.length - 1 - ascendingIndex;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
flatListRef.current?.scrollToIndex({ index: displayIndex, animated: true, viewPosition: 0.5 });
|
||||
} catch {
|
||||
flatListRef.current?.scrollToOffset?.({ offset: 0, animated: true });
|
||||
}
|
||||
}, 50);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 目标不在内存:kickstart 历史加载循环,由 scrollToSeq effect 接管后续
|
||||
if (hasMoreHistory) {
|
||||
loadMoreHistory();
|
||||
return true;
|
||||
}
|
||||
// 无更多历史且不在内存,无法定位
|
||||
scrollToSeqRef.current = null;
|
||||
return false;
|
||||
}, [messages, hasMoreHistory, loadMoreHistory, flatListRef]);
|
||||
|
||||
// 列表内容尺寸变化:仅同步内容高度,锚点补偿由 loadMoreHistory 统一处理
|
||||
const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
|
||||
scrollPositionRef.current.contentHeight = contentHeight;
|
||||
@@ -649,6 +695,17 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
isBrowsingHistoryRef.current = browsing;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 无条件清除浏览历史锁与自动跟随抑制(动量滚动结束、layout settle、
|
||||
* 轻触回到底部等场景使用)。区别于 handleReachLatestEdge,后者受加载锁保护。
|
||||
*/
|
||||
const clearHistoryLock = useCallback(() => {
|
||||
if (isBrowsingHistoryRef.current || suppressAutoFollowRef.current) {
|
||||
isBrowsingHistoryRef.current = false;
|
||||
suppressAutoFollowRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 进入聊天详情后立即清未读(不依赖滚动位置)
|
||||
useEffect(() => {
|
||||
if (!conversationId) return;
|
||||
@@ -874,6 +931,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
return map;
|
||||
}, [messages]);
|
||||
|
||||
// 引用消息回填:被引用消息不在已加载内存区间时,懒加载回填预览/跳转所需数据
|
||||
const { getCached: getCachedReply, ensureReplyMessage } = useReplyMessage();
|
||||
|
||||
// 选择@用户
|
||||
const handleSelectMention = useCallback((member: { user_id: string; nickname?: string; user?: { nickname?: string } }) => {
|
||||
const currentText = inputTextRef.current;
|
||||
@@ -1045,7 +1105,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
return segments;
|
||||
}, []);
|
||||
|
||||
// 【新架构】发送消息(支持纯文字、纯多图、图文同条)
|
||||
// 【新架构】发送消息(支持纯文字、纯多图、图文同条)- 乐观更新模式
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmedText = inputText.trim();
|
||||
const hasPending = pendingAttachments.length > 0;
|
||||
@@ -1071,6 +1131,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 图片需要先上传(这个无法乐观,因为需要URL)
|
||||
const uploadedUrls: string[] = [];
|
||||
if (hasPending) {
|
||||
setUploadingAttachments(true);
|
||||
@@ -1097,10 +1158,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
// 有文本或回复时才构建文本段,纯图片时不塞空 text
|
||||
// 构建消息段
|
||||
const segments: MessageSegment[] = (trimmedText || replyingTo)
|
||||
? [...buildTextSegments(trimmedText, replyingTo)]
|
||||
: [];
|
||||
@@ -1119,38 +1177,41 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGroupChat && effectiveGroupId) {
|
||||
await messageService.sendMessageByAction('group', conversationId, segments);
|
||||
|
||||
// 乐观更新:立即清空输入框并滚动到底部
|
||||
const savedReplyTo = replyingTo;
|
||||
setInputText('');
|
||||
setSelectedMentions([]);
|
||||
setMentionAll(false);
|
||||
setReplyingTo(null);
|
||||
setPendingAttachments([]);
|
||||
} else {
|
||||
await sendMessageViaManager(segments);
|
||||
setInputText('');
|
||||
setSelectedMentions([]);
|
||||
setMentionAll(false);
|
||||
setReplyingTo(null);
|
||||
setPendingAttachments([]);
|
||||
}
|
||||
|
||||
// 立即滚动到底部显示新消息
|
||||
setTimeout(() => {
|
||||
scrollToLatest(false, false, hasPending ? 'send-mixed' : 'send-text');
|
||||
}, 100);
|
||||
}, 50);
|
||||
|
||||
// 后台发送消息(私聊与群聊统一走 MessageSendService 的乐观更新):
|
||||
// 1. 立即在 store 中插入 pending 临时消息
|
||||
// 2. 成功 → 替换为正式消息、更新会话 last_message、写入本地数据库
|
||||
// 3. 失败 → 标记为 failed,供用户点击重试
|
||||
try {
|
||||
await sendMessageViaManager(segments, {
|
||||
replyToId: savedReplyTo?.id,
|
||||
detailType: isGroupChat ? 'group' : 'private',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
// MessageSendService 已将消息置为 failed 并在 UI 上显示状态,
|
||||
// 这里仅弹窗提示用户(与原行为保持一致)。
|
||||
if (isGroupChat) {
|
||||
Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试'));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
inputText,
|
||||
pendingAttachments,
|
||||
conversationId,
|
||||
isGroupChat,
|
||||
effectiveGroupId,
|
||||
sendMessageViaManager,
|
||||
isMuted,
|
||||
muteAll,
|
||||
@@ -1162,6 +1223,21 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
scrollToLatest,
|
||||
]);
|
||||
|
||||
// 重试发送失败的消息(点击「发送失败」气泡时触发)
|
||||
// 仅对当前用户自己发送且状态为 failed 的消息生效;
|
||||
// 重试期间消息会先变为 pending(发送中),再根据结果转为 normal/failed。
|
||||
const handleRetrySend = useCallback(async (message: GroupMessage) => {
|
||||
if (!conversationId) return;
|
||||
if (message.sender_id !== currentUserId) return;
|
||||
if (message.status !== 'failed') return;
|
||||
try {
|
||||
await retrySendMessageViaManager(message);
|
||||
} catch (error) {
|
||||
console.error('重试发送消息失败:', error);
|
||||
Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试'));
|
||||
}
|
||||
}, [conversationId, currentUserId, retrySendMessageViaManager, getSendErrorMessage]);
|
||||
|
||||
// 选择图片(多选进入输入框待发送,点发送时与文字一并发出)
|
||||
const handlePickImage = useCallback(async () => {
|
||||
if (pendingAttachments.length >= MAX_PENDING_CHAT_IMAGES) {
|
||||
@@ -1292,7 +1368,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
|
||||
const segments = buildFileSegments(
|
||||
uploaded.url,
|
||||
uploaded.name || asset.name,
|
||||
// asset.name 是 DocumentPicker 返回的用户原始文件名(真实名),
|
||||
// 优先使用它;uploaded.name 为后端回传的展示名(已与 asset.name 一致)。
|
||||
asset.name || uploaded.name || 'file',
|
||||
uploaded.size ?? asset.size,
|
||||
uploaded.mime_type ?? asset.mimeType,
|
||||
replyingTo,
|
||||
@@ -1487,7 +1565,10 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
try {
|
||||
setHasMoreHistory(true);
|
||||
await messageRepository.clearConversation(conversationId);
|
||||
// 刷新消息列表
|
||||
// 同步清空内存消息 + hydrated 标志,保证内存/DB/hydrated 三者一致。
|
||||
// 否则 hydrated 残留会让 refreshMessages 走轻量增量同步,清空后仍显示旧消息。
|
||||
useMessageStore.getState().clearMessages(conversationId);
|
||||
// 刷新消息列表(hydrated 已清,会重新走完整 hydration 管线)
|
||||
await refreshMessages();
|
||||
} catch (error) {
|
||||
console.error('清空会话失败:', error);
|
||||
@@ -1612,6 +1693,10 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
effectiveGroupName,
|
||||
otherUserLastReadSeq,
|
||||
messageMap,
|
||||
// 引用消息回填(会话级缓存:内存 → 本地 SQLite → 服务端)
|
||||
getCachedReply,
|
||||
ensureReplyMessage,
|
||||
jumpToMessageSeq,
|
||||
loadingMore,
|
||||
hasMoreHistory,
|
||||
|
||||
@@ -1625,6 +1710,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
shouldShowTime,
|
||||
handleInputChange,
|
||||
handleSend,
|
||||
handleRetrySend,
|
||||
removePendingAttachment,
|
||||
handleMoreAction,
|
||||
handleInsertEmoji,
|
||||
@@ -1653,5 +1739,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
handleReachLatestEdge,
|
||||
jumpToLatestMessages,
|
||||
setBrowsingHistory,
|
||||
clearHistoryLock,
|
||||
};
|
||||
};
|
||||
|
||||
126
src/screens/message/components/ChatScreen/useReplyMessage.ts
Normal file
126
src/screens/message/components/ChatScreen/useReplyMessage.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { MessageResponse } from '@/types/dto/message';
|
||||
import { messageRepository } from '@/database';
|
||||
import { messageService } from '@/services/message';
|
||||
import { MessageMapper } from '@/data/mappers';
|
||||
|
||||
/**
|
||||
* 引用消息回填 hook(会话级)
|
||||
*
|
||||
* 解决"被引用消息不在已加载内存区间"导致引用预览只显示"引用消息"占位的问题。
|
||||
*
|
||||
* 三级回填策略(微信/iMessage 风格:快照优先 + 懒加载):
|
||||
* 1. 内存缓存(本 hook 维护的会话级 Map)
|
||||
* 2. 本地 SQLite(离线命中,零网络)
|
||||
* 3. 服务端 GET /messages/:id(带参与者鉴权)
|
||||
*
|
||||
* 注意:消息 id 是全局唯一雪花ID(非会话内序号),故按 id 查询合法;
|
||||
* 而 seq 是会话内序号,仅在该会话内有意义。
|
||||
*
|
||||
* 特性:
|
||||
* - in-flight 去重:同一 messageId 的并发请求只发一次
|
||||
* - 失败不重试占位(避免无限重试),记为 null 表示"不可见"
|
||||
* - state 版本号驱动重渲染:缓存写入后自增 version,订阅方重渲染
|
||||
*/
|
||||
export function useReplyMessage() {
|
||||
// 会话级内存缓存:messageId -> 被引用消息(null 表示已查询但不可见/不存在)
|
||||
const cacheRef = useRef<Map<string, MessageResponse | null>>(new Map());
|
||||
// in-flight 请求去重:同一 id 同时只发一个请求
|
||||
const inflightRef = useRef<Map<string, Promise<MessageResponse | null>>>(new Map());
|
||||
// 版本号:缓存变更后自增,驱动订阅该 hook 的组件重渲染
|
||||
const [version, setVersion] = useState(0);
|
||||
const bump = useCallback(() => setVersion(v => v + 1), []);
|
||||
|
||||
/**
|
||||
* 同步查询缓存(渲染期使用)。命中缓存(含 null)返回结果;未查询过返回 undefined。
|
||||
* 与 ensureReplyMessage 配合:渲染期先 getCached,未命中则在 useEffect 调 ensure。
|
||||
* 注意:依赖 version,缓存更新后引用变化,从而破坏下游 memo 组件的浅比较,
|
||||
* 使引用了 getCachedReply 的 MessageBubble 在缓存回填后重渲染。
|
||||
*/
|
||||
const getCached = useCallback((messageId: string): MessageResponse | null | undefined => {
|
||||
const id = String(messageId);
|
||||
if (cacheRef.current.has(id)) {
|
||||
return cacheRef.current.get(id) ?? null;
|
||||
}
|
||||
return undefined;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [version]);
|
||||
|
||||
/**
|
||||
* 异步回填(本地优先 → 网络兜底)。幂等、去重。
|
||||
* @returns 回填到的消息;不可见/不存在返回 null;查询中返回该次 Promise
|
||||
*/
|
||||
const ensureReplyMessage = useCallback(
|
||||
async (messageId: string): Promise<MessageResponse | null> => {
|
||||
const id = String(messageId);
|
||||
|
||||
// 1. 内存缓存命中
|
||||
if (cacheRef.current.has(id)) {
|
||||
return cacheRef.current.get(id) ?? null;
|
||||
}
|
||||
|
||||
// 2. in-flight 去重:复用进行中的请求
|
||||
const inflight = inflightRef.current.get(id);
|
||||
if (inflight) {
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const task = (async (): Promise<MessageResponse | null> => {
|
||||
try {
|
||||
// 2a. 本地 SQLite 优先(按全局唯一 id 查询)
|
||||
const local = await messageRepository.getById(id);
|
||||
if (local) {
|
||||
const msg: MessageResponse = {
|
||||
id: local.id,
|
||||
conversation_id: local.conversationId,
|
||||
sender_id: local.senderId,
|
||||
seq: local.seq,
|
||||
segments: local.segments || [],
|
||||
status: local.status as MessageResponse['status'],
|
||||
created_at: local.createdAt,
|
||||
// CachedMessage 不缓存 sender,UI 侧由 getSenderInfo(sender_id) 兜底
|
||||
};
|
||||
cacheRef.current.set(id, msg);
|
||||
bump();
|
||||
return msg;
|
||||
}
|
||||
|
||||
// 2b. 本地未命中,走服务端(带鉴权)
|
||||
const remote = await messageService.getReplyMessage(id);
|
||||
if (remote) {
|
||||
// 落本地缓存(用 MessageMapper 转成 CachedMessage 格式),下次零网络
|
||||
try {
|
||||
await messageRepository.saveMessagesBatch(
|
||||
MessageMapper.toCachedMessages([remote])
|
||||
);
|
||||
} catch {
|
||||
// 落库失败不影响本次返回
|
||||
}
|
||||
cacheRef.current.set(id, remote);
|
||||
bump();
|
||||
return remote;
|
||||
}
|
||||
|
||||
// 不可见 / 已删除
|
||||
cacheRef.current.set(id, null);
|
||||
bump();
|
||||
return null;
|
||||
} catch {
|
||||
// 异常时也标记为 null,避免重复请求;用户重进会话可重试
|
||||
cacheRef.current.set(id, null);
|
||||
bump();
|
||||
return null;
|
||||
} finally {
|
||||
inflightRef.current.delete(id);
|
||||
}
|
||||
})();
|
||||
|
||||
inflightRef.current.set(id, task);
|
||||
return task;
|
||||
},
|
||||
[bump]
|
||||
);
|
||||
|
||||
return { getCached, ensureReplyMessage };
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 在宽屏下使用网格布局
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
@@ -22,6 +22,7 @@ import { User } from '../../types';
|
||||
import { useAuthStore, useUserStore } from '../../stores';
|
||||
import { authService } from '../../services';
|
||||
import { Avatar, Text, Button, Loading, EmptyState, ResponsiveContainer } from '../../components/common';
|
||||
import { SearchBar } from '../../components/business';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { useResponsive, useColumnCount } from '../../hooks';
|
||||
|
||||
@@ -53,9 +54,13 @@ const FollowListScreen: React.FC = () => {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [debouncedKeyword, setDebouncedKeyword] = useState('');
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const isCurrentUser = currentUser?.id === userId;
|
||||
const title = type === 'following' ? '关注' : '粉丝';
|
||||
const isSearching = debouncedKeyword.trim().length > 0;
|
||||
|
||||
// 加载用户列表
|
||||
const loadUsers = useCallback(async (pageNum: number = 1, refresh: boolean = false) => {
|
||||
@@ -64,11 +69,12 @@ const FollowListScreen: React.FC = () => {
|
||||
try {
|
||||
const pageSize = 20;
|
||||
let userList: User[] = [];
|
||||
const trimmedKeyword = debouncedKeyword.trim();
|
||||
|
||||
if (type === 'following') {
|
||||
userList = await authService.getFollowingList(userId, pageNum, pageSize);
|
||||
userList = await authService.getFollowingList(userId, pageNum, pageSize, trimmedKeyword);
|
||||
} else {
|
||||
userList = await authService.getFollowersList(userId, pageNum, pageSize);
|
||||
userList = await authService.getFollowersList(userId, pageNum, pageSize, trimmedKeyword);
|
||||
}
|
||||
|
||||
if (refresh) {
|
||||
@@ -84,12 +90,33 @@ const FollowListScreen: React.FC = () => {
|
||||
}
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}, [userId, type, hasMore]);
|
||||
}, [userId, type, hasMore, debouncedKeyword]);
|
||||
|
||||
// 初始加载
|
||||
// 初始加载(包括 type/userId 切换以及防抖后关键词变化)
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setHasMore(true);
|
||||
loadUsers(1, true);
|
||||
}, [userId, type]);
|
||||
// 仅在 userId/type/debouncedKeyword 变化时重新加载
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId, type, debouncedKeyword]);
|
||||
|
||||
// 关键词输入防抖(300ms)
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setDebouncedKeyword(keyword);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [keyword]);
|
||||
|
||||
// 下拉刷新
|
||||
const onRefresh = useCallback(() => {
|
||||
@@ -249,6 +276,17 @@ const FollowListScreen: React.FC = () => {
|
||||
const renderEmpty = () => {
|
||||
if (loading) return <Loading />;
|
||||
|
||||
if (isSearching) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="没有找到匹配的用户"
|
||||
description="换个关键词试试"
|
||||
icon="account-search-outline"
|
||||
variant="modern"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyText = type === 'following'
|
||||
? '还没有关注任何人'
|
||||
: '还没有粉丝';
|
||||
@@ -271,17 +309,34 @@ const FollowListScreen: React.FC = () => {
|
||||
<View style={styles.headerAccent} />
|
||||
<View style={styles.headerMainRow}>
|
||||
<Text variant="h2" style={styles.headerTitle}>{title}</Text>
|
||||
{!isSearching ? (
|
||||
<View style={styles.headerCountPill}>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
共 {users.length}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.headerCountPill}>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
搜索结果
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.headerSubtitle}>
|
||||
{type === 'following'
|
||||
? (isCurrentUser ? '你已关注的用户' : 'TA关注的用户')
|
||||
: (isCurrentUser ? '关注你的用户' : 'TA的粉丝')}
|
||||
</Text>
|
||||
<View style={styles.headerSearchWrap}>
|
||||
<SearchBar
|
||||
value={keyword}
|
||||
onChangeText={setKeyword}
|
||||
onSubmit={() => setDebouncedKeyword(keyword)}
|
||||
placeholder={type === 'following' ? '搜索关注' : '搜索粉丝'}
|
||||
compact
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -407,6 +462,9 @@ function createFollowListStyles(colors: AppColors) {
|
||||
headerSubtitle: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
headerSearchWrap: {
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
userItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
397
src/screens/profile/HelpScreen.tsx
Normal file
397
src/screens/profile/HelpScreen.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* 帮助与反馈页面 HelpScreen
|
||||
* 威友 - 常见问题、官方联系方式
|
||||
* 扁平化设计风格,可折叠问答,参考关于我们、设置页面
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Linking,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useRouter } from 'expo-router';
|
||||
import {
|
||||
useAppColors,
|
||||
spacing,
|
||||
fontSizes,
|
||||
borderRadius,
|
||||
type AppColors,
|
||||
} from '../../theme';
|
||||
import { Text, SimpleHeader } from '../../components/common';
|
||||
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
||||
|
||||
const CONTENT_MAX_WIDTH = 720;
|
||||
|
||||
// 威友橙主题色
|
||||
const THEME_COLORS = {
|
||||
primary: '#FF6B35',
|
||||
primaryLight: '#FFF1EA',
|
||||
};
|
||||
|
||||
// 官方联系邮箱
|
||||
const CONTACT_EMAIL = 'system@qczlit.cn';
|
||||
|
||||
interface FAQItem {
|
||||
q: string;
|
||||
a: string;
|
||||
/** 补充提示(如注意事项),与正文区分展示 */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface FAQSection {
|
||||
key: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
items: FAQItem[];
|
||||
}
|
||||
|
||||
// 常见问题内容
|
||||
const FAQ_SECTIONS: FAQSection[] = [
|
||||
{
|
||||
key: 'account',
|
||||
title: '账号与认证',
|
||||
icon: 'account-check-outline',
|
||||
items: [
|
||||
{
|
||||
q: '如何通过身份认证?',
|
||||
a: '进入「我的」页面,点击右上角齿轮状设置图标,选择「身份认证」。按提示填写学号/工号、真实姓名,并上传有效佐证材料。提交后通常等待 1-2 小时即可完成审核。',
|
||||
},
|
||||
{
|
||||
q: '忘记密码怎么找回?',
|
||||
a: '在登录页点击「忘记密码」,通过已绑定的手机号或校内邮箱进行重置。若均未绑定,请联系客服人工处理。',
|
||||
},
|
||||
{
|
||||
q: '如何绑定手机号?',
|
||||
a: '进入「我的」页面,点击「编辑资料」,选择「手机号」,输入手机号码并完成验证即可绑定。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'campus',
|
||||
title: '校园工具',
|
||||
icon: 'school-outline',
|
||||
items: [
|
||||
{
|
||||
q: '如何查看课表?',
|
||||
a: '进入「应用」页面,点击「课表」,点击左上角齿轮状设置图标,选择「同步教务系统」。输入学号/工号及教务系统密码即可同步。',
|
||||
note: '该密码与统一身份认证密码不同。如遗忘,可登录新教务系统,进入「个人中心」→「修改密码」进行重置,默认密码为身份证号码后 6 位。',
|
||||
},
|
||||
{
|
||||
q: '如何查找学习资料?',
|
||||
a: '进入「应用」页面,点击「学习资料」,可按学科分类浏览,也可点击右上角搜索图标快速查找所需资料。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'notification',
|
||||
title: '消息与通知',
|
||||
icon: 'bell-outline',
|
||||
items: [
|
||||
{
|
||||
q: '如何及时收到消息通知?',
|
||||
a: '请先在手机系统「设置」→「通知管理」或「应用管理」中,找到本 App,开启通知权限及自启动/后台运行权限;然后进入 App 内「我的」页面,点击右上角齿轮状设置图标,进入「通知管理」,确认所需消息提醒均已开启。设置完成后重启 App 即可生效。',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function createHelpStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingVertical: spacing.lg,
|
||||
},
|
||||
content: {
|
||||
maxWidth: CONTENT_MAX_WIDTH,
|
||||
alignSelf: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
|
||||
// 分组
|
||||
section: {
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing['2xl'],
|
||||
marginBottom: spacing.sm,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
sectionIcon: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: borderRadius.sm,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.secondary,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
// 问答卡片
|
||||
faqList: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
faqItem: {
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
faqItemLast: {
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
faqQuestion: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: spacing['2xl'],
|
||||
},
|
||||
questionText: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '500',
|
||||
color: colors.text.primary,
|
||||
lineHeight: fontSizes.lg * 1.4,
|
||||
},
|
||||
faqAnswer: {
|
||||
paddingHorizontal: spacing['2xl'],
|
||||
paddingBottom: 16,
|
||||
},
|
||||
answerText: {
|
||||
fontSize: fontSizes.md,
|
||||
lineHeight: fontSizes.md * 1.7,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
|
||||
// 补充提示
|
||||
noteContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginTop: spacing.md,
|
||||
padding: spacing.md,
|
||||
backgroundColor: THEME_COLORS.primaryLight,
|
||||
borderRadius: borderRadius.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
noteText: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.sm,
|
||||
lineHeight: fontSizes.sm * 1.6,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
|
||||
// 联系我们
|
||||
contactCard: {
|
||||
marginHorizontal: spacing['2xl'],
|
||||
marginTop: spacing.sm,
|
||||
padding: spacing.xl,
|
||||
borderRadius: borderRadius.xl,
|
||||
backgroundColor: colors.background.default,
|
||||
alignItems: 'center',
|
||||
},
|
||||
contactIconWrap: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: THEME_COLORS.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
contactTitle: {
|
||||
fontSize: fontSizes.xl,
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
contactDesc: {
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.secondary,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
lineHeight: fontSizes.md * 1.6,
|
||||
},
|
||||
contactButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.xl,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: THEME_COLORS.primary,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
contactButtonText: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
|
||||
// 底部版权
|
||||
footer: {
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xl,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.hint,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const HelpScreen: React.FC = () => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createHelpStyles(colors), [colors]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { isMobile } = useResponsive();
|
||||
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
||||
|
||||
// 当前展开的问题 key(同一时刻仅展开一个)
|
||||
const [expandedKey, setExpandedKey] = useState<string | null>(
|
||||
`${FAQ_SECTIONS[0].key}-0`,
|
||||
);
|
||||
|
||||
const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md;
|
||||
|
||||
const toggleItem = useCallback((key: string) => {
|
||||
setExpandedKey((prev) => (prev === key ? null : key));
|
||||
}, []);
|
||||
|
||||
const handleContactEmail = useCallback(async () => {
|
||||
const url = `mailto:${CONTACT_EMAIL}`;
|
||||
try {
|
||||
const canOpen = await Linking.canOpenURL(url);
|
||||
if (canOpen) {
|
||||
await Linking.openURL(url);
|
||||
} else {
|
||||
Alert.alert('联系我们', `请发送邮件至:${CONTACT_EMAIL}`);
|
||||
}
|
||||
} catch {
|
||||
Alert.alert('联系我们', `请发送邮件至:${CONTACT_EMAIL}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderFAQItem = (
|
||||
section: FAQSection,
|
||||
item: FAQItem,
|
||||
index: number,
|
||||
) => {
|
||||
const key = `${section.key}-${index}`;
|
||||
const isExpanded = expandedKey === key;
|
||||
const isLast = index === section.items.length - 1;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={key}
|
||||
style={[styles.faqItem, isLast && styles.faqItemLast]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.faqQuestion}
|
||||
onPress={() => toggleItem(key)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.questionText}>{item.q}</Text>
|
||||
<MaterialCommunityIcons
|
||||
name={isExpanded ? 'chevron-up' : 'chevron-down'}
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
{isExpanded && (
|
||||
<View style={styles.faqAnswer}>
|
||||
<Text style={styles.answerText}>{item.a}</Text>
|
||||
{item.note && (
|
||||
<View style={styles.noteContainer}>
|
||||
<MaterialCommunityIcons
|
||||
name="alert-circle-outline"
|
||||
size={16}
|
||||
color={THEME_COLORS.primary}
|
||||
/>
|
||||
<Text style={styles.noteText}>{item.note}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSection = (section: FAQSection) => (
|
||||
<View key={section.key} style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<View style={[styles.sectionIcon, { backgroundColor: THEME_COLORS.primaryLight }]}>
|
||||
<MaterialCommunityIcons name={section.icon as any} size={14} color={THEME_COLORS.primary} />
|
||||
</View>
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
</View>
|
||||
<View style={styles.faqList}>
|
||||
{section.items.map((item, index) => renderFAQItem(section, item, index))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<StatusBar style="auto" />
|
||||
<SimpleHeader title="帮助与反馈" onBack={() => router.back()} />
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
{FAQ_SECTIONS.map(renderSection)}
|
||||
|
||||
{/* 联系我们 */}
|
||||
<View style={styles.contactCard}>
|
||||
<View style={styles.contactIconWrap}>
|
||||
<MaterialCommunityIcons name="email-outline" size={26} color="#fff" />
|
||||
</View>
|
||||
<Text style={styles.contactTitle}>没有找到答案?</Text>
|
||||
<Text style={styles.contactDesc}>
|
||||
如果以上内容未能解决您的问题,欢迎通过邮件与官方团队联系,我们会尽快为您处理。
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.contactButton}
|
||||
onPress={handleContactEmail}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialCommunityIcons name="email-fast-outline" size={18} color="#fff" />
|
||||
<Text style={styles.contactButtonText}>{CONTACT_EMAIL}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>© 2026 青春之旅电子信息科技(威海)有限公司</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpScreen;
|
||||
@@ -271,7 +271,7 @@ export const SettingsScreen: React.FC = () => {
|
||||
router.push(hrefs.hrefProfileAbout());
|
||||
break;
|
||||
case 'help':
|
||||
Alert.alert('帮助与反馈', '帮助中心即将上线!');
|
||||
router.push(hrefs.hrefProfileHelp());
|
||||
break;
|
||||
|
||||
case 'edit_profile':
|
||||
|
||||
@@ -15,6 +15,7 @@ export { NotificationSettingsScreen } from './NotificationSettingsScreen';
|
||||
export { BlockedUsersScreen } from './BlockedUsersScreen';
|
||||
export { AccountSecurityScreen } from './AccountSecurityScreen';
|
||||
export { AboutScreen } from './AboutScreen';
|
||||
export { HelpScreen } from './HelpScreen';
|
||||
export { TermsOfServiceScreen } from './TermsOfServiceScreen';
|
||||
export { PrivacyPolicyScreen } from './PrivacyPolicyScreen';
|
||||
export { VerificationSettingsScreen } from './VerificationSettingsScreen';
|
||||
|
||||
@@ -3,10 +3,27 @@
|
||||
* 处理用户登录、注册、登出等认证功能
|
||||
*/
|
||||
|
||||
import { api } from '../core/api';
|
||||
import { api, ApiError, isNetworkError } from '../core/api';
|
||||
import { User } from '@/types';
|
||||
import type { PrivacySettingsDTO, PrivacySettingsRequestDTO, DeletionStatusDTO } from '@/types/dto';
|
||||
|
||||
/**
|
||||
* 当前用户获取结果。
|
||||
* - user:成功拿到用户信息
|
||||
* - auth_failed:后端明确拒绝(401 / token 失效)→ 应登出
|
||||
* - banned:后端返回 403 USER_BANNED(用户被封禁)→ 提示"账号已被封禁"
|
||||
* - network_error:网络故障(未到达后端)→ 不应登出,应保留登录态等待重试
|
||||
*
|
||||
* 引入该类型是为了修复"断网即被登出"的问题:
|
||||
* 之前 fetchCurrentUserFromAPI 在任何错误下都返回 null,
|
||||
* 调用方无法区分"token 真失效"与"只是连不上服务器"。
|
||||
*/
|
||||
export type FetchCurrentUserResult =
|
||||
| { kind: 'user'; user: User }
|
||||
| { kind: 'auth_failed' }
|
||||
| { kind: 'banned' }
|
||||
| { kind: 'network_error' };
|
||||
|
||||
export function resolveAuthApiError(error: any, fallback = '操作失败,请稍后重试'): string {
|
||||
const code: number = error?.code ?? 0;
|
||||
const msg: string = String(error?.message ?? '').toLowerCase();
|
||||
@@ -220,36 +237,70 @@ class AuthService {
|
||||
}
|
||||
|
||||
// 用户登出(只管 API + Token,DB/缓存清理交给 authStore)
|
||||
//
|
||||
// 后端行为变化:游客调用 /auth/logout 现在返回 401(之前返回 200)。
|
||||
// 客户端策略:401 视为正常路径(用户已处于登出状态),静默吞掉,
|
||||
// 避免在 finally 清 token 之外产生误导性的错误日志。
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
const code = error?.code ?? 0;
|
||||
if (code === 401) {
|
||||
// 游客调用登出 → 401,属正常情况(用户已是登出状态),静默处理
|
||||
} else {
|
||||
console.error('[AuthService] 登出 API 失败:', error);
|
||||
}
|
||||
} finally {
|
||||
await api.clearToken();
|
||||
}
|
||||
}
|
||||
|
||||
// 纯 API 获取当前用户(无任何 DB 依赖,供冷启动校验 Token 时使用)
|
||||
async fetchCurrentUserFromAPI(): Promise<User | null> {
|
||||
// 返回判别类型,便于调用方区分"网络故障"与"认证被拒"。
|
||||
// 旧调用方(getCurrentUser / fetchCurrentUserFresh)仍返回 User | null。
|
||||
async fetchCurrentUserFromAPI(): Promise<FetchCurrentUserResult> {
|
||||
try {
|
||||
const response = await api.get<UserResponse>('/users/me');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
// 401 错误(未登录/登录过期)是预期情况,不需要打印错误日志
|
||||
const isAuthError = error?.code === 401 ||
|
||||
String(error?.message ?? '').includes('登录') ||
|
||||
String(error?.message ?? '').includes('token') ||
|
||||
String(error?.message ?? '').includes('unauthorized');
|
||||
|
||||
if (!isAuthError) {
|
||||
console.error('[AuthService] fetchCurrentUserFromAPI 失败:', error);
|
||||
if (response.data) {
|
||||
return { kind: 'user', user: response.data };
|
||||
}
|
||||
return null;
|
||||
// 后端返回成功但无数据:视作认证异常
|
||||
return { kind: 'auth_failed' };
|
||||
} catch (error: any) {
|
||||
// 网络故障(断网 / DNS / 超时):保留登录态,不登出
|
||||
if (isNetworkError(error)) {
|
||||
console.warn('[AuthService] fetchCurrentUserFromAPI 网络失败(保留登录态):', error);
|
||||
return { kind: 'network_error' };
|
||||
}
|
||||
// 用户被封禁:后端返回 403 + error_code=USER_BANNED。
|
||||
// 单独返回 'banned',避免与"token 失效"混淆(提示文案不同)。
|
||||
const errorCodeStr = String(error?.errorCode ?? '').toUpperCase();
|
||||
if (errorCodeStr === 'USER_BANNED') {
|
||||
return { kind: 'banned' };
|
||||
}
|
||||
// 后端明确拒绝(401 等):认证失败
|
||||
const code = error?.code ?? 0;
|
||||
const isAuthError =
|
||||
code === 401 ||
|
||||
error instanceof ApiError && error.code === 401 ||
|
||||
String(error?.message ?? '').includes('unauthorized') ||
|
||||
String(error?.errorCode ?? '').toUpperCase() === 'AUTH_ERROR';
|
||||
if (isAuthError) {
|
||||
return { kind: 'auth_failed' };
|
||||
}
|
||||
// 其它非网络、非 401 错误(如 500):保守视作网络/服务不可用,不登出
|
||||
console.error('[AuthService] fetchCurrentUserFromAPI 失败:', error);
|
||||
return { kind: 'network_error' };
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新Token
|
||||
//
|
||||
// 注意:后端 rotate 后 refresh_token 是一次性使用的,
|
||||
// 成功响应中后端必然返回新的 refresh_token,这里会存新的。
|
||||
// 网络故障时保留旧 token 不清空,等网络恢复后重试。
|
||||
// 非网络错误(如 USER_BANNED、token 失效)向上抛出,由调用方决策。
|
||||
async refreshToken(): Promise<RefreshTokenResponse | null> {
|
||||
try {
|
||||
const refreshToken = await api.getRefreshToken();
|
||||
@@ -267,6 +318,8 @@ class AuthService {
|
||||
if (response.data.token) {
|
||||
await api.setToken(response.data.token);
|
||||
}
|
||||
// refresh_token rotate:后端返回新的 refresh_token 时存新的;
|
||||
// 若后端兼容旧逻辑没返回,则保留旧的(不清空),避免误清登录态。
|
||||
const newRefreshToken = response.data.refresh_token || response.data.refreshToken;
|
||||
if (newRefreshToken) {
|
||||
await api.setRefreshToken(newRefreshToken);
|
||||
@@ -274,10 +327,16 @@ class AuthService {
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('刷新Token失败:', error);
|
||||
await api.clearToken();
|
||||
// 网络故障:保留 token,等网络恢复后重试,避免误判登出
|
||||
if (isNetworkError(error)) {
|
||||
console.warn('[AuthService] 刷新Token网络失败(保留登录态):', error);
|
||||
return null;
|
||||
}
|
||||
// 非网络错误(USER_BANNED、token 失效等):向上抛出,
|
||||
// 由调用方根据 errorCode 决定提示文案与是否登出。
|
||||
console.error('[AuthService] 刷新Token失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前用户信息(兼容旧调用,始终走服务端)
|
||||
@@ -286,8 +345,11 @@ class AuthService {
|
||||
}
|
||||
|
||||
// 强制从服务器获取当前用户信息(别名,保留兼容)
|
||||
// 注意:此处丢弃了"网络故障 vs 认证失败"的区分,仅返回 User | null。
|
||||
// 需要区分的调用方应直接使用 fetchCurrentUserFromAPI()。
|
||||
async fetchCurrentUserFresh(): Promise<User | null> {
|
||||
return this.fetchCurrentUserFromAPI();
|
||||
const result = await this.fetchCurrentUserFromAPI();
|
||||
return result.kind === 'user' ? result.user : null;
|
||||
}
|
||||
|
||||
async fetchUserByIdFromAPI(userId: string): Promise<User | null> {
|
||||
@@ -368,12 +430,16 @@ class AuthService {
|
||||
}
|
||||
|
||||
// 获取用户关注列表
|
||||
async getFollowingList(userId: string, page = 1, pageSize = 20): Promise<User[]> {
|
||||
async getFollowingList(userId: string, page = 1, pageSize = 20, keyword = ''): Promise<User[]> {
|
||||
try {
|
||||
const response = await api.get<{ list: User[] }>(`/users/${userId}/following`, {
|
||||
const params: Record<string, string | number> = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
};
|
||||
if (keyword) {
|
||||
params.keyword = keyword;
|
||||
}
|
||||
const response = await api.get<{ list: User[] }>(`/users/${userId}/following`, params);
|
||||
return response.data.list;
|
||||
} catch (error) {
|
||||
console.error('获取关注列表失败:', error);
|
||||
@@ -382,12 +448,16 @@ class AuthService {
|
||||
}
|
||||
|
||||
// 获取用户粉丝列表
|
||||
async getFollowersList(userId: string, page = 1, pageSize = 20): Promise<User[]> {
|
||||
async getFollowersList(userId: string, page = 1, pageSize = 20, keyword = ''): Promise<User[]> {
|
||||
try {
|
||||
const response = await api.get<{ list: User[] }>(`/users/${userId}/followers`, {
|
||||
const params: Record<string, string | number> = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
};
|
||||
if (keyword) {
|
||||
params.keyword = keyword;
|
||||
}
|
||||
const response = await api.get<{ list: User[] }>(`/users/${userId}/followers`, params);
|
||||
return response.data.list;
|
||||
} catch (error) {
|
||||
console.error('获取粉丝列表失败:', error);
|
||||
|
||||
@@ -12,6 +12,7 @@ export type {
|
||||
BlockStatusResponse,
|
||||
QRCodeSession,
|
||||
ScanResponse,
|
||||
FetchCurrentUserResult,
|
||||
} from './authService';
|
||||
|
||||
export { verificationService } from './verificationService';
|
||||
|
||||
218
src/services/core/__tests__/optionalAuthPaths.test.ts
Normal file
218
src/services/core/__tests__/optionalAuthPaths.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* OptionalAuth 路径白名单与 401 重试策略单测
|
||||
*
|
||||
* 后端 OptionalAuth 中间件行为变化:携带过期 token 不再静默降级为游客,
|
||||
* 而是返回 401。前端用此白名单识别这些"对游客开放"的接口,
|
||||
* 在 401 时清掉本地过期 token 并以游客身份重试一次。
|
||||
*
|
||||
* 策略:仅 GET 请求应用 OptionalAuth 重试(避免误伤 RequireAuth 写接口)。
|
||||
*/
|
||||
|
||||
import {
|
||||
isOptionalAuthPath,
|
||||
shouldRetryAsGuestOn401,
|
||||
OPTIONAL_AUTH_PATH_PREFIXES,
|
||||
} from '../optionalAuthPaths';
|
||||
|
||||
describe('isOptionalAuthPath', () => {
|
||||
describe('应识别为 OptionalAuth 路径(返回 true)', () => {
|
||||
const optionalAuthPaths = [
|
||||
// /posts 全系列
|
||||
'/posts',
|
||||
'/posts?page=1',
|
||||
'/posts/search',
|
||||
'/posts/suggest',
|
||||
'/posts/abc123',
|
||||
'/posts/abc123/view',
|
||||
'/posts/abc123/share',
|
||||
'/posts/abc123/related',
|
||||
'/posts/abc123/refs',
|
||||
'/posts/abc123/ref-click',
|
||||
'/posts/abc123/vote',
|
||||
// /comments
|
||||
'/comments/post/abc',
|
||||
'/comments/post/abc/cursor',
|
||||
'/comments/abc123',
|
||||
'/comments/abc123/replies',
|
||||
'/comments/abc123/replies/flat',
|
||||
// /users/search(必须先于 /users/ 通配命中)
|
||||
'/users/search',
|
||||
'/users/search?keyword=foo',
|
||||
// /users/:id 系列(OptionalAuth)
|
||||
'/users/abc123',
|
||||
'/users/abc123/posts',
|
||||
'/users/abc123/favorites',
|
||||
// /trade
|
||||
'/trade',
|
||||
'/trade?trade_type=sale',
|
||||
'/trade/abc123',
|
||||
'/trade/abc123/view',
|
||||
];
|
||||
|
||||
for (const p of optionalAuthPaths) {
|
||||
it(`返回 true:${p}`, () => {
|
||||
expect(isOptionalAuthPath(p)).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('不应识别为 OptionalAuth 路径(返回 false)', () => {
|
||||
const nonOptionalPaths = [
|
||||
// /users/me* 是 RequireAuth,必须排除
|
||||
'/users/me',
|
||||
'/users/me/',
|
||||
'/users/me/email/verify',
|
||||
'/users/me/email/send-code',
|
||||
'/users/me/privacy',
|
||||
'/users/me/deactivate',
|
||||
'/users/me/deletion-status',
|
||||
// /auth/* 是公开路径(无需 token)
|
||||
'/auth/login',
|
||||
'/auth/register',
|
||||
'/auth/refresh',
|
||||
'/auth/logout',
|
||||
'/auth/password/reset',
|
||||
'/auth/check-username',
|
||||
'/auth/qrcode',
|
||||
// /uploads/* 是 RequireAuth
|
||||
'/uploads/files',
|
||||
'/uploads/images',
|
||||
// 其他 RequireAuth 接口
|
||||
'/notifications',
|
||||
'/conversations',
|
||||
];
|
||||
|
||||
for (const p of nonOptionalPaths) {
|
||||
it(`返回 false:${p}`, () => {
|
||||
expect(isOptionalAuthPath(p)).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
it('空字符串返回 false', () => {
|
||||
expect(isOptionalAuthPath('')).toBe(false);
|
||||
});
|
||||
|
||||
it('仅前缀斜杠返回 false', () => {
|
||||
expect(isOptionalAuthPath('/')).toBe(false);
|
||||
});
|
||||
|
||||
it('/users/me 前缀的精确匹配排除(不误伤 /users/mexico)', () => {
|
||||
// /users/mexico 不应被 /users/me 排除规则误伤,但也不在白名单 → false
|
||||
expect(isOptionalAuthPath('/users/mexico')).toBe(false);
|
||||
});
|
||||
|
||||
it('/users/search 必须命中(即使 /users/ 也在白名单中)', () => {
|
||||
expect(isOptionalAuthPath('/users/search')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OPTIONAL_AUTH_PATH_PREFIXES 完整性', () => {
|
||||
it('白名单包含全部 5 个前缀', () => {
|
||||
expect(OPTIONAL_AUTH_PATH_PREFIXES).toEqual([
|
||||
'/posts',
|
||||
'/comments',
|
||||
'/users/search',
|
||||
'/users/',
|
||||
'/trade',
|
||||
]);
|
||||
});
|
||||
|
||||
it('/users/search 排在 /users/ 之前(优先级,避免被通配吞掉)', () => {
|
||||
const searchIdx = OPTIONAL_AUTH_PATH_PREFIXES.indexOf('/users/search');
|
||||
const usersIdx = OPTIONAL_AUTH_PATH_PREFIXES.indexOf('/users/');
|
||||
expect(searchIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(usersIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(searchIdx).toBeLessThan(usersIdx);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldRetryAsGuestOn401', () => {
|
||||
describe('GET 请求:OptionalAuth 路径应允许游客重试', () => {
|
||||
const getOptionalPaths = [
|
||||
['GET', '/posts'],
|
||||
['GET', '/posts/search'],
|
||||
['GET', '/posts/abc123'],
|
||||
['GET', '/posts/abc123/related'],
|
||||
['GET', '/posts/abc123/vote'],
|
||||
['GET', '/comments/post/abc'],
|
||||
['GET', '/comments/abc123/replies'],
|
||||
['GET', '/users/search'],
|
||||
['GET', '/users/abc123'],
|
||||
['GET', '/users/abc123/posts'],
|
||||
['GET', '/trade'],
|
||||
['GET', '/trade/abc123'],
|
||||
] as const;
|
||||
|
||||
for (const [method, path] of getOptionalPaths) {
|
||||
it(`返回 true:${method} ${path}`, () => {
|
||||
expect(shouldRetryAsGuestOn401(method, path)).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('GET 请求:RequireAuth/公开路径不应游客重试', () => {
|
||||
const getNonOptionalPaths = [
|
||||
['GET', '/users/me'],
|
||||
['GET', '/users/me/email/verify'],
|
||||
['GET', '/auth/login'],
|
||||
['GET', '/notifications'],
|
||||
['GET', '/conversations'],
|
||||
] as const;
|
||||
|
||||
for (const [method, path] of getNonOptionalPaths) {
|
||||
it(`返回 false:${method} ${path}`, () => {
|
||||
expect(shouldRetryAsGuestOn401(method, path)).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('写请求(POST/PUT/DELETE):一律不游客重试', () => {
|
||||
// 即便 path 命中 OptionalAuth 白名单,写接口也走 RequireAuth refresh 流程,
|
||||
// 避免把"本可 refresh 续期"的用户误判为登出。
|
||||
const writeRequests = [
|
||||
// OptionalAuth 写接口(合法但策略上仍走 refresh)
|
||||
['POST', '/posts/abc123/view'],
|
||||
['POST', '/posts/abc123/share'],
|
||||
['POST', '/posts/abc123/ref-click'],
|
||||
['POST', '/trade/abc123/view'],
|
||||
// RequireAuth 写接口(必须走 refresh)
|
||||
['POST', '/posts'],
|
||||
['POST', '/posts/abc123/like'],
|
||||
['DELETE', '/posts/abc123/like'],
|
||||
['POST', '/posts/abc123/favorite'],
|
||||
['DELETE', '/posts/abc123/favorite'],
|
||||
['POST', '/posts/vote'],
|
||||
['POST', '/posts/abc123/vote'],
|
||||
['DELETE', '/posts/abc123/vote'],
|
||||
['POST', '/trade'],
|
||||
['PUT', '/trade/abc123'],
|
||||
['DELETE', '/trade/abc123'],
|
||||
['POST', '/trade/abc123/favorite'],
|
||||
['DELETE', '/trade/abc123/favorite'],
|
||||
// 其他 RequireAuth 写接口
|
||||
['POST', '/auth/logout'],
|
||||
['POST', '/users/me/deactivate'],
|
||||
['PUT', '/users/me/privacy'],
|
||||
] as const;
|
||||
|
||||
for (const [method, path] of writeRequests) {
|
||||
it(`返回 false:${method} ${path}`, () => {
|
||||
expect(shouldRetryAsGuestOn401(method, path)).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('大小写与方法名归一化', () => {
|
||||
it('小写方法名也能识别', () => {
|
||||
expect(shouldRetryAsGuestOn401('get', '/posts')).toBe(true);
|
||||
expect(shouldRetryAsGuestOn401('Get', '/posts')).toBe(true);
|
||||
});
|
||||
|
||||
it('空方法返回 false', () => {
|
||||
expect(shouldRetryAsGuestOn401('', '/posts')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,14 @@ import { Platform } from 'react-native';
|
||||
|
||||
import { eventBus } from '@/core/events/EventBus';
|
||||
import { showVerificationModal } from './verification';
|
||||
import {
|
||||
OPTIONAL_AUTH_PATH_PREFIXES,
|
||||
isOptionalAuthPath,
|
||||
shouldRetryAsGuestOn401,
|
||||
} from './optionalAuthPaths';
|
||||
|
||||
// 重新导出,便于现有从 '@/services/core/api' 导入的调用方使用
|
||||
export { OPTIONAL_AUTH_PATH_PREFIXES, isOptionalAuthPath, shouldRetryAsGuestOn401 };
|
||||
|
||||
// 生产地址 https://withyou.littlelan.cn
|
||||
const getBaseUrl = () => {
|
||||
@@ -72,10 +80,36 @@ interface JwtPayload {
|
||||
iss?: string;
|
||||
}
|
||||
|
||||
// 刷新 token 的结果:
|
||||
// - 'ok' 刷新成功
|
||||
// - 'auth_failed' 后端明确拒绝(refresh token 失效)→ 可安全登出
|
||||
// - 'network_error' 网络故障(未收到后端响应)→ 不应登出
|
||||
// - 'banned' 用户被封禁(后端返回 403 + USER_BANNED)→ 提示"账号已被封禁"
|
||||
type RefreshResult = 'ok' | 'auth_failed' | 'network_error' | 'banned';
|
||||
|
||||
// OptionalAuth 路径白名单与判断函数见 ./optionalAuthPaths(保持纯函数,便于单测)。
|
||||
|
||||
// 判断是否为网络错误(fetch 未收到后端响应)。
|
||||
// 与"后端明确拒绝(401 等)"区分开,避免网络故障被误判为账号退出。
|
||||
export function isNetworkError(error: unknown): boolean {
|
||||
if (error instanceof ApiError) {
|
||||
return error.errorCode === 'NETWORK_ERROR';
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message.toLowerCase();
|
||||
return (
|
||||
msg.includes('network request failed') ||
|
||||
msg.includes('failed to fetch') ||
|
||||
msg.includes('networkerror')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// API 客户端类
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
private refreshLock: Promise<boolean> | null = null;
|
||||
private refreshLock: Promise<RefreshResult> | null = null;
|
||||
private isAuthFailed = false; // 全局认证失败标志,防止无限重试
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
@@ -208,12 +242,21 @@ class ApiClient {
|
||||
if (token) {
|
||||
// 检查 token 是否快过期,如果是则主动刷新
|
||||
if (this.isTokenExpiringSoon(token)) {
|
||||
const refreshed = await this.refreshToken();
|
||||
if (!refreshed) {
|
||||
const refreshResult = await this.refreshToken();
|
||||
if (refreshResult === 'auth_failed') {
|
||||
// 后端明确拒绝(refresh token 失效)→ 登出
|
||||
await this.clearToken();
|
||||
this.navigateToLogin();
|
||||
throw new ApiError(401, '登录已过期,请重新登录');
|
||||
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
|
||||
}
|
||||
if (refreshResult === 'banned') {
|
||||
// 用户被封禁:清登录态并跳登录页,提示"账号已被封禁"
|
||||
await this.clearToken();
|
||||
this.navigateToLogin();
|
||||
throw new ApiError(403, '账号已被封禁,请联系管理员', 'USER_BANNED');
|
||||
}
|
||||
// 'network_error':网络故障,保留登录态,沿用旧 token 继续请求
|
||||
// (旧 token 可能仍有效;若已过期,后端会返回 401,届时再走重试逻辑)
|
||||
const newToken = await this.getToken();
|
||||
if (newToken) {
|
||||
headers['Authorization'] = `Bearer ${newToken}`;
|
||||
@@ -239,16 +282,41 @@ class ApiClient {
|
||||
|
||||
// 处理 401 未授权
|
||||
if (response.status === 401) {
|
||||
if (_retryCount >= 1) {
|
||||
// OptionalAuth 接口(仅 GET):后端检测到携带了过期/失效 token 就会返回 401
|
||||
// (不再静默降级为游客)。客户端清掉本地过期 token,以游客身份重试一次。
|
||||
// 这是可选认证场景,不应触发 navigateToLogin —— 用户可能是游客或 token 自然过期。
|
||||
//
|
||||
// 仅 GET:写接口(POST/PUT/DELETE)的前缀与 RequireAuth 写接口完全重叠,
|
||||
// 仅靠 path 无法区分,故写接口统一走下方 RequireAuth 的 refresh 重试流程,
|
||||
// 避免把"本可 refresh 续期"的用户误判为登出。
|
||||
if (shouldRetryAsGuestOn401(method, path) && _retryCount === 0) {
|
||||
await this.clearToken();
|
||||
this.navigateToLogin();
|
||||
throw new ApiError(401, '登录已过期,请重新登录');
|
||||
return this.request(method, path, params, body, _retryCount + 1);
|
||||
}
|
||||
const refreshed = await this.refreshToken();
|
||||
if (!refreshed) {
|
||||
|
||||
if (_retryCount >= 1) {
|
||||
// 已重试过仍 401 → 后端明确拒绝该账号 → 登出
|
||||
await this.clearToken();
|
||||
this.navigateToLogin();
|
||||
throw new ApiError(401, '登录已过期,请重新登录');
|
||||
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
|
||||
}
|
||||
const refreshResult = await this.refreshToken();
|
||||
if (refreshResult === 'auth_failed') {
|
||||
// refresh token 失效 → 后端明确拒绝 → 登出
|
||||
await this.clearToken();
|
||||
this.navigateToLogin();
|
||||
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
|
||||
}
|
||||
if (refreshResult === 'banned') {
|
||||
// 用户被封禁:清登录态并跳登录页,提示"账号已被封禁"
|
||||
await this.clearToken();
|
||||
this.navigateToLogin();
|
||||
throw new ApiError(403, '账号已被封禁,请联系管理员', 'USER_BANNED');
|
||||
}
|
||||
if (refreshResult === 'network_error') {
|
||||
// 刷新请求网络失败:此时无法判断 401 是真失效还是旧 token 临时过期,
|
||||
// 保留登录态,向上抛出可重试的网络错误,避免误判登出
|
||||
throw new ApiError(503, '网络连接失败,请检查网络后重试', 'NETWORK_ERROR');
|
||||
}
|
||||
|
||||
return this.request(method, path, params, body, _retryCount + 1);
|
||||
@@ -302,14 +370,16 @@ class ApiClient {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 网络错误
|
||||
// 网络错误(fetch 未收到后端响应):标记 errorCode,便于上层区分网络故障与认证拒绝
|
||||
console.error('API请求失败:', error);
|
||||
throw new ApiError(500, '网络请求失败,请检查网络连接');
|
||||
throw new ApiError(503, '网络请求失败,请检查网络连接', 'NETWORK_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新 token(带锁,防止并发刷新)
|
||||
private async refreshToken(): Promise<boolean> {
|
||||
// 返回 'ok' / 'auth_failed' / 'network_error':
|
||||
// - network_error 表示请求未到达后端,调用方不应据此判定账号退出
|
||||
private async refreshToken(): Promise<RefreshResult> {
|
||||
// 如果已有刷新操作正在进行,等待其完成
|
||||
if (this.refreshLock) {
|
||||
return this.refreshLock;
|
||||
@@ -328,11 +398,12 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// 实际执行刷新 token
|
||||
private async doRefreshToken(): Promise<boolean> {
|
||||
private async doRefreshToken(): Promise<RefreshResult> {
|
||||
try {
|
||||
const refreshToken = await this.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
return false;
|
||||
// 没有刷新令牌:后端无法鉴权,视为认证失败
|
||||
return 'auth_failed';
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
|
||||
@@ -345,27 +416,63 @@ class ApiClient {
|
||||
}),
|
||||
});
|
||||
|
||||
// 后端明确拒绝(refresh token 失效/黑名单)→ 认证失败
|
||||
// 但需先判断是否为用户被封禁(403 + error_code=USER_BANNED),
|
||||
// 单独返回 'banned',让上层给出"账号已被封禁"提示而非通用"登录已过期"。
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
if (await this.isBannedResponse(response)) {
|
||||
return 'banned';
|
||||
}
|
||||
return 'auth_failed';
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.code === 0 && data.data?.token) {
|
||||
await this.setToken(data.data.token);
|
||||
// refresh token rotate:后端返回新的 refresh_token 时存新的;
|
||||
// 若后端兼容旧逻辑没返回,则保留旧的(不清空),避免误清登录态。
|
||||
if (data.data.refresh_token || data.data.refreshToken) {
|
||||
await this.setRefreshToken(data.data.refresh_token || data.data.refreshToken);
|
||||
}
|
||||
this.registerDeviceOnRefresh();
|
||||
return true;
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
return false;
|
||||
// 业务码非 0:同样需先排除 USER_BANNED
|
||||
if (this.isBannedErrorCode(data)) {
|
||||
return 'banned';
|
||||
}
|
||||
// 后端明确返回失败 → 认证失败
|
||||
return 'auth_failed';
|
||||
} catch (error) {
|
||||
// fetch 抛异常(DNS 失败、断网、超时)→ 网络故障,不应登出
|
||||
if (isNetworkError(error)) {
|
||||
console.warn('刷新token网络失败(保留登录态):', error);
|
||||
return 'network_error';
|
||||
}
|
||||
console.error('刷新token失败:', error);
|
||||
return 'network_error';
|
||||
}
|
||||
}
|
||||
|
||||
// 判断 /auth/refresh 的失败响应是否为"用户被封禁"(403 + error_code=USER_BANNED)。
|
||||
// 已 clone response,避免消耗原始 body stream。
|
||||
private async isBannedResponse(response: Response): Promise<boolean> {
|
||||
try {
|
||||
const body = await response.json();
|
||||
return this.isBannedErrorCode(body);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 判断响应体是否携带 USER_BANNED 错误码。
|
||||
private isBannedErrorCode(body: any): boolean {
|
||||
if (!body || typeof body !== 'object') return false;
|
||||
const code = String(body.error_code ?? body.errorCode ?? '').toUpperCase();
|
||||
return code === 'USER_BANNED';
|
||||
}
|
||||
|
||||
// 冷启动/刷新token后自动注册设备,方便存量设备接入
|
||||
private registerDeviceOnRefresh(): void {
|
||||
(async () => {
|
||||
@@ -443,13 +550,24 @@ class ApiClient {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
if (this.isTokenExpiringSoon(token)) {
|
||||
const refreshed = await this.refreshToken();
|
||||
if (refreshed) {
|
||||
const refreshResult = await this.refreshToken();
|
||||
if (refreshResult === 'auth_failed') {
|
||||
// 后端明确拒绝 → 登出,避免上传无意义请求
|
||||
await this.clearToken();
|
||||
this.navigateToLogin();
|
||||
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
|
||||
}
|
||||
if (refreshResult === 'banned') {
|
||||
// 用户被封禁:清登录态并跳登录页
|
||||
await this.clearToken();
|
||||
this.navigateToLogin();
|
||||
throw new ApiError(403, '账号已被封禁,请联系管理员', 'USER_BANNED');
|
||||
}
|
||||
// 'ok' 或 'network_error':尝试用(可能已刷新的)token 继续
|
||||
const newToken = await this.getToken();
|
||||
if (newToken) {
|
||||
headers['Authorization'] = `Bearer ${newToken}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
@@ -457,6 +575,10 @@ class ApiClient {
|
||||
|
||||
let data: ApiResponse<T>;
|
||||
|
||||
// 根据请求路径决定 form 字段名:
|
||||
// 后端 UploadFile handler 期望字段名为 "file",其余(图片/头像/封面)为 "image"
|
||||
const fieldName = path === '/uploads/files' ? 'file' : 'image';
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
// Web 端:fetch + Blob/File + FormData
|
||||
const formData = new FormData();
|
||||
@@ -465,7 +587,7 @@ class ApiClient {
|
||||
const uploadFile = new File([blob], file.name, {
|
||||
type: file.type || blob.type || 'application/octet-stream',
|
||||
});
|
||||
formData.append('image', uploadFile);
|
||||
formData.append(fieldName, uploadFile);
|
||||
|
||||
if (additionalData) {
|
||||
Object.keys(additionalData).forEach(key => {
|
||||
@@ -486,7 +608,7 @@ class ApiClient {
|
||||
const result = await fsFile.upload(`${this.baseUrl}${path}`, {
|
||||
httpMethod: 'POST',
|
||||
uploadType: UploadType.MULTIPART,
|
||||
fieldName: 'image',
|
||||
fieldName,
|
||||
mimeType: file.type,
|
||||
headers,
|
||||
parameters: additionalData,
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
/**
|
||||
* WSClient - WebSocket连接管理
|
||||
* 只负责WebSocket连接管理,提供事件驱动架构
|
||||
*/
|
||||
|
||||
import {
|
||||
wsService,
|
||||
WSChatMessage,
|
||||
WSGroupChatMessage,
|
||||
WSReadMessage,
|
||||
WSGroupReadMessage,
|
||||
WSRecallMessage,
|
||||
WSGroupRecallMessage,
|
||||
WSGroupTypingMessage,
|
||||
WSGroupNoticeMessage,
|
||||
WSMessageType,
|
||||
} from '@/services/core';
|
||||
|
||||
// 事件处理器类型
|
||||
type EventHandler<T> = (data: T) => void;
|
||||
|
||||
// WS事件类型
|
||||
export interface WSEvents {
|
||||
'chat': WSChatMessage;
|
||||
'group_message': WSGroupChatMessage;
|
||||
'read': WSReadMessage;
|
||||
'group_read': WSGroupReadMessage;
|
||||
'recall': WSRecallMessage;
|
||||
'group_recall': WSGroupRecallMessage;
|
||||
'group_typing': WSGroupTypingMessage;
|
||||
'group_notice': WSGroupNoticeMessage;
|
||||
'connected': void;
|
||||
'disconnected': void;
|
||||
'error': Error;
|
||||
}
|
||||
|
||||
export type WSEventType = keyof WSEvents;
|
||||
|
||||
class WSClient {
|
||||
private handlers: Map<WSEventType, Set<EventHandler<any>>> = new Map();
|
||||
private unsubscribeFns: Array<() => void> = [];
|
||||
private isInitialized = false;
|
||||
|
||||
/**
|
||||
* 初始化WebSocket监听
|
||||
*/
|
||||
initialize(): void {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
// 监听私聊消息
|
||||
const unsubChat = wsService.on('chat', (message) => {
|
||||
this.emit('chat', message);
|
||||
});
|
||||
|
||||
// 监听群聊消息
|
||||
const unsubGroupMessage = wsService.on('group_message', (message) => {
|
||||
this.emit('group_message', message);
|
||||
});
|
||||
|
||||
// 监听私聊已读回执
|
||||
const unsubRead = wsService.on('read', (message) => {
|
||||
this.emit('read', message);
|
||||
});
|
||||
|
||||
// 监听群聊已读回执
|
||||
const unsubGroupRead = wsService.on('group_read', (message) => {
|
||||
this.emit('group_read', message);
|
||||
});
|
||||
|
||||
// 监听私聊消息撤回
|
||||
const unsubRecall = wsService.on('recall', (message) => {
|
||||
this.emit('recall', message);
|
||||
});
|
||||
|
||||
// 监听群聊消息撤回
|
||||
const unsubGroupRecall = wsService.on('group_recall', (message) => {
|
||||
this.emit('group_recall', message);
|
||||
});
|
||||
|
||||
// 监听群聊输入状态
|
||||
const unsubGroupTyping = wsService.on('group_typing', (message) => {
|
||||
this.emit('group_typing', message);
|
||||
});
|
||||
|
||||
// 监听群通知
|
||||
const unsubGroupNotice = wsService.on('group_notice', (message) => {
|
||||
this.emit('group_notice', message);
|
||||
});
|
||||
|
||||
// 监听连接状态
|
||||
const unsubConnect = wsService.onConnect(() => {
|
||||
this.emit('connected', undefined);
|
||||
});
|
||||
|
||||
const unsubDisconnect = wsService.onDisconnect(() => {
|
||||
this.emit('disconnected', undefined);
|
||||
});
|
||||
|
||||
this.unsubscribeFns = [
|
||||
unsubChat,
|
||||
unsubGroupMessage,
|
||||
unsubRead,
|
||||
unsubGroupRead,
|
||||
unsubRecall,
|
||||
unsubGroupRecall,
|
||||
unsubGroupTyping,
|
||||
unsubGroupNotice,
|
||||
unsubConnect,
|
||||
unsubDisconnect,
|
||||
];
|
||||
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁WebSocket监听
|
||||
*/
|
||||
destroy(): void {
|
||||
this.unsubscribeFns.forEach((fn) => fn());
|
||||
this.unsubscribeFns = [];
|
||||
this.handlers.clear();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅事件
|
||||
*/
|
||||
on<T extends WSEventType>(
|
||||
event: T,
|
||||
handler: EventHandler<WSEvents[T]>
|
||||
): () => void {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, new Set());
|
||||
}
|
||||
this.handlers.get(event)!.add(handler);
|
||||
|
||||
return () => {
|
||||
this.handlers.get(event)?.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件
|
||||
*/
|
||||
private emit<T extends WSEventType>(
|
||||
event: T,
|
||||
data: WSEvents[T]
|
||||
): void {
|
||||
const handlers = this.handlers.get(event);
|
||||
if (handlers) {
|
||||
handlers.forEach((handler) => {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (error) {
|
||||
console.error(`[WSClient] 事件处理器执行失败: ${event}`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查连接状态
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return wsService.isConnected();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动WebSocket连接
|
||||
*/
|
||||
async connect(): Promise<boolean> {
|
||||
return wsService.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开WebSocket连接
|
||||
*/
|
||||
disconnect(): void {
|
||||
wsService.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export const wsClient = new WSClient();
|
||||
export default wsClient;
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import { showPrompt } from '../ui/promptService';
|
||||
import { ApiError, isNetworkError } from './api';
|
||||
|
||||
export enum AppErrorCode {
|
||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||
@@ -45,6 +46,23 @@ const ERROR_MESSAGES: Record<AppErrorCode, string> = {
|
||||
function classifyError(error: unknown): AppErrorCode {
|
||||
if (!error) return AppErrorCode.UNKNOWN;
|
||||
|
||||
// 优先识别项目自有 ApiError(含 errorCode 字段,比 message 关键词匹配更可靠)
|
||||
// 修复衔接缺口:api.ts 用 status=503 + errorCode='NETWORK_ERROR' 表示网络错误,
|
||||
// 但原逻辑只看 status≥500 会误判为 SERVER_ERROR。这里用 isNetworkError 精确识别。
|
||||
if (isNetworkError(error)) return AppErrorCode.NETWORK_ERROR;
|
||||
if (error instanceof ApiError) {
|
||||
if (error.errorCode === 'AUTH_ERROR' || error.code === 401) return AppErrorCode.AUTH_ERROR;
|
||||
// USER_BANNED 优先识别:后端封禁用户的 errorCode,
|
||||
// 复用 FORBIDDEN 分类(语义相近),具体中文提示由调用方按 errorCode 给出。
|
||||
// (不在此处新增 BANNED 枚举值,保持改动面最小。)
|
||||
if (error.errorCode === 'USER_BANNED') return AppErrorCode.FORBIDDEN;
|
||||
if (error.errorCode === 'FORBIDDEN' || error.code === 403) return AppErrorCode.FORBIDDEN;
|
||||
if (error.errorCode === 'NOT_FOUND' || error.code === 404) return AppErrorCode.NOT_FOUND;
|
||||
if (error.errorCode === 'VERIFICATION_REQUIRED') return AppErrorCode.VALIDATION_ERROR;
|
||||
if (error.code === 400 || error.code === 422) return AppErrorCode.VALIDATION_ERROR;
|
||||
if (typeof error.code === 'number' && error.code >= 500) return AppErrorCode.SERVER_ERROR;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { api, WS_URL, TOKEN_KEY, REFRESH_TOKEN_KEY } from './api';
|
||||
export { ApiError } from './api';
|
||||
export { ApiError, isNetworkError } from './api';
|
||||
export type { ApiResponse, PaginatedData } from './api';
|
||||
|
||||
export { wsService } from './wsService';
|
||||
|
||||
44
src/services/core/optionalAuthPaths.ts
Normal file
44
src/services/core/optionalAuthPaths.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* OptionalAuth 中间件路径白名单(纯数据,无副作用,便于单测)。
|
||||
*
|
||||
* 与后端 router.go 中走 optionalAuth 中间件的路由保持同步。
|
||||
* 这些接口对游客开放,但若客户端携带了过期/失效 token,后端会返回 401
|
||||
* (不再静默降级为游客)。客户端策略:401 时清掉本地过期 token,并以游客身份重试一次。
|
||||
*
|
||||
* 注意:/users/me 及其子路径(/users/me/email/verify 等)是 RequireAuth,必须排除。
|
||||
*/
|
||||
|
||||
// OptionalAuth 中间件覆盖的路径前缀。
|
||||
export const OPTIONAL_AUTH_PATH_PREFIXES = [
|
||||
'/posts', // GET /posts, /posts/:id, /posts/search, /posts/suggest, /posts/:id/view, /posts/:id/share, /posts/:id/related, /posts/:id/refs, /posts/:id/ref-click, /posts/:id/vote
|
||||
'/comments', // GET /comments/...
|
||||
'/users/search', // 必须放在 /users/ 之前,避免 /users/me 被通配吞掉
|
||||
'/users/', // GET /users/:id、/users/:id/posts、/users/:id/favorites
|
||||
'/trade', // GET /trade, /trade/:id, POST /trade/:id/view
|
||||
];
|
||||
|
||||
// 判断请求路径是否走 OptionalAuth 中间件(对游客开放、但携带过期 token 会被拒)。
|
||||
//
|
||||
// /users/me* 是 RequireAuth(/users/me、/users/me/email/verify、/users/me/privacy 等),
|
||||
// 必须排除,避免把强制认证接口误判为可选认证。
|
||||
export function isOptionalAuthPath(path: string): boolean {
|
||||
// /users/me* 是 RequireAuth,必须排除
|
||||
if (path.startsWith('/users/me')) return false;
|
||||
return OPTIONAL_AUTH_PATH_PREFIXES.some(p => path.startsWith(p));
|
||||
}
|
||||
|
||||
// 判断"携带过期 token 触发 401 时,能否按 OptionalAuth 语义以游客身份重试"。
|
||||
//
|
||||
// 策略(保守,避免误伤 RequireAuth 接口):
|
||||
// - 仅对 GET 请求应用 OptionalAuth 重试。
|
||||
// - 这些前缀下的 POST/PUT/DELETE 既有 OptionalAuth 接口(如 /posts/:id/view、
|
||||
// /posts/:id/share、/trade/:id/view),也有 RequireAuth 接口(如 /posts/:id/like、
|
||||
// /posts/:id/favorite、/trade/:id/favorite)。两者前缀完全重叠,仅靠 path 无法区分。
|
||||
// - 若对 RequireAuth 写接口误用 OptionalAuth 重试,会把"本可 refresh 续期成功"的
|
||||
// 用户误判为登出(清 token → 游客重试仍 401 → 登出)。为避免该回归,
|
||||
// POST/PUT/DELETE 一律走 RequireAuth 的 refresh 重试流程(即便个别 OptionalAuth
|
||||
// POST 接口因此多走一次 refresh,也只影响性能,不影响正确性)。
|
||||
export function shouldRetryAsGuestOn401(method: string, path: string): boolean {
|
||||
if (method.toUpperCase() !== 'GET') return false;
|
||||
return isOptionalAuthPath(path);
|
||||
}
|
||||
@@ -44,8 +44,6 @@ export type WSMessageType =
|
||||
| 'call_peer_muted'
|
||||
| 'call_invited'
|
||||
| 'call_answered_elsewhere'
|
||||
| 'call_participant_joined'
|
||||
| 'call_participant_left'
|
||||
| 'sync_required';
|
||||
|
||||
export interface WSCallIncomingMessage {
|
||||
@@ -105,19 +103,6 @@ export interface WSCallAnsweredElsewhereMessage {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface WSCallParticipantJoinedMessage {
|
||||
type: 'call_participant_joined';
|
||||
call_id: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export interface WSCallParticipantLeftMessage {
|
||||
type: 'call_participant_left';
|
||||
call_id: string;
|
||||
user_id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface WSErrorMessage {
|
||||
type: 'error';
|
||||
code: string;
|
||||
@@ -272,8 +257,6 @@ export type WSMessage =
|
||||
| WSCallPeerMutedMessage
|
||||
| WSCallInvitedMessage
|
||||
| WSCallAnsweredElsewhereMessage
|
||||
| WSCallParticipantJoinedMessage
|
||||
| WSCallParticipantLeftMessage
|
||||
| WSErrorMessage
|
||||
| WSSyncRequiredMessage;
|
||||
|
||||
@@ -321,6 +304,11 @@ class WebSocketService {
|
||||
preventDisconnectOnBackground = false;
|
||||
|
||||
private getWSUrl(token: string | null): string {
|
||||
// 当前仅携带 token + compress。
|
||||
// 待后端支持 event 级 resume 后,可在此拼接 last_event_id 实现断点续传:
|
||||
// const eventIdParam = this.lastEventId ? `&last_event_id=${encodeURIComponent(this.lastEventId)}` : '';
|
||||
// return `${WS_URL}?token=${encodeURIComponent(token || '')}&compress=1${eventIdParam}`;
|
||||
// 届时重连后服务端从 lastEventId 续传断线期间的事件,无需全量 re-sync。
|
||||
return `${WS_URL}?token=${encodeURIComponent(token || '')}&compress=1`;
|
||||
}
|
||||
|
||||
|
||||
@@ -305,6 +305,33 @@ class MessageService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按消息 ID 获取被引用消息(用于引用预览回填/跳转)
|
||||
* 后端鉴权:必须是该会话参与者,否则返回错误。
|
||||
* @param messageId 被引用消息的 ID
|
||||
*/
|
||||
async getReplyMessage(messageId: string): Promise<MessageResponse | null> {
|
||||
try {
|
||||
const response = await api.get<MessageResponse>(
|
||||
`/messages/${encodeURIComponent(messageId)}`
|
||||
);
|
||||
return response.data ?? null;
|
||||
} catch (error: any) {
|
||||
// 消息不存在或无权限(已被删除/不可见)——引用回填的常见降级场景
|
||||
const errorMessage = error?.message || String(error);
|
||||
if (
|
||||
errorMessage.includes('not found') ||
|
||||
errorMessage.includes('no permission') ||
|
||||
error?.code === 'NOT_FOUND' ||
|
||||
error?.code === 'FORBIDDEN'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
console.error('[MessageService] 获取被引用消息失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(新格式)
|
||||
* 优先使用WebSocket发送,失败时降级到HTTP
|
||||
|
||||
@@ -45,6 +45,13 @@ class JPushService {
|
||||
private notificationCallback: JPushCallback | null = null;
|
||||
private connectResolved = false;
|
||||
private listenersRegistered = false;
|
||||
// notificationArrived 去重:JPush 自有通道与厂商通道(如小米)可能对
|
||||
// 同一条消息各弹一次通知,按 messageID 去重,只让回调处理第一次到达。
|
||||
// 使用 Map<msgID, timestamp> 配合 TTL 清理,避免长时间运行后集合无限增长,
|
||||
// 也避免「淘汰最早一半」策略误删近期仍在被厂商通道二次到达的消息。
|
||||
private arrivedMessageTimestamps: Map<string, number> = new Map();
|
||||
private static readonly ARRIVED_DEDUP_TTL_MS = 5 * 60 * 1000; // 5 分钟
|
||||
private static readonly ARRIVED_DEDUP_MAX = 500; // 触发清理的容量上限
|
||||
|
||||
async initialize(): Promise<boolean> {
|
||||
if (this.isInitialized) return true;
|
||||
@@ -70,12 +77,40 @@ class JPushService {
|
||||
|
||||
JPush!.addNotificationListener((result: any) => {
|
||||
console.log('[JPush] notification:', result);
|
||||
if (result.notificationEventType === 'notificationArrived') {
|
||||
const eventType: string = result.notificationEventType || '';
|
||||
|
||||
// notificationArrived 去重:
|
||||
// JPush 在配置了厂商通道(如小米)的设备上,会对同一条消息同时通过
|
||||
// JPush 自有通道和厂商通道各推送一次,导致通知栏出现两条相同通知。
|
||||
// 这里按 messageID 去重,只处理第一次到达,避免重复震动/重复弹窗。
|
||||
// notificationOpened(用户点击)不去重:点哪条都应响应跳转。
|
||||
if (eventType === 'notificationArrived') {
|
||||
const msgID = String(result.messageID || '');
|
||||
if (msgID) {
|
||||
const now = Date.now();
|
||||
// 清理过期记录(每次到达顺手清理,摊销成本,避免泄漏)
|
||||
if (this.arrivedMessageTimestamps.size > JPushService.ARRIVED_DEDUP_MAX) {
|
||||
for (const [id, ts] of this.arrivedMessageTimestamps) {
|
||||
if (now - ts > JPushService.ARRIVED_DEDUP_TTL_MS) {
|
||||
this.arrivedMessageTimestamps.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.arrivedMessageTimestamps.has(msgID)) {
|
||||
console.log('[JPush] duplicate notificationArrived dropped, msgID:', msgID);
|
||||
return;
|
||||
}
|
||||
// 记录到达时间,TTL 内重复到达即被去重
|
||||
this.arrivedMessageTimestamps.set(msgID, now);
|
||||
}
|
||||
|
||||
const prefs = getNotificationPreferencesSync();
|
||||
if (prefs.vibrationEnabled && AppState.currentState === 'active') {
|
||||
vibrateOnMessage('notification').catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.notificationCallback) {
|
||||
this.notificationCallback({
|
||||
messageID: result.messageID || '',
|
||||
@@ -83,7 +118,7 @@ class JPushService {
|
||||
content: result.content || '',
|
||||
extras: result.extras || {},
|
||||
notificationType: result.extras?.notification_type,
|
||||
notificationEventType: result.notificationEventType,
|
||||
notificationEventType: eventType as any,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -431,6 +466,7 @@ class JPushService {
|
||||
this.notificationCallback = null;
|
||||
this.initPromise = null;
|
||||
this.connectResolved = false;
|
||||
this.arrivedMessageTimestamps.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ interface CreatePostRequest {
|
||||
segments?: MessageSegment[];
|
||||
images?: string[];
|
||||
channel_id?: string;
|
||||
// 客户端幂等键:同一 key 在服务端 TTL 内重复提交只创建一次,避免重复发帖
|
||||
client_request_id?: string;
|
||||
}
|
||||
|
||||
// 更新帖子请求
|
||||
@@ -104,7 +106,6 @@ class PostService {
|
||||
const response = await api.post<PostResponse>('/posts', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 更新帖子
|
||||
async updatePost(postId: string, data: UpdatePostRequest): Promise<Post | null> {
|
||||
try {
|
||||
|
||||
@@ -24,6 +24,11 @@ interface UpdateVoteOptionResponse {
|
||||
class VoteService {
|
||||
/**
|
||||
* 创建投票帖子(通过统一的 POST /posts 接口,投票选项作为 vote segment)
|
||||
*
|
||||
* 支持两种正文形态:
|
||||
* - 长文模式(块编辑器):调用方传入 `segments`(含文本/图片/@ 段),将其作为正文 segments。
|
||||
* 此时 `content` 仅作为纯文本摘要回填,图片等富内容在 segments 中。
|
||||
* - 兼容旧调用:不传 `segments` 时,沿用旧行为(用 `content` 生成单条 text 段 + images 字段)。
|
||||
*/
|
||||
async createVotePost(data: CreateVotePostRequest): Promise<Post | null> {
|
||||
if (!data.vote_options || data.vote_options.length < 2) {
|
||||
@@ -35,12 +40,8 @@ class VoteService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 构建 segments:先放文本内容,再放投票 segment
|
||||
const segments: MessageSegment[] = [];
|
||||
if (data.content) {
|
||||
segments.push({ type: 'text', data: { text: data.content } });
|
||||
}
|
||||
segments.push({
|
||||
// 投票 segment
|
||||
const voteSegment: MessageSegment = {
|
||||
type: 'vote',
|
||||
data: {
|
||||
options: data.vote_options.map((opt, i) => ({
|
||||
@@ -50,7 +51,20 @@ class VoteService {
|
||||
max_choices: 1,
|
||||
is_multi_choice: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
let segments: MessageSegment[];
|
||||
if (data.segments && data.segments.length > 0) {
|
||||
// 长文模式:使用调用方提供的正文 segments,再附加投票段
|
||||
segments = [...data.segments, voteSegment];
|
||||
} else {
|
||||
// 兼容旧调用:用纯文本 content 生成一条 text 段
|
||||
segments = [];
|
||||
if (data.content) {
|
||||
segments.push({ type: 'text', data: { text: data.content } });
|
||||
}
|
||||
segments.push(voteSegment);
|
||||
}
|
||||
|
||||
const response = await api.post<Post>('/posts', {
|
||||
title: data.title,
|
||||
@@ -58,6 +72,7 @@ class VoteService {
|
||||
segments,
|
||||
images: data.images,
|
||||
channel_id: data.channel_id,
|
||||
client_request_id: data.client_request_id,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -135,6 +135,9 @@ class UploadService {
|
||||
|
||||
// 上传文件(通用,对接后端 POST /uploads/files)
|
||||
// 后端返回 { url, name, size, mime_type }
|
||||
// 注意:原生端 expo-file-system 的 File.upload() 无法自定义 multipart filename,
|
||||
// 文件常被复制到缓存目录并以 UUID 命名,因此把真实文件名通过 "name" 字段回传后端,
|
||||
// 由后端作为权威展示名(见 upload_handler.UploadFile)。
|
||||
async uploadFile(
|
||||
file: {
|
||||
uri: string;
|
||||
@@ -144,11 +147,13 @@ class UploadService {
|
||||
folder: string = 'chat'
|
||||
): Promise<UploadResponse | null> {
|
||||
try {
|
||||
const realName = file.name || `file_${Date.now()}`;
|
||||
const response = await api.upload<UploadResponse>('/uploads/files', {
|
||||
uri: file.uri,
|
||||
name: file.name || `file_${Date.now()}`,
|
||||
// 这里的 name 仅作为 multipart part 的 fallback 名,后端会优先使用下方回传的 name 字段
|
||||
name: realName,
|
||||
type: file.type || 'application/octet-stream',
|
||||
}, { folder });
|
||||
}, { folder, name: realName });
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -331,9 +331,16 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
|
||||
// 3. 纯 API 调用验证 Token 有效性(fetchCurrentUserFromAPI 完全不碰 DB)
|
||||
const user = await authService.fetchCurrentUserFromAPI();
|
||||
// 返回判别类型:
|
||||
// - 'user' 成功,继续登录流程
|
||||
// - 'auth_failed' 后端明确拒绝(401)→ 清除登录态
|
||||
// - 'banned' 用户被封禁(403 USER_BANNED)→ 清除登录态 + 提示"账号已被封禁"
|
||||
// - 'network_error' 网络故障 → 保留旧登录态(持久化的 isAuthenticated),
|
||||
// 等待网络恢复后重试,避免断网即被登出
|
||||
const result = await authService.fetchCurrentUserFromAPI();
|
||||
|
||||
if (user) {
|
||||
if (result.kind === 'user') {
|
||||
const user = result.user;
|
||||
const userId = String(user.id);
|
||||
|
||||
// 4. 确保 DB 已初始化(如果预初始化失败,这里会重试)
|
||||
@@ -364,27 +371,45 @@ export const useAuthStore = create<AuthState>()(
|
||||
isAuthenticated: true,
|
||||
currentUser: user,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// 8. 启动 SSE(不阻塞状态设置)
|
||||
startRealtime().catch((err) => {
|
||||
console.warn('[AuthStore] 冷启动实时服务失败:', err);
|
||||
});
|
||||
} else {
|
||||
// Token 已失效或不存在
|
||||
} else if (result.kind === 'network_error') {
|
||||
// 网络故障:不登出、不清 userId。
|
||||
// 保留持久化的 isAuthenticated / currentUser(如有),让用户在网络恢复后仍可用。
|
||||
set({
|
||||
isLoading: false,
|
||||
error: '网络连接失败,已使用本地登录态',
|
||||
});
|
||||
} else if (result.kind === 'banned') {
|
||||
// 用户被封禁:后端返回 403 USER_BANNED。
|
||||
// 清除登录态 + 提示"账号已被封禁,请联系管理员"。
|
||||
await clearUserId();
|
||||
useSessionStore.getState().setUserId(null);
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
isLoading: false,
|
||||
error: '账号已被封禁,请联系管理员',
|
||||
});
|
||||
} else {
|
||||
// auth_failed:后端明确拒绝,Token 真失效 → 清除登录态
|
||||
await clearUserId();
|
||||
useSessionStore.getState().setUserId(null);
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AuthStore] fetchCurrentUser 异常:', error);
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
isLoading: false,
|
||||
error: '获取用户信息失败',
|
||||
});
|
||||
|
||||
@@ -680,24 +680,6 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_participant_joined', (msg) => {
|
||||
console.log('[CallStore] Participant joined:', msg.user_id);
|
||||
const { currentCall } = get();
|
||||
if (!currentCall || currentCall.id !== msg.call_id) return;
|
||||
get().addParticipant(msg.user_id, { name: msg.user_id });
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_participant_left', (msg) => {
|
||||
console.log('[CallStore] Participant left:', msg.user_id, 'reason:', msg.reason);
|
||||
const { currentCall } = get();
|
||||
if (!currentCall || currentCall.id !== msg.call_id) return;
|
||||
get().removeParticipant(msg.user_id);
|
||||
})
|
||||
);
|
||||
|
||||
const cleanup = () => {
|
||||
cleanupResources();
|
||||
if (unsubInvited) {
|
||||
|
||||
@@ -10,16 +10,19 @@ import { useAuthStore } from '../auth/authStore';
|
||||
import type { MessageManagerConversationListDeps } from './types';
|
||||
|
||||
// 导入服务
|
||||
import { MessageDeduplication, messageDeduplication } from './services/MessageDeduplication';
|
||||
import { UserCacheService, userCacheService } from './services/UserCacheService';
|
||||
import { UserCacheService } from './services/UserCacheService';
|
||||
import { ReadReceiptManager } from './services/ReadReceiptManager';
|
||||
import { ConversationOperations } from './services/ConversationOperations';
|
||||
import { MessageSendService } from './services/MessageSendService';
|
||||
import { MessageSyncService } from './services/MessageSyncService';
|
||||
import { WSMessageHandler } from './services/WSMessageHandler';
|
||||
import { RealtimeIngestionPipeline } from './services/RealtimeIngestionPipeline';
|
||||
import { messageRepository } from '@/database';
|
||||
|
||||
// 导入 store
|
||||
import { useMessageStore, normalizeConversationId, loadPersistedLastSystemMessageAt } from './store';
|
||||
import { useMessageStore, normalizeConversationId, loadPersistedLastSystemMessageAt, loadPersistedSyncVersion } from './store';
|
||||
import { createErrorHandler } from '@/services/core';
|
||||
|
||||
const errorHandler = createErrorHandler('MessageManager');
|
||||
|
||||
// 重新导出类型,保持兼容性
|
||||
export type {
|
||||
@@ -31,13 +34,12 @@ export type {
|
||||
|
||||
class MessageManager {
|
||||
// 子模块
|
||||
private deduplication: MessageDeduplication;
|
||||
private userCacheService: UserCacheService;
|
||||
private readReceiptManager: ReadReceiptManager;
|
||||
private conversationOps: ConversationOperations;
|
||||
private sendService: MessageSendService;
|
||||
private syncService: MessageSyncService;
|
||||
private wsHandler: WSMessageHandler;
|
||||
private pipeline: RealtimeIngestionPipeline;
|
||||
|
||||
// 本地状态
|
||||
private currentUserId: string | null = null;
|
||||
@@ -47,7 +49,6 @@ class MessageManager {
|
||||
|
||||
constructor(deps?: MessageManagerConversationListDeps) {
|
||||
// 初始化子模块
|
||||
this.deduplication = new MessageDeduplication();
|
||||
this.userCacheService = new UserCacheService();
|
||||
this.readReceiptManager = new ReadReceiptManager();
|
||||
this.conversationOps = new ConversationOperations();
|
||||
@@ -58,18 +59,35 @@ class MessageManager {
|
||||
this.userCacheService,
|
||||
deps
|
||||
);
|
||||
this.wsHandler = new WSMessageHandler(
|
||||
this.deduplication,
|
||||
this.userCacheService,
|
||||
() => this.getCurrentUserId(),
|
||||
{
|
||||
markAsRead: (id, seq) => this.markAsRead(id, seq),
|
||||
fetchConversations: (force, source) => this.fetchConversations(force, source),
|
||||
fetchUnreadCount: () => this.fetchUnreadCount(),
|
||||
fetchMessages: (id) => this.fetchMessages(id),
|
||||
syncBySeq: () => this.syncService.syncBySeq(),
|
||||
this.pipeline = new RealtimeIngestionPipeline({
|
||||
userCacheService: this.userCacheService,
|
||||
getCurrentUserId: () => this.getCurrentUserId(),
|
||||
onActiveConvMessage: (id, seq) => this.markAsRead(id, seq),
|
||||
triggerSync: async (source) => {
|
||||
// 三级恢复策略(精确度从高到低):
|
||||
// 1. syncByVersion:基于持久化游标的精确增量(对标 Matrix resume),最省流量
|
||||
// 2. syncBySeq:基于 seq 比对的增量(原有路径)
|
||||
// 3. 全量刷新:会话列表 + 活动会话消息 + 未读
|
||||
const persistedVersion = useMessageStore.getState().syncVersion;
|
||||
let ok = false;
|
||||
if (persistedVersion && persistedVersion > 0) {
|
||||
ok = await this.syncService.syncByVersion(persistedVersion);
|
||||
}
|
||||
);
|
||||
if (!ok) {
|
||||
ok = await this.syncService.syncBySeq();
|
||||
}
|
||||
if (!ok) {
|
||||
await this.fetchConversations(true, source);
|
||||
const activeConv = useMessageStore.getState().getActiveConversation();
|
||||
if (activeConv) {
|
||||
await this.fetchMessages(activeConv).catch(() => {});
|
||||
}
|
||||
}
|
||||
await this.fetchUnreadCount().catch(() => {});
|
||||
},
|
||||
fetchConversations: (force, src) => this.fetchConversations(force, src),
|
||||
fetchUnreadCount: () => this.fetchUnreadCount(),
|
||||
});
|
||||
|
||||
// 监听认证状态变化
|
||||
this.initAuthListener();
|
||||
@@ -98,7 +116,7 @@ class MessageManager {
|
||||
|
||||
if (nextUserId && useMessageStore.getState().currentConversationId) {
|
||||
this.activateConversation(useMessageStore.getState().currentConversationId!, { forceSync: true }).catch(error => {
|
||||
console.error('[MessageManager] 登录态就绪后重激活会话失败:', error);
|
||||
errorHandler(error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,6 +134,74 @@ class MessageManager {
|
||||
return this.currentUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbox 恢复:扫描本地未完成发送的消息(pending/failed),读入 store 并自动重试。
|
||||
*
|
||||
* 对标 Matrix Send Queue:发送是离线可靠操作,pending/failed 消息必须持久化,
|
||||
* App 重启后能恢复未完成的发送。
|
||||
*
|
||||
* 策略:
|
||||
* - pending:重启后视为「发送可能已到服务端但未收到 ACK」,直接重试一次(幂等由服务端保证,
|
||||
* 或 WS 回执去重)。重试成功则落库 normal + 删 tempId;仍失败则保持 failed 等用户手动。
|
||||
* - failed:重启后保持 failed 显示(用户可见失败气泡),不自动重试(避免无限重试刷屏),
|
||||
* 等待用户点击重试。
|
||||
*/
|
||||
private async restoreOutboxMessages(): Promise<void> {
|
||||
try {
|
||||
const pendingOrFailed = await messageRepository.getPendingOrFailed();
|
||||
if (pendingOrFailed.length === 0) return;
|
||||
|
||||
const store = useMessageStore.getState();
|
||||
// 1. 全部读入 store(按 conversationId 分组合并,幂等)
|
||||
const byConv = new Map<string, MessageResponse[]>();
|
||||
for (const cached of pendingOrFailed) {
|
||||
const msg: MessageResponse = {
|
||||
id: cached.id,
|
||||
conversation_id: cached.conversationId,
|
||||
sender_id: cached.senderId,
|
||||
seq: cached.seq,
|
||||
segments: cached.segments || [],
|
||||
created_at: cached.createdAt,
|
||||
status: cached.status as any,
|
||||
};
|
||||
const arr = byConv.get(cached.conversationId) || [];
|
||||
arr.push(msg);
|
||||
byConv.set(cached.conversationId, arr);
|
||||
}
|
||||
for (const [convId, msgs] of byConv) {
|
||||
store.mergeMessages(convId, msgs);
|
||||
}
|
||||
|
||||
// 2. 自动重试 pending 消息(failed 不自动重试,等用户手动)
|
||||
const pending = pendingOrFailed.filter(m => m.status === 'pending');
|
||||
if (pending.length > 0) {
|
||||
console.log(`[MessageManager] 恢复 ${pending.length} 条 pending 消息,自动重试`);
|
||||
// 串行重试,避免并发发送打乱顺序
|
||||
for (const cached of pending) {
|
||||
try {
|
||||
// pending 重启后无法确认是否已到服务端,统一按 failed 处理后重试:
|
||||
// retrySendMessage 会删旧记录、用新 tempId 重新走乐观更新流程
|
||||
await messageRepository.updateStatus(cached.id, 'failed');
|
||||
const failedMsg: MessageResponse = {
|
||||
id: cached.id,
|
||||
conversation_id: cached.conversationId,
|
||||
sender_id: cached.senderId,
|
||||
seq: cached.seq,
|
||||
segments: cached.segments || [],
|
||||
created_at: cached.createdAt,
|
||||
status: 'failed',
|
||||
};
|
||||
await this.sendService.retrySendMessage(cached.conversationId, failedMsg);
|
||||
} catch (error) {
|
||||
errorHandler(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errorHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 初始化与销毁 ====================
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
@@ -138,13 +224,16 @@ class MessageManager {
|
||||
return;
|
||||
}
|
||||
|
||||
this.wsHandler.setBootstrapping(true);
|
||||
this.pipeline.setBootstrapping(true);
|
||||
|
||||
// 从本地缓存恢复最后系统通知时间(避免首次渲染显示当前时间)
|
||||
await loadPersistedLastSystemMessageAt();
|
||||
|
||||
// 初始化SSE监听
|
||||
this.wsHandler.connect();
|
||||
// 恢复持久化的同步游标(供 triggerSync 的 syncByVersion 精确增量恢复)
|
||||
await loadPersistedSyncVersion();
|
||||
|
||||
// 注册实时事件监听(start 内部会先注销旧监听器再注册)
|
||||
this.pipeline.start();
|
||||
|
||||
// 加载会话列表
|
||||
await this.fetchConversations(false, 'initialize');
|
||||
@@ -156,15 +245,19 @@ class MessageManager {
|
||||
|
||||
// 先关闭 bootstrap(事件流转入正常处理路径),再做尾部 flush
|
||||
// 避免 flush 期间新到的事件被压入 buffer 后无人消费
|
||||
this.wsHandler.setBootstrapping(false);
|
||||
await this.wsHandler.flushBufferedSSEEvents();
|
||||
this.pipeline.setBootstrapping(false);
|
||||
await this.pipeline.flushBufferedEvents();
|
||||
|
||||
if (useMessageStore.getState().currentConversationId) {
|
||||
await this.fetchMessages(useMessageStore.getState().currentConversationId!);
|
||||
}
|
||||
|
||||
// Outbox 恢复:扫描本地未完成发送的消息(pending/failed),读入 store 并自动重试。
|
||||
// 修复前:pending/failed 消息只存在内存,App 重启后完全丢失。
|
||||
await this.restoreOutboxMessages();
|
||||
} catch (error) {
|
||||
console.error('[MessageManager] 初始化失败:', error);
|
||||
this.wsHandler.setBootstrapping(false);
|
||||
errorHandler(error);
|
||||
this.pipeline.setBootstrapping(false);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -176,11 +269,10 @@ class MessageManager {
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.wsHandler.disconnect();
|
||||
this.pipeline.stop();
|
||||
useMessageStore.getState().reset();
|
||||
this.syncService.restartSources();
|
||||
this.readReceiptManager.reset();
|
||||
this.deduplication.reset();
|
||||
this.userCacheService.reset();
|
||||
this.activatingConversationTasks.clear();
|
||||
this.initializePromise = null;
|
||||
@@ -217,11 +309,18 @@ class MessageManager {
|
||||
async sendMessage(
|
||||
conversationId: string,
|
||||
segments: MessageSegment[],
|
||||
options?: { replyToId?: string }
|
||||
options?: { replyToId?: string; detailType?: 'private' | 'group' }
|
||||
): Promise<MessageResponse | null> {
|
||||
return this.sendService.sendMessage(conversationId, segments, options);
|
||||
}
|
||||
|
||||
async retrySendMessage(
|
||||
conversationId: string,
|
||||
failedMessage: MessageResponse
|
||||
): Promise<MessageResponse | null> {
|
||||
return this.sendService.retrySendMessage(conversationId, failedMessage);
|
||||
}
|
||||
|
||||
async markAsRead(conversationId: string, seq: number): Promise<void> {
|
||||
return this.readReceiptManager.markAsRead(conversationId, seq);
|
||||
}
|
||||
|
||||
192
src/stores/message/__tests__/RealtimeIngestionPipeline.test.ts
Normal file
192
src/stores/message/__tests__/RealtimeIngestionPipeline.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* RealtimeIngestionPipeline 单测
|
||||
*
|
||||
* 锁定的不变量:
|
||||
* 1. 重复 chat 事件只入库一次、副作用(未读递增)只触发一次。
|
||||
* 2. ingest 中途副作用抛错时,消息仍已落库(修复「标记早于落库」竞态的核心保证)。
|
||||
* 3. start() 重复调用不叠加监听器;stop() 彻底注销(杜绝监听器泄漏)。
|
||||
*/
|
||||
|
||||
import { wsService } from '@/services/core';
|
||||
import { eventBus } from '@/core/events/EventBus';
|
||||
import { useMessageStore } from '../store';
|
||||
import { RealtimeIngestionPipeline } from '../services/RealtimeIngestionPipeline';
|
||||
import type { RealtimeIngestionPipelineDeps } from '../services/RealtimeIngestionPipeline';
|
||||
|
||||
function makeDeps(overrides: Partial<RealtimeIngestionPipelineDeps> = {}): RealtimeIngestionPipelineDeps {
|
||||
return {
|
||||
userCacheService: { getSenderInfo: async () => null, enrichMessagesWithSenderInfo: () => {} },
|
||||
getCurrentUserId: () => 'me',
|
||||
onActiveConvMessage: async () => {},
|
||||
triggerSync: async () => {},
|
||||
fetchConversations: async () => {},
|
||||
fetchUnreadCount: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeChatMessage(id: string, seq: number, senderId = 'other') {
|
||||
return {
|
||||
type: 'chat' as const,
|
||||
conversation_id: 'c1',
|
||||
id,
|
||||
sender_id: senderId,
|
||||
seq,
|
||||
segments: [{ type: 'text', data: { text: 'hi' } }] as any[],
|
||||
created_at: `2024-01-01T00:00:${String(seq).padStart(2, '0')}Z`,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RealtimeIngestionPipeline', () => {
|
||||
beforeEach(() => {
|
||||
useMessageStore.getState().reset();
|
||||
(wsService as any).reset();
|
||||
(eventBus as any).reset();
|
||||
// 确保 store 处于已初始化状态,否则事件会被 buffer
|
||||
useMessageStore.getState().setInitialized(true);
|
||||
});
|
||||
|
||||
describe('幂等 ingest', () => {
|
||||
it('重复 chat 事件只入库一次', async () => {
|
||||
const pipeline = new RealtimeIngestionPipeline(makeDeps());
|
||||
const msg = makeChatMessage('m1', 1);
|
||||
|
||||
await pipeline.ingestChatMessage(msg);
|
||||
await pipeline.ingestChatMessage(msg); // 重复
|
||||
|
||||
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('重复 chat 事件只递增未读一次', async () => {
|
||||
const pipeline = new RealtimeIngestionPipeline(makeDeps());
|
||||
// 先确保会话存在,否则 incrementUnreadAtomic 会因找不到会话而跳过
|
||||
useMessageStore.getState().updateConversation({
|
||||
id: 'c1',
|
||||
unread_count: 0,
|
||||
last_seq: 0,
|
||||
} as any);
|
||||
const msg = makeChatMessage('m1', 1);
|
||||
|
||||
await pipeline.ingestChatMessage(msg);
|
||||
await pipeline.ingestChatMessage(msg);
|
||||
|
||||
const conv = useMessageStore.getState().getConversation('c1');
|
||||
expect(conv?.unread_count).toBe(1);
|
||||
});
|
||||
|
||||
it('重复 chat 事件只发一次震动', async () => {
|
||||
const pipeline = new RealtimeIngestionPipeline(makeDeps());
|
||||
useMessageStore.getState().updateConversation({
|
||||
id: 'c1',
|
||||
unread_count: 0,
|
||||
last_seq: 0,
|
||||
} as any);
|
||||
const msg = makeChatMessage('m1', 1);
|
||||
|
||||
await pipeline.ingestChatMessage(msg);
|
||||
await pipeline.ingestChatMessage(msg);
|
||||
|
||||
const vibrateEvents = (eventBus as any).emitted.filter((e: any) => e.type === 'VIBRATE');
|
||||
expect(vibrateEvents).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('每次 ingest 都发 ACK(即使重复,服务器侧幂等)', async () => {
|
||||
const pipeline = new RealtimeIngestionPipeline(makeDeps());
|
||||
const msg = makeChatMessage('m1', 1);
|
||||
|
||||
await pipeline.ingestChatMessage(msg);
|
||||
await pipeline.ingestChatMessage(msg);
|
||||
|
||||
expect((wsService as any).ackCalls).toEqual(['m1', 'm1']);
|
||||
});
|
||||
|
||||
it('不同消息独立入库', async () => {
|
||||
const pipeline = new RealtimeIngestionPipeline(makeDeps());
|
||||
await pipeline.ingestChatMessage(makeChatMessage('m1', 1));
|
||||
await pipeline.ingestChatMessage(makeChatMessage('m2', 2));
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
expect(msgs.map(m => m.id)).toEqual(['m1', 'm2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('落库先于副作用(不丢消息)', () => {
|
||||
it('副作用回调抛错时消息仍已落库', async () => {
|
||||
// triggerSync 抛错不应影响 ingest(虽然 ingest 本身不调 triggerSync,
|
||||
// 这里验证 onActiveConvMessage 抛错时消息仍在 store)
|
||||
const pipeline = new RealtimeIngestionPipeline(
|
||||
makeDeps({
|
||||
onActiveConvMessage: async () => {
|
||||
throw new Error('markAsRead failed');
|
||||
},
|
||||
})
|
||||
);
|
||||
// 设为活动会话以触发 onActiveConvMessage
|
||||
useMessageStore.getState().setCurrentConversation('c1');
|
||||
|
||||
await pipeline.ingestChatMessage(makeChatMessage('m1', 1));
|
||||
|
||||
// 关键不变量:消息已落库,副作用失败未回滚
|
||||
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('监听器生命周期(杜绝泄漏)', () => {
|
||||
it('start() 注册监听器,事件能被路由', async () => {
|
||||
const pipeline = new RealtimeIngestionPipeline(makeDeps());
|
||||
pipeline.start();
|
||||
// 触发 chat 事件
|
||||
(wsService as any).emit('chat', makeChatMessage('m1', 1));
|
||||
// ingestChatMessage 是 async,等一个微任务
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1);
|
||||
pipeline.stop();
|
||||
});
|
||||
|
||||
it('stop() 彻底注销所有监听器', () => {
|
||||
const pipeline = new RealtimeIngestionPipeline(makeDeps());
|
||||
pipeline.start();
|
||||
const beforeChat = (wsService as any).listenerCount('chat');
|
||||
const beforeConnect = (wsService as any).connectHandlerCount();
|
||||
expect(beforeChat).toBeGreaterThan(0);
|
||||
expect(beforeConnect).toBeGreaterThan(0);
|
||||
|
||||
pipeline.stop();
|
||||
|
||||
expect((wsService as any).listenerCount('chat')).toBe(0);
|
||||
expect((wsService as any).listenerCount('group_message')).toBe(0);
|
||||
expect((wsService as any).listenerCount('recall')).toBe(0);
|
||||
expect((wsService as any).listenerCount('sync_required')).toBe(0);
|
||||
expect((wsService as any).connectHandlerCount()).toBe(0);
|
||||
expect((wsService as any).disconnectHandlerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('重复 start() 不叠加监听器', () => {
|
||||
const pipeline = new RealtimeIngestionPipeline(makeDeps());
|
||||
pipeline.start();
|
||||
const firstCount = (wsService as any).listenerCount('chat');
|
||||
expect(firstCount).toBeGreaterThan(0);
|
||||
|
||||
pipeline.start();
|
||||
pipeline.start();
|
||||
|
||||
// 监听器数量应与首次注册后相同,而非 3 倍
|
||||
expect((wsService as any).listenerCount('chat')).toBe(firstCount);
|
||||
pipeline.stop();
|
||||
});
|
||||
|
||||
it('stop() 后再 start() 可重新接收事件', async () => {
|
||||
const pipeline = new RealtimeIngestionPipeline(makeDeps());
|
||||
pipeline.start();
|
||||
pipeline.stop();
|
||||
pipeline.start();
|
||||
|
||||
(wsService as any).emit('chat', makeChatMessage('m1', 1));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1);
|
||||
pipeline.stop();
|
||||
});
|
||||
});
|
||||
});
|
||||
345
src/stores/message/__tests__/messageHydration.test.ts
Normal file
345
src/stores/message/__tests__/messageHydration.test.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* 消息 hydration 管线单测(A+B+C 融合方案)
|
||||
*
|
||||
* 锁定的不变量(直接对应「从消息栏进入聊天页只显示一条消息、历史被吞」的回归):
|
||||
*
|
||||
* 1. 【方案 A 核心回归】内存已有 WS 零散最新消息时,进入会话仍能从本地 DB 加载历史,
|
||||
* 不再因为「内存非空」而跳过本地读取、误判 baseline。
|
||||
* 2. 【方案 B 连续回填】本地连续区间存在下缺口时,管线循环 loadMore 回填直到连续到顶。
|
||||
* 3. 【方案 B 上限保护】历史巨大时回填在 MAX_GAP_FILL_ROUNDS 后停止,不卡死。
|
||||
* 4. 【方案 C 门控】首次进入走完整管线并标记 hydrated;重入只做轻量增量同步。
|
||||
* 5. 回填后内存消息按 seq 严格升序、无重复、无丢失。
|
||||
*
|
||||
* mock 机制:jest moduleNameMapper 自动把 @/services/message、@/database 重定向到
|
||||
* mocks/messageServiceMock、mocks/databaseMock。测试通过 __setMessagesHandler /
|
||||
* __setMessagesByConversation 配置响应,beforeEach 调 __reset 复原。
|
||||
*/
|
||||
|
||||
import { useMessageStore } from '../store';
|
||||
import { MessageSyncService } from '../services/MessageSyncService';
|
||||
import { messageService } from '@/services/message';
|
||||
import { messageRepository } from '@/database';
|
||||
import type { MessageResponse } from '../../../types/dto';
|
||||
import { MAX_GAP_FILL_ROUNDS, MESSAGES_PAGE_SIZE } from '../constants';
|
||||
|
||||
// mock 注入的测试辅助方法(运行时由 mocks/databaseMock、mocks/messageServiceMock 提供,
|
||||
// 类型上不存在,统一用 any 别名调用,与现有 (xxx as any).reset() 风格一致)
|
||||
const repoMock = messageRepository as any;
|
||||
const serviceMock = messageService as any;
|
||||
|
||||
// ---- 测试辅助 ----
|
||||
|
||||
function makeMessage(id: string, seq: number, extra: Partial<MessageResponse> = {}): MessageResponse {
|
||||
return {
|
||||
id,
|
||||
conversation_id: 'c1',
|
||||
sender_id: 'u1',
|
||||
seq,
|
||||
segments: [{ type: 'text', data: { text: `msg-${seq}` } }],
|
||||
created_at: `2024-01-01T00:00:${String(seq).padStart(2, '0')}Z`,
|
||||
status: 'normal',
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** 生成 [from..to] 区间的消息(含端点),seq 升序。 */
|
||||
function makeRange(from: number, to: number, prefix = 'm'): MessageResponse[] {
|
||||
const out: MessageResponse[] = [];
|
||||
for (let s = from; s <= to; s++) {
|
||||
out.push(makeMessage(`${prefix}-${s}`, s));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟真实后端的 getMessages 行为:
|
||||
* - afterSeq 指定:返回 seq > afterSeq 的「新消息」(默认空,可由 extraOverrides 注入)
|
||||
* - beforeSeq 指定:返回 [beforeSeq-limit, beforeSeq-1] 区间的历史(分页回填)
|
||||
* - 都未指定:返回最近 limit 条快照(默认空,可由 snapshotOverride 注入)
|
||||
*
|
||||
* 这样回填循环每轮 beforeSeq 请求都能拿到真实历史,模拟线上行为。
|
||||
*/
|
||||
function makeServerHandler(opts: {
|
||||
topSeq?: number; // 会话历史的最大 seq(决定回填到顶);不传则按 beforeSeq 递减
|
||||
extraAbove?: MessageResponse[]; // afterSeq 增量返回的新消息
|
||||
snapshot?: MessageResponse[]; // 快照响应覆盖
|
||||
} = {}): (args: any) => any {
|
||||
return ({ afterSeq, beforeSeq, limit }) => {
|
||||
const pageSize = limit || MESSAGES_PAGE_SIZE;
|
||||
if (afterSeq !== undefined) {
|
||||
return { messages: opts.extraAbove || [], total: 0, page: 1, page_size: pageSize };
|
||||
}
|
||||
if (beforeSeq !== undefined) {
|
||||
const from = Math.max(1, beforeSeq - pageSize);
|
||||
const to = beforeSeq - 1;
|
||||
if (to < 1) return { messages: [], total: 0, page: 1, page_size: pageSize };
|
||||
return { messages: makeRange(from, to), total: to, page: 1, page_size: pageSize };
|
||||
}
|
||||
// 快照
|
||||
return { messages: opts.snapshot || [], total: 0, page: 1, page_size: pageSize };
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造 MessageSyncService,依赖全部用最小存根。 */
|
||||
function createSyncService(): MessageSyncService {
|
||||
return new MessageSyncService(
|
||||
() => 'me',
|
||||
{ markAsRead: async () => {}, markAllAsRead: async () => {} } as any,
|
||||
{
|
||||
getSenderInfo: async () => null,
|
||||
enrichMessagesWithSenderInfo: () => {},
|
||||
} as any,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
describe('消息 hydration 管线', () => {
|
||||
beforeEach(() => {
|
||||
useMessageStore.getState().reset();
|
||||
repoMock.__reset();
|
||||
serviceMock.__reset();
|
||||
});
|
||||
|
||||
describe('方案 A:baseline 修正(核心回归)', () => {
|
||||
it('内存已有 WS 零散最新消息时,仍从本地 DB 加载更早的历史', async () => {
|
||||
// 场景:WS 已把 seq=49 写入内存(模拟用户停留在消息栏时收到的最新推送),
|
||||
// 本地 DB 存有 seq=1~48 的完整历史。
|
||||
// 修复前:existingMessagesAtStart.length>0 → 跳过本地读取 → after_seq=49 → 历史全丢。
|
||||
// 修复后:无条件读本地补全 → 内存得到 seq=1~49。
|
||||
useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('ws-49', 49));
|
||||
repoMock.__setMessagesByConversation('c1', makeRange(1, 48));
|
||||
|
||||
// 后端:after_seq=49 无新消息;beforeSeq 请求返回分页历史(模拟真实后端)
|
||||
serviceMock.__setMessagesHandler(makeServerHandler());
|
||||
|
||||
const sync = createSyncService();
|
||||
await sync.fetchMessages('c1');
|
||||
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
// 关键断言:应能看到完整历史,而非只有 seq=49 一条
|
||||
expect(msgs).toHaveLength(49);
|
||||
expect(msgs.map((m) => m.seq)).toEqual(expect.arrayContaining(makeRange(1, 49).map((m) => m.seq)));
|
||||
expect(msgs[0].seq).toBe(1);
|
||||
expect(msgs[msgs.length - 1].seq).toBe(49);
|
||||
// 已 hydrated
|
||||
expect(useMessageStore.getState().isHydrated('c1')).toBe(true);
|
||||
});
|
||||
|
||||
it('内存与本地均空时走冷启动快照分支', async () => {
|
||||
// 冷启动:本地无数据,拉取最近 50 条快照
|
||||
const snapshot = makeRange(1, 50);
|
||||
serviceMock.__setMessagesHandler(makeServerHandler({ snapshot }));
|
||||
|
||||
const sync = createSyncService();
|
||||
await sync.fetchMessages('c1');
|
||||
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
expect(msgs).toHaveLength(50);
|
||||
expect(useMessageStore.getState().isHydrated('c1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('方案 B:连续区间回填', () => {
|
||||
it('本地连续区间有下缺口时,循环 loadMore 回填到顶', async () => {
|
||||
// 场景:本地 DB 只有 seq=45~49(minSeq=45 > 1,存在下缺口),
|
||||
// 服务端按 beforeSeq 分段返回历史。
|
||||
// 注意:getByConversation 默认读 MESSAGES_PAGE_SIZE=20 条,本地只有 5 条,
|
||||
// 故 Step 1 先把 5 条并入内存;Step 2 上缺口无新消息;
|
||||
// Step 3 回填:beforeSeq=45 → 服务端返回 seq<45 的一页。
|
||||
repoMock.__setMessagesByConversation('c1', makeRange(45, 49));
|
||||
|
||||
serviceMock.__setMessagesHandler(makeServerHandler());
|
||||
|
||||
const sync = createSyncService();
|
||||
await sync.fetchMessages('c1');
|
||||
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
// 应连续到 seq=1
|
||||
expect(msgs).toHaveLength(49);
|
||||
const seqs = msgs.map((m) => m.seq);
|
||||
expect(seqs).toEqual(expect.arrayContaining(makeRange(1, 49).map((m) => m.seq)));
|
||||
expect(Math.min(...seqs)).toBe(1);
|
||||
expect(Math.max(...seqs)).toBe(49);
|
||||
});
|
||||
|
||||
it('回填在 MAX_GAP_FILL_ROUNDS 后停止,不卡死', async () => {
|
||||
// 场景:历史巨大(seq 到 10000),每页 MESSAGES_PAGE_SIZE,
|
||||
// 5 轮最多回填 5*20=100 条,无法到顶,应在上限处停止。
|
||||
const TOP = 10000;
|
||||
repoMock.__setMessagesByConversation('c1', makeRange(TOP - 4, TOP, 'top'));
|
||||
|
||||
serviceMock.__setMessagesHandler(makeServerHandler());
|
||||
|
||||
const sync = createSyncService();
|
||||
await sync.fetchMessages('c1');
|
||||
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
// 本地 5 + 至多 5*20=100 回填 = 至多 105 条,远小于 10000(证明未无限循环)
|
||||
expect(msgs.length).toBeLessThanOrEqual(5 + MAX_GAP_FILL_ROUNDS * MESSAGES_PAGE_SIZE);
|
||||
expect(msgs.length).toBeGreaterThan(5); // 确实回填了
|
||||
// 仍标记 hydrated(避免反复重试卡首屏)
|
||||
expect(useMessageStore.getState().isHydrated('c1')).toBe(true);
|
||||
});
|
||||
|
||||
it('回填后消息按 seq 严格升序、无重复', async () => {
|
||||
repoMock.__setMessagesByConversation('c1', makeRange(10, 15));
|
||||
serviceMock.__setMessagesHandler(makeServerHandler());
|
||||
|
||||
const sync = createSyncService();
|
||||
await sync.fetchMessages('c1');
|
||||
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
const seqs = msgs.map((m) => m.seq);
|
||||
// 严格升序
|
||||
for (let i = 1; i < seqs.length; i++) {
|
||||
expect(seqs[i]).toBeGreaterThan(seqs[i - 1]);
|
||||
}
|
||||
// 无重复 id
|
||||
const ids = msgs.map((m) => m.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('方案 C:hydrated 门控', () => {
|
||||
it('首次进入走完整管线;重入只做轻量增量同步', async () => {
|
||||
let snapshotCallCount = 0;
|
||||
let beforeSeqCallCount = 0;
|
||||
let localReadCount = 0;
|
||||
// 首次 hydration 的上缺口同步(after_seq=20)返回空;
|
||||
// 重入的增量同步(after_seq=20)返回 seq=21,用于验证重入确实走了增量路径。
|
||||
let afterSeqCallCount = 0;
|
||||
|
||||
const realGetByConversation = repoMock.getByConversation.bind(repoMock);
|
||||
repoMock.getByConversation = async (id: string, limit?: number) => {
|
||||
localReadCount++;
|
||||
return realGetByConversation(id, limit);
|
||||
};
|
||||
|
||||
repoMock.__setMessagesByConversation('c1', makeRange(1, 20));
|
||||
|
||||
serviceMock.__setMessagesHandler(({ afterSeq, beforeSeq }: any) => {
|
||||
if (afterSeq !== undefined) {
|
||||
afterSeqCallCount++;
|
||||
// 仅重入(第 2 次 afterSeq 请求)返回新消息 seq=21
|
||||
if (afterSeqCallCount === 2) {
|
||||
return { messages: [makeMessage('new-21', 21)], total: 0, page: 1, page_size: 20 };
|
||||
}
|
||||
return { messages: [], total: 0, page: 1, page_size: 20 };
|
||||
}
|
||||
if (beforeSeq !== undefined) {
|
||||
beforeSeqCallCount++;
|
||||
// 首次 hydration 本地已连续到 1,理论上不应被调用
|
||||
return { messages: [], total: 0, page: 1, page_size: 20 };
|
||||
}
|
||||
snapshotCallCount++;
|
||||
return { messages: [], total: 0, page: 1, page_size: 20 };
|
||||
});
|
||||
|
||||
const sync = createSyncService();
|
||||
|
||||
// 首次:完整 hydration。本地 1~20 连续到顶,无需回填。
|
||||
const localReadAfterFirst = localReadCount;
|
||||
await sync.fetchMessages('c1');
|
||||
expect(useMessageStore.getState().isHydrated('c1')).toBe(true);
|
||||
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(20);
|
||||
// 本地已连续到顶,回填不应触发服务端 beforeSeq 请求
|
||||
expect(beforeSeqCallCount).toBe(0);
|
||||
expect(snapshotCallCount).toBe(0);
|
||||
expect(localReadCount).toBeGreaterThan(localReadAfterFirst); // 首次读了本地
|
||||
|
||||
// 重入:应只做轻量增量(after_seq=maxSeq=20),不再读本地/回填
|
||||
const localReadBeforeReentry = localReadCount;
|
||||
await sync.fetchMessages('c1');
|
||||
// 重入后增量拿到 seq=21
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
expect(msgs).toHaveLength(21);
|
||||
expect(msgs.some((m) => m.seq === 21)).toBe(true);
|
||||
// 重入不应再读本地、不应触发快照或回填
|
||||
expect(localReadCount).toBe(localReadBeforeReentry);
|
||||
expect(snapshotCallCount).toBe(0);
|
||||
expect(beforeSeqCallCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('并发安全', () => {
|
||||
it('hydration 期间 WS ingest 新消息,最终消息完整不丢失', async () => {
|
||||
// 本地有 seq=1~20,内存初始空
|
||||
repoMock.__setMessagesByConversation('c1', makeRange(1, 20));
|
||||
|
||||
serviceMock.__setMessagesHandler(makeServerHandler());
|
||||
|
||||
const sync = createSyncService();
|
||||
const fetchPromise = sync.fetchMessages('c1');
|
||||
|
||||
// 在 hydration 进行中模拟 WS ingest 一条新消息
|
||||
useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('ws-21', 21));
|
||||
|
||||
await fetchPromise;
|
||||
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
const seqs = msgs.map((m) => m.seq);
|
||||
// 本地 1~20 + WS 的 21,共 21 条,无丢失
|
||||
expect(msgs).toHaveLength(21);
|
||||
expect(seqs).toContain(21);
|
||||
expect(new Set(msgs.map((m) => m.id)).size).toBe(msgs.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('清空聊天记录后重新 hydration', () => {
|
||||
it('clearMessages 清空内存消息 + hydrated 标志,下次进入重新走完整管线', async () => {
|
||||
// 场景:会话已 hydrated 且内存有消息;用户清空记录后重新进入。
|
||||
// 修复前:hydrated 残留 → 走轻量增量(after_seq=旧maxSeq)→ 清空后仍显示旧消息或拉不到新历史。
|
||||
// 修复后:clearMessages 清内存+hydrated → 下次 fetchMessages 重新走完整 hydration。
|
||||
messageRepository.__setMessagesByConversation('c1', makeRange(1, 20));
|
||||
messageService.__setMessagesHandler(makeServerHandler());
|
||||
|
||||
const sync = createSyncService();
|
||||
|
||||
// 首次 hydration
|
||||
await sync.fetchMessages('c1');
|
||||
expect(useMessageStore.getState().isHydrated('c1')).toBe(true);
|
||||
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(20);
|
||||
|
||||
// 用户清空记录(DB + 内存 + hydrated)
|
||||
await messageRepository.clearConversation('c1');
|
||||
useMessageStore.getState().clearMessages('c1');
|
||||
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(0);
|
||||
expect(useMessageStore.getState().isHydrated('c1')).toBe(false);
|
||||
|
||||
// 重新进入:本地 DB 已空(clearConversation 清了 mock 状态),走冷启动快照
|
||||
// 配置服务端快照返回 seq=1~10(模拟清空后服务端重新返回的消息)
|
||||
messageService.__setMessagesHandler(
|
||||
makeServerHandler({ snapshot: makeRange(1, 10) })
|
||||
);
|
||||
|
||||
await sync.fetchMessages('c1');
|
||||
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
// 应重新加载到快照消息,而非停留在清空状态
|
||||
expect(msgs).toHaveLength(10);
|
||||
expect(useMessageStore.getState().isHydrated('c1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界:临时消息(seq<=0)不干扰连续性判断', () => {
|
||||
it('内存混入 seq=0 的发送中消息时,回填仍按真实历史序号进行', async () => {
|
||||
// 场景:本地有 seq=5~10,内存还混入一条 seq=0 的「发送中」临时消息。
|
||||
// 修复后:computeContiguousMinSeq 过滤 seq<=0,按 5 判定下缺口,回填 1~4。
|
||||
// 修复前(理论上):seq=0 恰好等于 expected-1 当 expected=1,不会误判;但过滤后语义更稳。
|
||||
messageRepository.__setMessagesByConversation('c1', makeRange(5, 10));
|
||||
// 预置一条 seq=0 的临时消息到内存
|
||||
useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('temp-0', 0));
|
||||
|
||||
messageService.__setMessagesHandler(makeServerHandler());
|
||||
|
||||
const sync = createSyncService();
|
||||
await sync.fetchMessages('c1');
|
||||
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
const realSeqs = msgs.filter((m) => m.seq > 0).map((m) => m.seq);
|
||||
// 真实历史应回填到 seq=1
|
||||
expect(Math.min(...realSeqs)).toBe(1);
|
||||
expect(Math.max(...realSeqs)).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
150
src/stores/message/__tests__/messageOutbox.test.ts
Normal file
150
src/stores/message/__tests__/messageOutbox.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 发送 Outbox 单测(批次二②)
|
||||
*
|
||||
* 锁定 Outbox 持久化的核心不变量:
|
||||
* 1. 发送时临时消息(pending)落库(status='pending'),重启后可恢复
|
||||
* 2. 发送成功后正式消息落库(status='normal'),且 tempId 的 DB 记录被删除
|
||||
* 3. 发送失败后落库(status='failed'),重启后可见
|
||||
* 4. getPendingOrFailed 正确返回 pending/failed 消息
|
||||
*
|
||||
* 对标 Matrix Send Queue:发送是离线可靠操作,pending/failed 必须持久化。
|
||||
*/
|
||||
|
||||
import { useMessageStore } from '../store';
|
||||
import { MessageSendService } from '../services/MessageSendService';
|
||||
import { messageService } from '@/services/message';
|
||||
import { messageRepository } from '@/database';
|
||||
import type { MessageSegment } from '../../../types/dto';
|
||||
|
||||
const TEXT_SEGMENTS: MessageSegment[] = [{ type: 'text', data: { text: 'hello' } }];
|
||||
|
||||
function createSendService(): MessageSendService {
|
||||
return new MessageSendService(() => 'me');
|
||||
}
|
||||
|
||||
describe('发送 Outbox 持久化', () => {
|
||||
beforeEach(() => {
|
||||
useMessageStore.getState().reset();
|
||||
(messageRepository as any).__reset();
|
||||
(messageService as any).__reset();
|
||||
});
|
||||
|
||||
it('发送成功:临时消息落库(pending) → 正式消息落库(normal) → tempId 删除', async () => {
|
||||
const repoMock = messageRepository as any;
|
||||
const serviceMock = messageService as any;
|
||||
// 服务端发送成功,返回 id/seq
|
||||
serviceMock.__setSendMessageHandler(() => ({ id: 'srv-1', seq: 42 }));
|
||||
|
||||
const send = createSendService();
|
||||
await send.sendMessage('c1', TEXT_SEGMENTS, { detailType: 'private' });
|
||||
|
||||
const saved = repoMock.__getSavedMessages();
|
||||
// 至少落库两条:pending 临时消息 + normal 正式消息
|
||||
const statuses = saved.map((m: any) => m.status);
|
||||
expect(statuses).toContain('pending');
|
||||
expect(statuses).toContain('normal');
|
||||
|
||||
// tempId 的 DB 记录应被删除
|
||||
const deleted = repoMock.__getDeletedIds();
|
||||
expect(deleted.length).toBeGreaterThan(0);
|
||||
// 被删除的 id 应是 temp_ 前缀
|
||||
expect(deleted.some((id: string) => id.startsWith('temp_'))).toBe(true);
|
||||
|
||||
// store 里最终只有正式消息
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0].id).toBe('srv-1');
|
||||
expect(msgs[0].status).toBe('normal');
|
||||
expect(msgs[0].seq).toBe(42);
|
||||
});
|
||||
|
||||
it('发送失败:临时消息落库(failed),重启后可见', async () => {
|
||||
const repoMock = messageRepository as any;
|
||||
const serviceMock = messageService as any;
|
||||
// 服务端发送失败(返回 null)
|
||||
serviceMock.__setSendMessageHandler(() => null);
|
||||
|
||||
const send = createSendService();
|
||||
await send.sendMessage('c1', TEXT_SEGMENTS, { detailType: 'private' });
|
||||
|
||||
const saved = repoMock.__getSavedMessages();
|
||||
// pending 落库 + failed 覆盖落库
|
||||
const statuses = saved.map((m: any) => m.status);
|
||||
expect(statuses).toContain('pending');
|
||||
expect(statuses).toContain('failed');
|
||||
|
||||
// store 里是 failed 消息
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0].status).toBe('failed');
|
||||
|
||||
// getPendingOrFailed 应返回这条 failed 消息(模拟重启恢复)
|
||||
const pending = await messageRepository.getPendingOrFailed();
|
||||
expect(pending.length).toBeGreaterThanOrEqual(1);
|
||||
expect(pending.some((m: any) => m.status === 'failed')).toBe(true);
|
||||
});
|
||||
|
||||
it('发送异常(throw):临时消息落库(failed)', async () => {
|
||||
const repoMock = messageRepository as any;
|
||||
const serviceMock = messageService as any;
|
||||
// 服务端抛异常
|
||||
serviceMock.__setSendMessageHandler(() => {
|
||||
throw new Error('网络断开');
|
||||
});
|
||||
|
||||
const send = createSendService();
|
||||
await send.sendMessage('c1', TEXT_SEGMENTS, { detailType: 'private' });
|
||||
|
||||
const saved = repoMock.__getSavedMessages();
|
||||
const statuses = saved.map((m: any) => m.status);
|
||||
expect(statuses).toContain('failed');
|
||||
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
expect(msgs[0].status).toBe('failed');
|
||||
});
|
||||
|
||||
it('重试失败消息:旧 failed 记录被删除,新消息重新落库', async () => {
|
||||
const repoMock = messageRepository as any;
|
||||
const serviceMock = messageService as any;
|
||||
|
||||
// 第一次发送失败
|
||||
serviceMock.__setSendMessageHandler(() => null);
|
||||
const send = createSendService();
|
||||
await send.sendMessage('c1', TEXT_SEGMENTS, { detailType: 'private' });
|
||||
|
||||
const failedMsg = useMessageStore.getState().getMessages('c1')[0];
|
||||
expect(failedMsg.status).toBe('failed');
|
||||
|
||||
// 重试:这次成功
|
||||
serviceMock.__setSendMessageHandler(() => ({ id: 'srv-2', seq: 50 }));
|
||||
await send.retrySendMessage('c1', failedMsg);
|
||||
|
||||
// 旧 failedId 的 DB 记录应被删除
|
||||
const deleted = repoMock.__getDeletedIds();
|
||||
expect(deleted).toContain(failedMsg.id);
|
||||
|
||||
// store 里是新的正式消息
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
expect(msgs.some((m) => m.id === 'srv-2')).toBe(true);
|
||||
});
|
||||
|
||||
it('getPendingOrFailed 只返回 pending/failed,不含 normal', async () => {
|
||||
const repoMock = messageRepository as any;
|
||||
// 注入混合状态的消息到 mock DB
|
||||
repoMock.__setMessagesByConversation('c1', [
|
||||
{ id: 'm1', conversationId: 'c1', senderId: 'u1', seq: 1, status: 'normal', createdAt: '2024-01-01', segments: [] },
|
||||
{ id: 'm2', conversationId: 'c1', senderId: 'u1', seq: 0, status: 'pending', createdAt: '2024-01-02', segments: [] },
|
||||
{ id: 'm3', conversationId: 'c1', senderId: 'u1', seq: 0, status: 'failed', createdAt: '2024-01-03', segments: [] },
|
||||
{ id: 'm4', conversationId: 'c1', senderId: 'u1', seq: 2, status: 'recalled', createdAt: '2024-01-04', segments: [] },
|
||||
]);
|
||||
|
||||
const result = await messageRepository.getPendingOrFailed();
|
||||
const ids = result.map((m: any) => m.id);
|
||||
expect(ids).toContain('m2');
|
||||
expect(ids).toContain('m3');
|
||||
expect(ids).not.toContain('m1');
|
||||
expect(ids).not.toContain('m4');
|
||||
// 按 createdAt 升序
|
||||
expect(result[0].id).toBe('m2');
|
||||
});
|
||||
});
|
||||
18
src/stores/message/__tests__/mocks/asyncStorageMock.ts
Normal file
18
src/stores/message/__tests__/mocks/asyncStorageMock.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 测试用 AsyncStorage mock:内存 Map 实现。
|
||||
*/
|
||||
|
||||
const store = new Map<string, string>();
|
||||
|
||||
export default {
|
||||
getItem: async (key: string): Promise<string | null> => store.get(key) ?? null,
|
||||
setItem: async (key: string, value: string): Promise<void> => {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: async (key: string): Promise<void> => {
|
||||
store.delete(key);
|
||||
},
|
||||
__reset(): void {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
15
src/stores/message/__tests__/mocks/authStoreMock.ts
Normal file
15
src/stores/message/__tests__/mocks/authStoreMock.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 测试用 authStore mock:提供 getState/subscribe 与 currentUser。
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface AuthState {
|
||||
currentUser: { id: string } | null;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>(() => ({
|
||||
currentUser: { id: 'me' },
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
162
src/stores/message/__tests__/mocks/coreMock.ts
Normal file
162
src/stores/message/__tests__/mocks/coreMock.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 测试用 core mock:提供 wsService、GroupNoticeType、ApiError 等最小可用实现。
|
||||
* 仅覆盖 RealtimeIngestionPipeline 依赖的表面。
|
||||
*/
|
||||
|
||||
export const GroupNoticeType = undefined as any;
|
||||
|
||||
export class ApiError extends Error {
|
||||
code?: number;
|
||||
constructor(message: string, code?: number) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
type EventHandler = (msg: any) => void;
|
||||
type ConnHandler = () => void;
|
||||
|
||||
/**
|
||||
* 可控的 wsService mock:on/onConnect/onDisconnect 注册处理器,
|
||||
* 测试通过 emit('chat', msg) / emitConnect() 等方法模拟 WS 事件。
|
||||
*/
|
||||
class WsServiceMock {
|
||||
private handlers: Map<string, EventHandler[]> = new Map();
|
||||
private connectHandlers: ConnHandler[] = [];
|
||||
private disconnectHandlers: ConnHandler[] = [];
|
||||
private syncRequiredHandlers: EventHandler[] = [];
|
||||
|
||||
ackCalls: string[] = [];
|
||||
|
||||
on(type: string, handler: EventHandler): () => void {
|
||||
const list = this.handlers.get(type) || [];
|
||||
list.push(handler);
|
||||
this.handlers.set(type, list);
|
||||
return () => {
|
||||
const cur = this.handlers.get(type) || [];
|
||||
const idx = cur.indexOf(handler);
|
||||
if (idx >= 0) cur.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
onConnect(handler: ConnHandler): () => void {
|
||||
this.connectHandlers.push(handler);
|
||||
return () => {
|
||||
const i = this.connectHandlers.indexOf(handler);
|
||||
if (i >= 0) this.connectHandlers.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
onDisconnect(handler: ConnHandler): () => void {
|
||||
this.disconnectHandlers.push(handler);
|
||||
return () => {
|
||||
const i = this.disconnectHandlers.indexOf(handler);
|
||||
if (i >= 0) this.disconnectHandlers.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
sendAck(messageId: string): void {
|
||||
this.ackCalls.push(messageId);
|
||||
}
|
||||
|
||||
// ===== 测试驱动方法 =====
|
||||
emit(type: string, msg: any): void {
|
||||
const list = this.handlers.get(type) || [];
|
||||
list.forEach(h => h(msg));
|
||||
}
|
||||
|
||||
emitConnect(): void {
|
||||
this.connectHandlers.forEach(h => h());
|
||||
}
|
||||
|
||||
emitDisconnect(): void {
|
||||
this.disconnectHandlers.forEach(h => h());
|
||||
}
|
||||
|
||||
/** 当前已注册某类型监听器的数量(用于断言无泄漏) */
|
||||
listenerCount(type: string): number {
|
||||
return (this.handlers.get(type) || []).length;
|
||||
}
|
||||
|
||||
connectHandlerCount(): number {
|
||||
return this.connectHandlers.length;
|
||||
}
|
||||
|
||||
disconnectHandlerCount(): number {
|
||||
return this.disconnectHandlers.length;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.handlers.clear();
|
||||
this.connectHandlers = [];
|
||||
this.disconnectHandlers = [];
|
||||
this.ackCalls = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const wsService = new WsServiceMock();
|
||||
|
||||
/**
|
||||
* errorHandler 相关导出:测试环境用简化实现(不依赖 RN UI)。
|
||||
*
|
||||
* 真实 errorHandler 的副作用是 console.error(默认开启)+ 可选 showPrompt(依赖 RN)。
|
||||
* 我们的错误接入都用默认选项(showErrorPrompt=false),不触发 showPrompt,
|
||||
* 所以测试环境用简化实现即可,安全无副作用。
|
||||
*
|
||||
* 注意:测试环境不透传真实 errorHandler,因为 jest moduleNameMapper 只把
|
||||
* `^@/services/core$` 映射到本文件,`@/services/core/errorHandler` 不匹配,
|
||||
* 会落到真实实现,而真实实现 import 了 api.ts 等可能有副作用的模块。
|
||||
*/
|
||||
export function isNetworkError(error: unknown): boolean {
|
||||
if (error instanceof ApiError) {
|
||||
return (error as any).errorCode === 'NETWORK_ERROR';
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message.toLowerCase();
|
||||
return msg.includes('network request failed') || msg.includes('failed to fetch');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// errorHandler 的核心函数(简化版:分类 + console.error,不调 showPrompt)
|
||||
export const handleError = (error: unknown, options?: any) => {
|
||||
const context = options?.context;
|
||||
const prefix = context ? `[${context}]` : '[error]';
|
||||
if (options?.logToConsole !== false) {
|
||||
console.error(`${prefix} ${error instanceof Error ? error.message : String(error)}`, error);
|
||||
}
|
||||
return { code: 'UNKNOWN', message: String(error), originalError: error, context, silent: options?.silent };
|
||||
};
|
||||
|
||||
export const createErrorHandler = (defaultContext: string) => {
|
||||
return (error: unknown, options: any = {}) =>
|
||||
handleError(error, { ...options, context: defaultContext });
|
||||
};
|
||||
|
||||
export const safeAsync = async <T>(fn: () => Promise<T>, options?: any): Promise<{ data: T | null; error: any | null }> => {
|
||||
try {
|
||||
const data = await fn();
|
||||
return { data, error: null };
|
||||
} catch (error) {
|
||||
const appError = handleError(error, options);
|
||||
return { data: null, error: appError };
|
||||
}
|
||||
};
|
||||
|
||||
export const AppErrorCode = {
|
||||
NETWORK_ERROR: 'NETWORK_ERROR',
|
||||
AUTH_ERROR: 'AUTH_ERROR',
|
||||
FORBIDDEN: 'FORBIDDEN',
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
||||
SERVER_ERROR: 'SERVER_ERROR',
|
||||
TIMEOUT: 'TIMEOUT',
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 供 WSServerMessage 等 wsService.ts 导出的类型在 mock 中可见。
|
||||
* Pipeline 只用到 wsService 实例和 GroupNoticeType;类型 import 是 type-only,
|
||||
* 在运行期被擦除,因此这里不需要真正导出接口类型。
|
||||
*/
|
||||
export {};
|
||||
190
src/stores/message/__tests__/mocks/databaseMock.ts
Normal file
190
src/stores/message/__tests__/mocks/databaseMock.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 测试用 database mock:messageRepository / conversationRepository / userCacheRepository。
|
||||
*
|
||||
* 默认行为:所有读取返回空、写入为 no-op,与历史行为一致(不破坏现有单测)。
|
||||
* 增强能力:单测可通过 `messageRepository.__setState()` / `messageRepository.__setHandler()`
|
||||
* 注入按 (conversationId, 参数) 维度的响应,用于 hydration 回填管线等需要
|
||||
* 真实数据流的场景。每个 test 的 beforeEach 应调用 `__reset()` 复原默认行为。
|
||||
*/
|
||||
|
||||
// ---- 可配置的内部状态 ----
|
||||
|
||||
interface RepositoryMockState {
|
||||
/** 按 `${conversationId}` 存储该会话的全部本地消息(模拟 SQLite 表内容)。 */
|
||||
messagesByConversation: Map<string, any[]>;
|
||||
/** saveMessage 实际写入的调用记录(验证 Outbox 落库用)。 */
|
||||
savedMessages: any[];
|
||||
/** delete 调用记录的 messageId(验证 Outbox tempId 清理用)。 */
|
||||
deletedIds: string[];
|
||||
/** updateStatus 调用记录(验证 Outbox 状态流转用)。 */
|
||||
statusUpdates: { messageId: string; status: string }[];
|
||||
}
|
||||
|
||||
const state: RepositoryMockState = {
|
||||
messagesByConversation: new Map(),
|
||||
savedMessages: [],
|
||||
deletedIds: [],
|
||||
statusUpdates: [],
|
||||
};
|
||||
|
||||
function resetState() {
|
||||
state.messagesByConversation.clear();
|
||||
state.savedMessages = [];
|
||||
state.deletedIds = [];
|
||||
state.statusUpdates = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 让 `getByConversation` / `getBeforeSeq` / `getContiguousRange` 等读取方法
|
||||
* 基于注入的消息集合工作(模拟本地 DB 已落库的数据)。
|
||||
* `messages` 应按 seq 升序提供。
|
||||
*/
|
||||
function __setMessagesByConversation(conversationId: string, messages: any[]) {
|
||||
state.messagesByConversation.set(conversationId, [...messages].sort((a, b) => a.seq - b.seq));
|
||||
}
|
||||
|
||||
export const messageRepository = {
|
||||
// ---- 写入:记录调用(验证 Outbox 落库),同时维护内部状态 ----
|
||||
async saveMessage(message: any) {
|
||||
state.savedMessages.push(message);
|
||||
// 同步更新 messagesByConversation,让 getPendingOrFailed 等读取能反映写入
|
||||
const convId = message.conversationId;
|
||||
const arr = state.messagesByConversation.get(convId) || [];
|
||||
const idx = arr.findIndex((m) => m.id === message.id);
|
||||
if (idx >= 0) arr[idx] = message;
|
||||
else arr.push(message);
|
||||
state.messagesByConversation.set(convId, arr);
|
||||
},
|
||||
|
||||
async saveMessagesBatch(messages: any[]) {
|
||||
for (const m of messages) {
|
||||
await this.saveMessage(m);
|
||||
}
|
||||
},
|
||||
|
||||
// ---- 读取:基于内部状态 ----
|
||||
async getByConversation(conversationId: string, limit = 20) {
|
||||
const all = state.messagesByConversation.get(conversationId) || [];
|
||||
// 模拟 SQL:ORDER BY seq DESC LIMIT,再 reverse() 为升序
|
||||
return [...all].sort((a, b) => b.seq - a.seq).slice(0, limit).reverse();
|
||||
},
|
||||
|
||||
async getBeforeSeq(conversationId: string, beforeSeq: number, limit = 20) {
|
||||
const all = state.messagesByConversation.get(conversationId) || [];
|
||||
return [...all]
|
||||
.filter((m) => m.seq < beforeSeq)
|
||||
.sort((a, b) => b.seq - a.seq)
|
||||
.slice(0, limit)
|
||||
.reverse();
|
||||
},
|
||||
|
||||
async getMaxSeq(conversationId: string) {
|
||||
const all = state.messagesByConversation.get(conversationId) || [];
|
||||
return all.reduce((max, m) => Math.max(max, m.seq || 0), 0);
|
||||
},
|
||||
|
||||
async getMinSeq(conversationId: string) {
|
||||
const all = state.messagesByConversation.get(conversationId) || [];
|
||||
if (all.length === 0) return 0;
|
||||
return all.reduce((min, m) => Math.min(min, m.seq || Infinity), Infinity);
|
||||
},
|
||||
|
||||
async getContiguousRange(conversationId: string) {
|
||||
const all = (state.messagesByConversation.get(conversationId) || [])
|
||||
.filter((m) => m.seq > 0)
|
||||
.map((m) => m.seq)
|
||||
.sort((a, b) => b - a); // DESC
|
||||
if (all.length === 0) return { maxSeq: 0, minSeq: 0 };
|
||||
let maxSeq = all[0];
|
||||
let minSeq = maxSeq;
|
||||
for (let i = 1; i < all.length; i++) {
|
||||
if (all[i] === minSeq - 1) {
|
||||
minSeq = all[i];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { maxSeq, minSeq };
|
||||
},
|
||||
|
||||
async getCount(conversationId: string) {
|
||||
return (state.messagesByConversation.get(conversationId) || []).length;
|
||||
},
|
||||
|
||||
// ---- 其它:状态维护版本(验证 Outbox 状态流转)----
|
||||
async updateStatus(messageId: string, status: string, clearContent = false) {
|
||||
state.statusUpdates.push({ messageId, status });
|
||||
// 同步更新内部状态
|
||||
for (const [, arr] of state.messagesByConversation) {
|
||||
const m = arr.find((x) => x.id === messageId);
|
||||
if (m) {
|
||||
m.status = status;
|
||||
if (clearContent) {
|
||||
m.content = '';
|
||||
m.segments = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
markAsRead: async () => {},
|
||||
markConversationAsRead: async () => {},
|
||||
async delete(messageId: string) {
|
||||
state.deletedIds.push(messageId);
|
||||
for (const [, arr] of state.messagesByConversation) {
|
||||
const idx = arr.findIndex((x) => x.id === messageId);
|
||||
if (idx >= 0) arr.splice(idx, 1);
|
||||
}
|
||||
},
|
||||
clearConversation: async (conversationId: string) => {
|
||||
state.messagesByConversation.delete(conversationId);
|
||||
},
|
||||
async getPendingOrFailed() {
|
||||
// 扫描所有会话,收集 status 为 pending/failed 的消息,按 createdAt 升序
|
||||
const out: any[] = [];
|
||||
for (const [, arr] of state.messagesByConversation) {
|
||||
for (const m of arr) {
|
||||
if (m.status === 'pending' || m.status === 'failed') {
|
||||
out.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) =>
|
||||
String(a.createdAt).localeCompare(String(b.createdAt))
|
||||
);
|
||||
},
|
||||
search: async () => [],
|
||||
searchByConversation: async () => [],
|
||||
getStats: async () => ({ totalMessages: 0, totalConversations: 0 }),
|
||||
getLastMessageByConversation: async () => null,
|
||||
getCountBeforeSeq: async () => 0,
|
||||
|
||||
// ---- 测试辅助 ----
|
||||
__reset: resetState,
|
||||
__setState: resetState, // 别名,语义对齐 asyncStorageMock
|
||||
__setMessagesByConversation,
|
||||
__getMessagesByConversation: (conversationId: string) =>
|
||||
state.messagesByConversation.get(conversationId) || [],
|
||||
/** 已 saveMessage 落库的消息记录(断言 Outbox 持久化) */
|
||||
__getSavedMessages: () => state.savedMessages,
|
||||
/** 已 delete 调用的 messageId 列表(断言 Outbox tempId 清理) */
|
||||
__getDeletedIds: () => state.deletedIds,
|
||||
/** 已 updateStatus 调用的记录(断言 Outbox 状态流转) */
|
||||
__getStatusUpdates: () => state.statusUpdates,
|
||||
};
|
||||
|
||||
export const conversationRepository = {
|
||||
getListCache: async () => [],
|
||||
saveWithRelated: async () => {},
|
||||
delete: async () => {},
|
||||
saveCache: async () => {},
|
||||
getCache: async () => null,
|
||||
updateCacheUnreadCount: async () => {},
|
||||
clearAll: async () => {},
|
||||
};
|
||||
|
||||
export const userCacheRepository = {
|
||||
get: () => null,
|
||||
set: () => {},
|
||||
save: async () => {},
|
||||
saveBatch: async () => {},
|
||||
};
|
||||
23
src/stores/message/__tests__/mocks/eventBusMock.ts
Normal file
23
src/stores/message/__tests__/mocks/eventBusMock.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 测试用 eventBus mock:记录 emit 的事件供断言。
|
||||
*/
|
||||
|
||||
type AppEvent = { type: string; payload?: any };
|
||||
|
||||
class EventBusMock {
|
||||
emitted: AppEvent[] = [];
|
||||
|
||||
emit(event: AppEvent): void {
|
||||
this.emitted.push(event);
|
||||
}
|
||||
|
||||
subscribe(_fn: (event: AppEvent) => void): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.emitted = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBus = new EventBusMock();
|
||||
110
src/stores/message/__tests__/mocks/messageServiceMock.ts
Normal file
110
src/stores/message/__tests__/mocks/messageServiceMock.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 测试用 messageService mock:仅覆盖单测路径调用的方法。
|
||||
*
|
||||
* 默认行为:所有方法返回空/no-op,与历史行为一致(不破坏现有单测)。
|
||||
* 增强能力:`getMessages` 可通过 `__setMessagesHandler` 配置响应分发,
|
||||
* 用于 hydration 回填管线等需要按 (afterSeq/beforeSeq/limit) 返回不同数据的场景。
|
||||
* 每个 test 的 beforeEach 应调用 `__reset()` 复原默认行为。
|
||||
*/
|
||||
|
||||
export type MessageListResponse = { messages: any[]; total: number; page: number; page_size: number };
|
||||
|
||||
type GetMessagesHandler = (args: {
|
||||
conversationId: string;
|
||||
afterSeq?: number;
|
||||
beforeSeq?: number;
|
||||
limit?: number;
|
||||
}) => MessageListResponse;
|
||||
|
||||
/** 发送消息的模拟响应 */
|
||||
type SendMessageResponse = { id: string; seq: number };
|
||||
type SendMessageHandler = (args: {
|
||||
conversationId: string;
|
||||
segments: any[];
|
||||
replyToId?: string;
|
||||
}) => SendMessageResponse | null;
|
||||
|
||||
/** syncByVersion 的模拟响应 */
|
||||
type SyncByVersionHandler = (version: number) => {
|
||||
current_version: number;
|
||||
changes: any[];
|
||||
full_sync: boolean;
|
||||
has_more: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_RESPONSE: MessageListResponse = { messages: [], total: 0, page: 1, page_size: 20 };
|
||||
|
||||
let getMessagesHandler: GetMessagesHandler | null = null;
|
||||
let sendMessageHandler: SendMessageHandler | null = null;
|
||||
let syncByVersionHandler: SyncByVersionHandler | null = null;
|
||||
|
||||
function resetHandler() {
|
||||
getMessagesHandler = null;
|
||||
sendMessageHandler = null;
|
||||
syncByVersionHandler = null;
|
||||
}
|
||||
|
||||
export const messageService = {
|
||||
async getMessages(
|
||||
conversationId: string,
|
||||
afterSeq?: number,
|
||||
beforeSeq?: number,
|
||||
limit?: number
|
||||
): Promise<MessageListResponse> {
|
||||
if (getMessagesHandler) {
|
||||
return getMessagesHandler({ conversationId, afterSeq, beforeSeq, limit });
|
||||
}
|
||||
return { ...EMPTY_RESPONSE };
|
||||
},
|
||||
|
||||
async sendMessage(
|
||||
conversationId: string,
|
||||
payload: { segments: any[]; reply_to_id?: string }
|
||||
): Promise<SendMessageResponse | null> {
|
||||
if (sendMessageHandler) {
|
||||
return sendMessageHandler({
|
||||
conversationId,
|
||||
segments: payload.segments,
|
||||
replyToId: payload.reply_to_id,
|
||||
});
|
||||
}
|
||||
// 默认:返回一个递增的 id/seq,模拟发送成功
|
||||
return { id: `srv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, seq: 0 };
|
||||
},
|
||||
|
||||
async getSyncByVersion(version: number): Promise<{
|
||||
current_version: number;
|
||||
changes: any[];
|
||||
full_sync: boolean;
|
||||
has_more: boolean;
|
||||
}> {
|
||||
if (syncByVersionHandler) {
|
||||
return syncByVersionHandler(version);
|
||||
}
|
||||
return { current_version: version, changes: [], full_sync: false, has_more: false };
|
||||
},
|
||||
|
||||
getConversationById: async () => null,
|
||||
getUnreadCount: async () => ({ total_unread_count: 0 }),
|
||||
getSystemUnreadCount: async () => ({ unread_count: 0 }),
|
||||
getSyncData: async () => [],
|
||||
markAsRead: async () => {},
|
||||
markAllAsRead: async () => {},
|
||||
markAllSystemMessagesRead: async () => {},
|
||||
fetchRemoteConversationListPage: async () => ({ items: [], hasMore: false }),
|
||||
getSystemMessagesCursor: async () => ({ list: [] }),
|
||||
|
||||
// ---- 测试辅助 ----
|
||||
__reset: resetHandler,
|
||||
__setMessagesHandler: (handler: GetMessagesHandler) => {
|
||||
getMessagesHandler = handler;
|
||||
},
|
||||
__setSendMessageHandler: (handler: SendMessageHandler) => {
|
||||
sendMessageHandler = handler;
|
||||
},
|
||||
__setSyncByVersionHandler: (handler: SyncByVersionHandler) => {
|
||||
syncByVersionHandler = handler;
|
||||
},
|
||||
};
|
||||
|
||||
export type { GetMessagesHandler, SendMessageHandler, SyncByVersionHandler };
|
||||
112
src/stores/message/__tests__/store.idempotency.test.ts
Normal file
112
src/stores/message/__tests__/store.idempotency.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Store 幂等性单测
|
||||
*
|
||||
* 锁定的不变量:
|
||||
* 1. addOrReplaceMessage 以 message id 为幂等键,重复写入返回 false 且不产生重复消息。
|
||||
* 2. 消息按 seq 升序排列。
|
||||
* 3. 相邻消息不会因重复 ingest 而丢失(修复「消息被吞」的核心保证)。
|
||||
* 4. 并发 merge(模拟多次同步写入)不会丢消息。
|
||||
*/
|
||||
|
||||
import { useMessageStore } from '../store';
|
||||
import type { MessageResponse } from '../../../types/dto';
|
||||
|
||||
function makeMessage(id: string, seq: number, extra: Partial<MessageResponse> = {}): MessageResponse {
|
||||
return {
|
||||
id,
|
||||
conversation_id: 'c1',
|
||||
sender_id: 'u1',
|
||||
seq,
|
||||
segments: [],
|
||||
created_at: `2024-01-01T00:00:${String(seq).padStart(2, '0')}Z`,
|
||||
status: 'normal',
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe('store 幂等性', () => {
|
||||
beforeEach(() => {
|
||||
useMessageStore.getState().reset();
|
||||
});
|
||||
|
||||
describe('addOrReplaceMessage', () => {
|
||||
it('首次写入返回 true,消息进入 store', () => {
|
||||
const inserted = useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1));
|
||||
expect(inserted).toBe(true);
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0].id).toBe('m1');
|
||||
});
|
||||
|
||||
it('同 id 重复写入返回 false 且不产生重复', () => {
|
||||
useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1));
|
||||
const second = useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1));
|
||||
expect(second).toBe(false);
|
||||
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('id 相同但 seq 不同(重排)仍视为幂等,不重复插入', () => {
|
||||
useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1));
|
||||
const second = useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 999));
|
||||
// 幂等键是 id,而非 seq
|
||||
expect(second).toBe(false);
|
||||
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('相邻消息不会因中间消息重复 ingest 而丢失', () => {
|
||||
// 模拟 WS 与 REST 增量同步交织:m1, m2 都先入,然后 m1 重复,最后 m3 入
|
||||
useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1));
|
||||
useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m2', 2));
|
||||
// m1 重复(如发送回显 + WS echo)
|
||||
useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1));
|
||||
useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m3', 3));
|
||||
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
expect(msgs.map(m => m.id)).toEqual(['m1', 'm2', 'm3']);
|
||||
expect(msgs.map(m => m.seq)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('不同会话互不影响', () => {
|
||||
useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1));
|
||||
const inserted = useMessageStore.getState().addOrReplaceMessage('c2', makeMessage('m1', 1));
|
||||
expect(inserted).toBe(true);
|
||||
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1);
|
||||
expect(useMessageStore.getState().getMessages('c2')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeMessages', () => {
|
||||
it('批量合并同样按 id 去重', () => {
|
||||
useMessageStore.getState().mergeMessages('c1', [makeMessage('m1', 1), makeMessage('m2', 2)]);
|
||||
// 重复 m1 + 新 m3
|
||||
useMessageStore.getState().mergeMessages('c1', [makeMessage('m1', 1), makeMessage('m3', 3)]);
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
expect(msgs.map(m => m.id)).toEqual(['m1', 'm2', 'm3']);
|
||||
});
|
||||
|
||||
it('空 incoming 不改变现有消息', () => {
|
||||
useMessageStore.getState().mergeMessages('c1', [makeMessage('m1', 1)]);
|
||||
useMessageStore.getState().mergeMessages('c1', []);
|
||||
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('乱序 seq 入库后仍按 seq 升序排列', () => {
|
||||
useMessageStore.getState().mergeMessages('c1', [
|
||||
makeMessage('m3', 3),
|
||||
makeMessage('m1', 1),
|
||||
makeMessage('m2', 2),
|
||||
]);
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
expect(msgs.map(m => m.seq)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('并发连续 merge 不丢消息(模拟 WS + REST 增量同步竞态)', () => {
|
||||
// 两条独立 merge,store 在 set 回调内原子合并
|
||||
useMessageStore.getState().mergeMessages('c1', [makeMessage('m1', 1), makeMessage('m2', 2)]);
|
||||
useMessageStore.getState().mergeMessages('c1', [makeMessage('m2', 2), makeMessage('m3', 3)]);
|
||||
useMessageStore.getState().mergeMessages('c1', [makeMessage('m1', 1), makeMessage('m3', 3), makeMessage('m4', 4)]);
|
||||
const msgs = useMessageStore.getState().getMessages('c1');
|
||||
expect(msgs.map(m => m.id)).toEqual(['m1', 'm2', 'm3', 'm4']);
|
||||
});
|
||||
});
|
||||
});
|
||||
117
src/stores/message/__tests__/syncByVersion.test.ts
Normal file
117
src/stores/message/__tests__/syncByVersion.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* syncByVersion 游标同步单测(批次二③)
|
||||
*
|
||||
* 锁定基于版本号的精确增量恢复不变量:
|
||||
* 1. 成功同步并推进持久化游标
|
||||
* 2. full_sync=true 时退化(返回 false)
|
||||
* 3. 异常时退化(返回 false)
|
||||
* 4. 无 version(<=0)时直接退化
|
||||
*
|
||||
* 对标 Matrix resume 游标:断线恢复从全量轮询升级为精确增量。
|
||||
*/
|
||||
|
||||
import { useMessageStore } from '../store';
|
||||
import { MessageSyncService } from '../services/MessageSyncService';
|
||||
import { messageService } from '@/services/message';
|
||||
import { messageRepository } from '@/database';
|
||||
|
||||
function createSyncService(): MessageSyncService {
|
||||
return new MessageSyncService(
|
||||
() => 'me',
|
||||
{ markAsRead: async () => {}, markAllAsRead: async () => {} } as any,
|
||||
{ getSenderInfo: async () => null, enrichMessagesWithSenderInfo: () => {} } as any,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
describe('syncByVersion 游标同步', () => {
|
||||
beforeEach(() => {
|
||||
useMessageStore.getState().reset();
|
||||
(messageRepository as any).__reset();
|
||||
(messageService as any).__reset();
|
||||
});
|
||||
|
||||
it('无变更时返回成功且推进游标', async () => {
|
||||
const serviceMock = messageService as any;
|
||||
serviceMock.__setSyncByVersionHandler((version: number) => ({
|
||||
current_version: version + 10,
|
||||
changes: [],
|
||||
full_sync: false,
|
||||
has_more: false,
|
||||
}));
|
||||
|
||||
const sync = createSyncService();
|
||||
const ok = await sync.syncByVersion(100);
|
||||
|
||||
expect(ok).toBe(true);
|
||||
// 游标推进到 current_version
|
||||
expect(useMessageStore.getState().syncVersion).toBe(110);
|
||||
});
|
||||
|
||||
it('full_sync=true 时退化为 false', async () => {
|
||||
const serviceMock = messageService as any;
|
||||
serviceMock.__setSyncByVersionHandler(() => ({
|
||||
current_version: 0,
|
||||
changes: [],
|
||||
full_sync: true,
|
||||
has_more: false,
|
||||
}));
|
||||
|
||||
const sync = createSyncService();
|
||||
const ok = await sync.syncByVersion(100);
|
||||
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it('API 抛异常时退化为 false', async () => {
|
||||
const serviceMock = messageService as any;
|
||||
serviceMock.__setSyncByVersionHandler(() => {
|
||||
throw new Error('网络故障');
|
||||
});
|
||||
|
||||
const sync = createSyncService();
|
||||
const ok = await sync.syncByVersion(100);
|
||||
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it('version <= 0 时直接退化', async () => {
|
||||
const sync = createSyncService();
|
||||
const ok1 = await sync.syncByVersion(0);
|
||||
const ok2 = await sync.syncByVersion(-1);
|
||||
expect(ok1).toBe(false);
|
||||
expect(ok2).toBe(false);
|
||||
});
|
||||
|
||||
it('有会话变更时按需同步会话详情', async () => {
|
||||
const serviceMock = messageService as any;
|
||||
// 先在 store 放一个会话,让 fetchConversationDetail 有上下文
|
||||
useMessageStore.getState().setConversationsWithUnread(
|
||||
new Map([['c1', { id: 'c1', last_seq: 5, unread_count: 0 } as any]]),
|
||||
0, 0
|
||||
);
|
||||
|
||||
let detailCalled = false;
|
||||
serviceMock.__setSyncByVersionHandler((version: number) => ({
|
||||
current_version: version + 5,
|
||||
changes: [
|
||||
{ conversation_id: 'c1', change_type: 'new_message', max_seq: 10, version: version + 1 },
|
||||
],
|
||||
full_sync: false,
|
||||
has_more: false,
|
||||
}));
|
||||
// fetchConversationDetail 内部调 getConversationById
|
||||
serviceMock.getConversationById = async () => {
|
||||
detailCalled = true;
|
||||
return { id: 'c1', last_seq: 10 } as any;
|
||||
};
|
||||
serviceMock.getMessages = async () => ({ messages: [], total: 0, page: 1, page_size: 20 });
|
||||
|
||||
const sync = createSyncService();
|
||||
const ok = await sync.syncByVersion(100);
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(detailCalled).toBe(true);
|
||||
expect(useMessageStore.getState().syncVersion).toBe(105);
|
||||
});
|
||||
});
|
||||
129
src/stores/message/__tests__/syncState.test.ts
Normal file
129
src/stores/message/__tests__/syncState.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 同步状态机单测(批次一④)
|
||||
*
|
||||
* 锁定 conversationSyncStateMap 的状态流转不变量:
|
||||
* - 初始 idle
|
||||
* - hydration 进行中 → hydrating
|
||||
* - hydration 成功 → synced
|
||||
* - hydration 失败 → error
|
||||
* - clearMessages/removeConversation → 回到 idle(隐式删除)
|
||||
*/
|
||||
|
||||
import { useMessageStore } from '../store';
|
||||
import { MessageSyncService } from '../services/MessageSyncService';
|
||||
import { messageService } from '@/services/message';
|
||||
import { messageRepository } from '@/database';
|
||||
import type { MessageResponse } from '../../../types/dto';
|
||||
|
||||
function makeRange(from: number, to: number): MessageResponse[] {
|
||||
const out: MessageResponse[] = [];
|
||||
for (let s = from; s <= to; s++) {
|
||||
out.push({
|
||||
id: `m-${s}`,
|
||||
conversation_id: 'c1',
|
||||
sender_id: 'u1',
|
||||
seq: s,
|
||||
segments: [],
|
||||
created_at: `2024-01-01T00:00:${String(s).padStart(2, '0')}Z`,
|
||||
status: 'normal',
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function createSyncService(): MessageSyncService {
|
||||
return new MessageSyncService(
|
||||
() => 'me',
|
||||
{ markAsRead: async () => {}, markAllAsRead: async () => {} } as any,
|
||||
{ getSenderInfo: async () => null, enrichMessagesWithSenderInfo: () => {} } as any,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
describe('同步状态机 conversationSyncState', () => {
|
||||
beforeEach(() => {
|
||||
useMessageStore.getState().reset();
|
||||
(messageRepository as any).__reset();
|
||||
(messageService as any).__reset();
|
||||
});
|
||||
|
||||
it('初始状态为 idle', () => {
|
||||
expect(useMessageStore.getState().getConversationSyncState('c1')).toBe('idle');
|
||||
});
|
||||
|
||||
it('hydration 成功后状态转为 synced', async () => {
|
||||
const repoMock = messageRepository as any;
|
||||
const serviceMock = messageService as any;
|
||||
repoMock.__setMessagesByConversation('c1', makeRange(1, 20));
|
||||
// 后端:本地已连续到顶,无需回填
|
||||
serviceMock.__setMessagesHandler(() => ({ messages: [], total: 0, page: 1, page_size: 20 }));
|
||||
|
||||
const sync = createSyncService();
|
||||
await sync.fetchMessages('c1');
|
||||
|
||||
expect(useMessageStore.getState().isHydrated('c1')).toBe(true);
|
||||
expect(useMessageStore.getState().getConversationSyncState('c1')).toBe('synced');
|
||||
});
|
||||
|
||||
it('hydration 失败后状态转为 error(store 层验证)', () => {
|
||||
// 直接验证状态机 action:外部(如 catch 块)设置 error 态
|
||||
const store = useMessageStore.getState();
|
||||
store.setConversationSyncState('c1', 'hydrating');
|
||||
expect(store.getConversationSyncState('c1')).toBe('hydrating');
|
||||
|
||||
store.setConversationSyncState('c1', 'error');
|
||||
expect(store.getConversationSyncState('c1')).toBe('error');
|
||||
|
||||
// error 态下 isHydrated 仍为 false(未成功完成)
|
||||
expect(store.isHydrated('c1')).toBe(false);
|
||||
});
|
||||
|
||||
it('error 态可重新进入 hydrating(用户重试场景)', async () => {
|
||||
const store = useMessageStore.getState();
|
||||
// 模拟上次失败留下 error 态
|
||||
store.setConversationSyncState('c1', 'error');
|
||||
// 手动清除 hydrated(确保重新 hydration)
|
||||
store.clearHydrated('c1');
|
||||
|
||||
const repoMock = messageRepository as any;
|
||||
const serviceMock = messageService as any;
|
||||
repoMock.__setMessagesByConversation('c1', makeRange(1, 5));
|
||||
serviceMock.__setMessagesHandler(() => ({ messages: [], total: 0, page: 1, page_size: 20 }));
|
||||
|
||||
// 重新 fetch:error → hydrating → synced
|
||||
const sync = createSyncService();
|
||||
await sync.fetchMessages('c1');
|
||||
expect(store.getConversationSyncState('c1')).toBe('synced');
|
||||
});
|
||||
|
||||
it('clearMessages 后状态回到 idle', async () => {
|
||||
const repoMock = messageRepository as any;
|
||||
const serviceMock = messageService as any;
|
||||
repoMock.__setMessagesByConversation('c1', makeRange(1, 5));
|
||||
serviceMock.__setMessagesHandler(() => ({ messages: [], total: 0, page: 1, page_size: 20 }));
|
||||
|
||||
const sync = createSyncService();
|
||||
await sync.fetchMessages('c1');
|
||||
expect(useMessageStore.getState().getConversationSyncState('c1')).toBe('synced');
|
||||
|
||||
useMessageStore.getState().clearMessages('c1');
|
||||
expect(useMessageStore.getState().getConversationSyncState('c1')).toBe('idle');
|
||||
});
|
||||
|
||||
it('重入已 synced 会话不再进入 hydrating', async () => {
|
||||
const repoMock = messageRepository as any;
|
||||
const serviceMock = messageService as any;
|
||||
repoMock.__setMessagesByConversation('c1', makeRange(1, 20));
|
||||
serviceMock.__setMessagesHandler(() => ({ messages: [], total: 0, page: 1, page_size: 20 }));
|
||||
|
||||
const sync = createSyncService();
|
||||
// 首次:idle → hydrating → synced
|
||||
await sync.fetchMessages('c1');
|
||||
expect(useMessageStore.getState().getConversationSyncState('c1')).toBe('synced');
|
||||
|
||||
// 重入:已 synced,走轻量增量,不重置为 hydrating
|
||||
await sync.fetchMessages('c1');
|
||||
// 仍为 synced(不会回到 hydrating,因为 wasHydratedAtStart=true 时不设 hydrating)
|
||||
expect(useMessageStore.getState().getConversationSyncState('c1')).toBe('synced');
|
||||
});
|
||||
});
|
||||
11
src/stores/message/__tests__/tsconfig.json
Normal file
11
src/stores/message/__tests__/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "../../../../",
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules", "mocks/**"]
|
||||
}
|
||||
112
src/stores/message/__tests__/unread.atomics.test.ts
Normal file
112
src/stores/message/__tests__/unread.atomics.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 未读计数原子性单测
|
||||
*
|
||||
* 锁定的不变量:
|
||||
* 1. store 原子 actions(incrementSystemUnreadAtomic / setSystemUnreadAtomic /
|
||||
* decrementSystemUnreadAtomic)保持 total = system + 会话未读的一致性。
|
||||
* 2. ConversationOperations 的系统未读变更走原子路径,不丢更新(修复 RMW 竞态)。
|
||||
* 3. 并发多次递增计数不丢失。
|
||||
*/
|
||||
|
||||
import { useMessageStore } from '../store';
|
||||
import { ConversationOperations } from '../services/ConversationOperations';
|
||||
|
||||
describe('未读计数原子性', () => {
|
||||
let convOps: ConversationOperations;
|
||||
|
||||
beforeEach(() => {
|
||||
useMessageStore.getState().reset();
|
||||
convOps = new ConversationOperations();
|
||||
});
|
||||
|
||||
describe('store 原子 actions', () => {
|
||||
it('incrementSystemUnreadAtomic 同步推进 system 与 total', () => {
|
||||
useMessageStore.getState().incrementSystemUnreadAtomic();
|
||||
useMessageStore.getState().incrementSystemUnreadAtomic();
|
||||
const { total, system } = useMessageStore.getState().getUnreadCount();
|
||||
expect(system).toBe(2);
|
||||
expect(total).toBe(2);
|
||||
});
|
||||
|
||||
it('setSystemUnreadAtomic 保持 total 一致性(基于 delta)', () => {
|
||||
useMessageStore.getState().incrementSystemUnreadAtomic(); // system=1,total=1
|
||||
useMessageStore.getState().setSystemUnreadAtomic(5); // system=5, total 应 +4
|
||||
const { total, system } = useMessageStore.getState().getUnreadCount();
|
||||
expect(system).toBe(5);
|
||||
expect(total).toBe(5);
|
||||
});
|
||||
|
||||
it('setSystemUnreadAtomic 向下调整也保持一致', () => {
|
||||
useMessageStore.getState().setSystemUnreadAtomic(10); // system=10,total=10
|
||||
useMessageStore.getState().setSystemUnreadAtomic(3); // system=3,total=3
|
||||
const { total, system } = useMessageStore.getState().getUnreadCount();
|
||||
expect(system).toBe(3);
|
||||
expect(total).toBe(3);
|
||||
});
|
||||
|
||||
it('decrementSystemUnreadAtomic 不低于 0', () => {
|
||||
useMessageStore.getState().setSystemUnreadAtomic(2);
|
||||
useMessageStore.getState().decrementSystemUnreadAtomic(5); // 应被钳制到 0
|
||||
const { total, system } = useMessageStore.getState().getUnreadCount();
|
||||
expect(system).toBe(0);
|
||||
expect(total).toBe(0);
|
||||
});
|
||||
|
||||
it('decrementSystemUnreadAtomic 默认减 1', () => {
|
||||
useMessageStore.getState().setSystemUnreadAtomic(3);
|
||||
useMessageStore.getState().decrementSystemUnreadAtomic();
|
||||
const { system } = useMessageStore.getState().getUnreadCount();
|
||||
expect(system).toBe(2);
|
||||
});
|
||||
|
||||
it('并发连续递增计数不丢失(每个 set 都是原子的)', () => {
|
||||
// 模拟 WS 高并发推送 100 条系统通知
|
||||
for (let i = 0; i < 100; i++) {
|
||||
useMessageStore.getState().incrementSystemUnreadAtomic();
|
||||
}
|
||||
const { total, system } = useMessageStore.getState().getUnreadCount();
|
||||
expect(system).toBe(100);
|
||||
expect(total).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConversationOperations 走原子路径', () => {
|
||||
it('incrementSystemUnreadCount 不丢更新', () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
convOps.incrementSystemUnreadCount();
|
||||
}
|
||||
const { system } = useMessageStore.getState().getUnreadCount();
|
||||
expect(system).toBe(50);
|
||||
});
|
||||
|
||||
it('setSystemUnreadCount 后再并发递增不丢', () => {
|
||||
convOps.setSystemUnreadCount(10);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
convOps.incrementSystemUnreadCount();
|
||||
}
|
||||
const { system, total } = useMessageStore.getState().getUnreadCount();
|
||||
expect(system).toBe(30);
|
||||
expect(total).toBe(30);
|
||||
});
|
||||
|
||||
it('decrementSystemUnreadCount 与原子行为一致', () => {
|
||||
convOps.setSystemUnreadCount(10);
|
||||
convOps.decrementSystemUnreadCount(3);
|
||||
expect(useMessageStore.getState().getUnreadCount().system).toBe(7);
|
||||
});
|
||||
|
||||
it('会话未读 + 系统未读共同构成 total', () => {
|
||||
// 先放一个有未读的会话
|
||||
useMessageStore.getState().updateConversation({
|
||||
id: 'c1',
|
||||
unread_count: 5,
|
||||
last_seq: 0,
|
||||
} as any);
|
||||
convOps.incrementSystemUnreadCount(); // system=1,total 应 = 5+1=6
|
||||
const { total, system } = useMessageStore.getState().getUnreadCount();
|
||||
expect(system).toBe(1);
|
||||
// total 由会话未读累加与系统未读共同维护;这里仅断言 system 路径
|
||||
expect(total).toBeGreaterThanOrEqual(system);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,32 +9,30 @@
|
||||
*/
|
||||
export const READ_STATE_PROTECTION_DELAY = 5000; // 5秒
|
||||
|
||||
/**
|
||||
* 已处理消息ID过期时间(毫秒)
|
||||
* 用于消息去重机制,防止内存泄漏
|
||||
*/
|
||||
export const PROCESSED_MESSAGE_ID_EXPIRY = 5 * 60 * 1000; // 5分钟
|
||||
|
||||
/**
|
||||
* 已处理消息ID集合最大大小
|
||||
* 超过此大小时触发清理
|
||||
*/
|
||||
export const PROCESSED_MESSAGE_ID_MAX_SIZE = 1000;
|
||||
|
||||
/**
|
||||
* 缓冲SSE事件刷新最大迭代次数
|
||||
* 防止在线流持续不断导致长时间阻塞初始化
|
||||
*/
|
||||
export const MAX_FLUSH_ITERATIONS = 3;
|
||||
|
||||
/**
|
||||
* 会话列表刷新延迟检查间隔(毫秒)
|
||||
* 用于防抖处理
|
||||
*/
|
||||
export const CONVERSATION_REFRESH_THROTTLE = 1000;
|
||||
|
||||
/**
|
||||
* 重连同步最小间隔(毫秒)
|
||||
* 防止频繁重连导致的重复同步
|
||||
*/
|
||||
export const MIN_RECONNECT_SYNC_INTERVAL = 1500;
|
||||
|
||||
/**
|
||||
* 单次消息拉取/本地读取的页大小。
|
||||
* 对齐现有 loadMoreMessages 的默认 limit,作为 hydration 管线的统一页大小。
|
||||
*/
|
||||
export const MESSAGES_PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* 进入会话时「下缺口回填」的最大循环轮数(对标 Matrix backpagination 上限)。
|
||||
*
|
||||
* 当本地连续区间 [maxSeq, minSeq] 的 minSeq > 1(即最新端往前存在历史缺口)时,
|
||||
* hydration 管线会循环 loadMoreMessages 向前回填,直到连续到顶(minSeq==1)
|
||||
* 或触达本上限。上限是为了防止极端情况(如会话有数万条历史)下首屏卡死,
|
||||
* 剩余缺口由用户上滑 loadMoreHistory 兜底补全。
|
||||
*/
|
||||
export const MAX_GAP_FILL_ROUNDS = 5;
|
||||
|
||||
@@ -17,7 +17,11 @@ import { useShallow } from 'zustand/react/shallow';
|
||||
import { ConversationResponse, MessageResponse, MessageSegment } from '../../types/dto';
|
||||
import { messageManager } from './MessageManager';
|
||||
import { useMessageStore } from './store';
|
||||
import type { ConversationSyncState } from './store';
|
||||
import { useUnreadCountQuery } from '../../hooks/useUnreadCount';
|
||||
import { createErrorHandler } from '@/services/core';
|
||||
|
||||
const errorHandler = createErrorHandler('message-hooks');
|
||||
|
||||
const EMPTY_MESSAGES: MessageResponse[] = [];
|
||||
|
||||
@@ -44,7 +48,7 @@ export function useConversations(): UseConversationsReturn {
|
||||
useEffect(() => {
|
||||
// 初始化时确保 MessageManager 已初始化
|
||||
messageManager.initialize().catch(error => {
|
||||
console.error('[useConversations] 初始化失败:', error);
|
||||
errorHandler(error);
|
||||
});
|
||||
|
||||
// 仅在首次初始化时做一次冷启动兜底刷新,避免每次挂载都触发
|
||||
@@ -52,7 +56,7 @@ export function useConversations(): UseConversationsReturn {
|
||||
if (!store.isInitialized) {
|
||||
const coldStartSyncTimer = setTimeout(() => {
|
||||
messageManager.refreshConversations(true, 'hooks-initial-refresh').catch(error => {
|
||||
console.error('[useConversations] 冷启动强制刷新失败:', error);
|
||||
errorHandler(error);
|
||||
});
|
||||
}, 1200);
|
||||
|
||||
@@ -89,6 +93,7 @@ export function useConversations(): UseConversationsReturn {
|
||||
interface UseMessagesReturn {
|
||||
messages: MessageResponse[];
|
||||
isLoading: boolean;
|
||||
syncState: ConversationSyncState;
|
||||
hasMore: boolean;
|
||||
loadMore: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
@@ -121,6 +126,12 @@ export function useMessages(conversationId: string | null): UseMessagesReturn {
|
||||
const isLoadingMessages = useMessageStore(state =>
|
||||
normalizedConversationId ? state.loadingMessagesSet.has(normalizedConversationId) : false
|
||||
);
|
||||
// 会话同步状态机(hydration 管线维护),供 UI 展示错误态/重试
|
||||
const syncState = useMessageStore(state =>
|
||||
normalizedConversationId
|
||||
? (state.conversationSyncStateMap.get(normalizedConversationId) || 'idle')
|
||||
: 'idle'
|
||||
);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const loadMoreInFlightRef = useRef(false);
|
||||
// 追踪上次激活的 conversationId,避免同一会话重复激活时不必要的 forceSync
|
||||
@@ -144,7 +155,7 @@ export function useMessages(conversationId: string | null): UseMessagesReturn {
|
||||
// 架构入口:激活会话并完成初始化/同步
|
||||
// 重入同一会话时 forceSync,确保拿到最新消息
|
||||
messageManager.activateConversation(normalizedConversationId, { forceSync: isReentry }).catch(error => {
|
||||
console.error('[useMessages] 激活会话失败:', error);
|
||||
errorHandler(error);
|
||||
});
|
||||
|
||||
lastActivatedIdRef.current = normalizedConversationId;
|
||||
@@ -180,11 +191,11 @@ export function useMessages(conversationId: string | null): UseMessagesReturn {
|
||||
const maxSeq = currentMessages.reduce((max, m) => Math.max(max, m.seq || 0), 0);
|
||||
if (maxSeq > 0) {
|
||||
messageManager.fetchMessages(normalizedConversationId, maxSeq).catch(error => {
|
||||
console.error('[useMessages] 焦点同步消息失败:', error);
|
||||
errorHandler(error);
|
||||
});
|
||||
} else {
|
||||
messageManager.fetchMessages(normalizedConversationId).catch(error => {
|
||||
console.error('[useMessages] 焦点同步消息(冷启动)失败:', error);
|
||||
errorHandler(error);
|
||||
});
|
||||
}
|
||||
}, [normalizedConversationId])
|
||||
@@ -219,6 +230,7 @@ export function useMessages(conversationId: string | null): UseMessagesReturn {
|
||||
return {
|
||||
messages,
|
||||
isLoading: isLoadingMessages,
|
||||
syncState,
|
||||
hasMore,
|
||||
loadMore,
|
||||
refresh,
|
||||
@@ -228,7 +240,8 @@ export function useMessages(conversationId: string | null): UseMessagesReturn {
|
||||
// ==================== useSendMessage - 发送消息 ====================
|
||||
|
||||
interface UseSendMessageReturn {
|
||||
sendMessage: (segments: MessageSegment[], options?: { replyToId?: string }) => Promise<MessageResponse | null>;
|
||||
sendMessage: (segments: MessageSegment[], options?: { replyToId?: string; detailType?: 'private' | 'group' }) => Promise<MessageResponse | null>;
|
||||
retrySendMessage: (failedMessage: MessageResponse) => Promise<MessageResponse | null>;
|
||||
isSending: boolean;
|
||||
}
|
||||
|
||||
@@ -239,7 +252,7 @@ export function useSendMessage(conversationId: string | null): UseSendMessageRet
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (segments: MessageSegment[], options?: { replyToId?: string }): Promise<MessageResponse | null> => {
|
||||
async (segments: MessageSegment[], options?: { replyToId?: string; detailType?: 'private' | 'group' }): Promise<MessageResponse | null> => {
|
||||
if (!conversationId) return null;
|
||||
|
||||
setIsSending(true);
|
||||
@@ -253,8 +266,24 @@ export function useSendMessage(conversationId: string | null): UseSendMessageRet
|
||||
[conversationId]
|
||||
);
|
||||
|
||||
const retrySendMessage = useCallback(
|
||||
async (failedMessage: MessageResponse): Promise<MessageResponse | null> => {
|
||||
if (!conversationId) return null;
|
||||
|
||||
setIsSending(true);
|
||||
try {
|
||||
const message = await messageManager.retrySendMessage(conversationId, failedMessage);
|
||||
return message;
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
},
|
||||
[conversationId]
|
||||
);
|
||||
|
||||
return {
|
||||
sendMessage,
|
||||
retrySendMessage,
|
||||
isSending,
|
||||
};
|
||||
}
|
||||
@@ -396,7 +425,7 @@ export function useMessageManager(): UseMessageManagerReturn {
|
||||
useEffect(() => {
|
||||
// 初始化
|
||||
messageManager.initialize().catch(error => {
|
||||
console.error('[useMessageManager] 初始化失败:', error);
|
||||
errorHandler(error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -511,7 +540,7 @@ export function useMessageListRefresh(): UseMessageListRefreshReturn {
|
||||
try {
|
||||
await messageManager.refreshConversations(true, 'hooks-load-more-fallback');
|
||||
} catch (error) {
|
||||
console.error('[useMessageListRefresh] 刷新失败:', error);
|
||||
errorHandler(error);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
@@ -537,7 +566,8 @@ interface UseChatReturn {
|
||||
refreshMessages: () => Promise<void>;
|
||||
|
||||
// 发送消息
|
||||
sendMessage: (segments: MessageSegment[], options?: { replyToId?: string }) => Promise<MessageResponse | null>;
|
||||
sendMessage: (segments: MessageSegment[], options?: { replyToId?: string; detailType?: 'private' | 'group' }) => Promise<MessageResponse | null>;
|
||||
retrySendMessage: (failedMessage: MessageResponse) => Promise<MessageResponse | null>;
|
||||
isSending: boolean;
|
||||
|
||||
// 已读相关
|
||||
@@ -549,7 +579,7 @@ interface UseChatReturn {
|
||||
|
||||
export function useChat(conversationId: string | null): UseChatReturn {
|
||||
const { messages, isLoading: isLoadingMessages, hasMore: hasMoreMessages, loadMore, refresh } = useMessages(conversationId);
|
||||
const { sendMessage, isSending } = useSendMessage(conversationId);
|
||||
const { sendMessage, retrySendMessage, isSending } = useSendMessage(conversationId);
|
||||
const { markAsRead } = useMarkAsRead(conversationId);
|
||||
const { conversation } = useConversation(conversationId);
|
||||
|
||||
@@ -560,6 +590,7 @@ export function useChat(conversationId: string | null): UseChatReturn {
|
||||
loadMoreMessages: loadMore,
|
||||
refreshMessages: refresh,
|
||||
sendMessage,
|
||||
retrySendMessage,
|
||||
isSending,
|
||||
markAsRead,
|
||||
conversation,
|
||||
|
||||
@@ -20,34 +20,27 @@ export type {
|
||||
ReadStateRecord,
|
||||
MessageManagerConversationListDeps,
|
||||
HandleNewMessageOptions,
|
||||
IMessageDeduplication,
|
||||
IUserCacheService,
|
||||
IReadReceiptManager,
|
||||
IConversationOperations,
|
||||
IMessageSendService,
|
||||
IMessageSyncService,
|
||||
IWSMessageHandler,
|
||||
} from './types';
|
||||
|
||||
// ==================== 常量导出 ====================
|
||||
export {
|
||||
READ_STATE_PROTECTION_DELAY,
|
||||
PROCESSED_MESSAGE_ID_EXPIRY,
|
||||
PROCESSED_MESSAGE_ID_MAX_SIZE,
|
||||
MAX_FLUSH_ITERATIONS,
|
||||
CONVERSATION_REFRESH_THROTTLE,
|
||||
MIN_RECONNECT_SYNC_INTERVAL,
|
||||
} from './constants';
|
||||
|
||||
// ==================== 服务导出 ====================
|
||||
export {
|
||||
// 消息去重服务
|
||||
MessageDeduplication,
|
||||
messageDeduplication,
|
||||
// 实时消息入口
|
||||
RealtimeIngestionPipeline,
|
||||
|
||||
// 用户缓存服务
|
||||
UserCacheService,
|
||||
userCacheService,
|
||||
|
||||
// 已读回执管理器
|
||||
ReadReceiptManager,
|
||||
@@ -60,10 +53,8 @@ export {
|
||||
|
||||
// 消息同步服务
|
||||
MessageSyncService,
|
||||
|
||||
// WebSocket 消息处理器
|
||||
WSMessageHandler,
|
||||
} from './services';
|
||||
export type { RealtimeIngestionPipelineDeps, IngestOptions } from './services';
|
||||
|
||||
// ==================== Hooks 导出(便捷版,使用 messageManager 单例) ====================
|
||||
export {
|
||||
|
||||
@@ -8,6 +8,9 @@ import { messageService } from '@/services/message';
|
||||
import { conversationRepository } from '@/database';
|
||||
import type { IConversationOperations } from '../types';
|
||||
import { useMessageStore, normalizeConversationId } from '../store';
|
||||
import { createErrorHandler } from '@/services/core';
|
||||
|
||||
const errorHandler = createErrorHandler('ConversationOperations');
|
||||
|
||||
export class ConversationOperations implements IConversationOperations {
|
||||
/**
|
||||
@@ -24,7 +27,7 @@ export class ConversationOperations implements IConversationOperations {
|
||||
|
||||
return conversation;
|
||||
} catch (error) {
|
||||
console.error('[ConversationOperations] 创建会话失败:', error);
|
||||
errorHandler(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -57,7 +60,7 @@ export class ConversationOperations implements IConversationOperations {
|
||||
|
||||
// 删除本地数据库中的会话
|
||||
conversationRepository.delete(conversationId).catch(error => {
|
||||
console.error('[ConversationOperations] 删除本地会话失败:', error);
|
||||
errorHandler(error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,30 +102,23 @@ export class ConversationOperations implements IConversationOperations {
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置系统消息未读数
|
||||
* 设置系统消息未读数(原子:避免与 WS 推送的并发递增丢更新)
|
||||
*/
|
||||
setSystemUnreadCount(count: number): void {
|
||||
const store = useMessageStore.getState();
|
||||
const currentUnread = store.getUnreadCount();
|
||||
store.setUnreadCount(currentUnread.total, count);
|
||||
useMessageStore.getState().setSystemUnreadAtomic(count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加系统消息未读数
|
||||
* 增加系统消息未读数(原子)
|
||||
*/
|
||||
incrementSystemUnreadCount(): void {
|
||||
const store = useMessageStore.getState();
|
||||
const currentUnread = store.getUnreadCount();
|
||||
store.setUnreadCount(currentUnread.total, currentUnread.system + 1);
|
||||
useMessageStore.getState().incrementSystemUnreadAtomic();
|
||||
}
|
||||
|
||||
/**
|
||||
* 减少系统消息未读数
|
||||
* 减少系统消息未读数(原子,不低于 0)
|
||||
*/
|
||||
decrementSystemUnreadCount(count = 1): void {
|
||||
const store = useMessageStore.getState();
|
||||
const currentUnread = store.getUnreadCount();
|
||||
const newSystemCount = Math.max(0, currentUnread.system - count);
|
||||
store.setUnreadCount(currentUnread.total, newSystemCount);
|
||||
useMessageStore.getState().decrementSystemUnreadAtomic(count);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* 消息去重服务
|
||||
* 处理消息去重逻辑,防止重复处理相同的消息
|
||||
*/
|
||||
|
||||
import type { IMessageDeduplication } from '../types';
|
||||
import {
|
||||
PROCESSED_MESSAGE_ID_EXPIRY,
|
||||
PROCESSED_MESSAGE_ID_MAX_SIZE,
|
||||
} from '../constants';
|
||||
|
||||
export class MessageDeduplication implements IMessageDeduplication {
|
||||
// 已处理消息ID集合(用于去重,防止ACK消息和正常消息重复处理)
|
||||
private processedMessageIds: Set<string> = new Set();
|
||||
private processedMessageIdsExpiry: Map<string, number> = new Map();
|
||||
|
||||
/**
|
||||
* 检查消息是否已处理
|
||||
*/
|
||||
isMessageProcessed(messageId: string): boolean {
|
||||
return this.processedMessageIds.has(messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记消息已处理
|
||||
*/
|
||||
markMessageAsProcessed(messageId: string): void {
|
||||
this.processedMessageIds.add(messageId);
|
||||
this.processedMessageIdsExpiry.set(messageId, Date.now());
|
||||
|
||||
// 定期清理
|
||||
if (this.processedMessageIds.size > PROCESSED_MESSAGE_ID_MAX_SIZE) {
|
||||
this.cleanupProcessedMessageIds();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的消息ID(防止内存泄漏)
|
||||
*/
|
||||
cleanupProcessedMessageIds(): void {
|
||||
const now = Date.now();
|
||||
for (const [id, timestamp] of this.processedMessageIdsExpiry.entries()) {
|
||||
if (now - timestamp > PROCESSED_MESSAGE_ID_EXPIRY) {
|
||||
this.processedMessageIds.delete(id);
|
||||
this.processedMessageIdsExpiry.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有已处理的消息ID
|
||||
* 用于测试或需要清除缓存的场景
|
||||
*/
|
||||
reset(): void {
|
||||
this.processedMessageIds.clear();
|
||||
this.processedMessageIdsExpiry.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// 单例导出
|
||||
export const messageDeduplication = new MessageDeduplication();
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 消息发送服务
|
||||
* 处理消息发送相关逻辑
|
||||
* 实现乐观更新:先显示消息,再发送到服务器
|
||||
*/
|
||||
|
||||
import type { MessageResponse, MessageSegment, ConversationResponse } from '../../../types/dto';
|
||||
@@ -8,6 +9,15 @@ import { messageService } from '@/services/message';
|
||||
import { messageRepository } from '@/database';
|
||||
import type { IMessageSendService } from '../types';
|
||||
import { useMessageStore } from '../store';
|
||||
import { createErrorHandler } from '@/services/core';
|
||||
|
||||
const errorHandler = createErrorHandler('MessageSendService');
|
||||
|
||||
// 临时ID前缀,用于乐观更新
|
||||
const TEMP_ID_PREFIX = 'temp_';
|
||||
// 全局自增计数器:与 Date.now() + Math.random() 组合,彻底消除同一毫秒内
|
||||
// 连续发送导致的临时 ID 碰撞风险(addOrReplaceMessage 按 id 幂等,碰撞会吞掉消息)。
|
||||
let tempIdCounter = 0;
|
||||
|
||||
export class MessageSendService implements IMessageSendService {
|
||||
private getCurrentUserId: () => string | null;
|
||||
@@ -17,68 +27,177 @@ export class MessageSendService implements IMessageSendService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
* 生成临时消息ID
|
||||
* 组合 timestamp + 自增计数器 + 随机串,保证全局唯一。
|
||||
*/
|
||||
private generateTempId(): string {
|
||||
return `${TEMP_ID_PREFIX}${Date.now()}_${++tempIdCounter}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(乐观更新模式)
|
||||
* 1. 立即创建临时消息并显示
|
||||
* 2. 后台发送到服务器
|
||||
* 3. 成功:更新为正式消息
|
||||
* 4. 失败:标记为发送失败
|
||||
*
|
||||
* @param detailType 会话类型,'private' 优先走 WebSocket(失败降级 HTTP),
|
||||
* 'group' 直接走 HTTP。默认 'private'。
|
||||
*/
|
||||
async sendMessage(
|
||||
conversationId: string,
|
||||
segments: MessageSegment[],
|
||||
options?: { replyToId?: string }
|
||||
options?: { replyToId?: string; detailType?: 'private' | 'group' }
|
||||
): Promise<MessageResponse | null> {
|
||||
const store = useMessageStore.getState();
|
||||
const currentUserId = this.getCurrentUserId();
|
||||
const detailType = options?.detailType ?? 'private';
|
||||
|
||||
// 1. 创建临时消息(乐观显示)
|
||||
const tempId = this.generateTempId();
|
||||
const tempMessage: MessageResponse = {
|
||||
id: tempId,
|
||||
conversation_id: conversationId,
|
||||
sender_id: currentUserId || '',
|
||||
seq: 0, // 临时消息seq为0
|
||||
segments,
|
||||
reply_to_id: options?.replyToId,
|
||||
created_at: new Date().toISOString(),
|
||||
status: 'pending', // 标记为发送中
|
||||
};
|
||||
|
||||
// 2. 立即添加到store,用户可以马上看到
|
||||
store.addOrReplaceMessage(conversationId, tempMessage);
|
||||
// Outbox:临时消息落库(status='pending'),保证 App 重启后能恢复未完成的发送
|
||||
this.persistMessage(tempMessage);
|
||||
|
||||
// 3. 更新会话最后消息(乐观更新)
|
||||
this.updateConversationWithNewMessage(conversationId, tempMessage);
|
||||
|
||||
try {
|
||||
// 调用API发送
|
||||
// 4. 调用API发送
|
||||
const response = await messageService.sendMessage(conversationId, {
|
||||
segments,
|
||||
reply_to_id: options?.replyToId,
|
||||
});
|
||||
}, detailType);
|
||||
|
||||
if (response) {
|
||||
const currentUserId = this.getCurrentUserId();
|
||||
|
||||
// 原子性合并新消息到内存(防止并发 WS 消息丢失)
|
||||
const newMessage: MessageResponse = {
|
||||
// 5. 发送成功:用服务器返回的数据更新临时消息
|
||||
const successMessage: MessageResponse = {
|
||||
id: response.id,
|
||||
conversation_id: conversationId,
|
||||
sender_id: currentUserId || '',
|
||||
seq: response.seq,
|
||||
segments,
|
||||
reply_to_id: options?.replyToId,
|
||||
created_at: new Date().toISOString(),
|
||||
status: 'normal',
|
||||
};
|
||||
|
||||
store.mergeMessages(conversationId, [newMessage]);
|
||||
// 删除临时消息,添加正式消息
|
||||
store.removeMessage(conversationId, tempId);
|
||||
store.addOrReplaceMessage(conversationId, successMessage);
|
||||
|
||||
// 更新会话最后消息
|
||||
this.updateConversationWithNewMessage(conversationId, newMessage);
|
||||
this.updateConversationWithNewMessage(conversationId, successMessage);
|
||||
|
||||
// 保存到本地数据库
|
||||
// Outbox:成功后持久化正式消息(INSERT OR REPLACE 按 id 覆盖),
|
||||
// 并删除 tempId 的 DB 记录,避免重启后重复恢复
|
||||
this.persistMessage(successMessage);
|
||||
messageRepository.delete(tempId).catch((e: unknown) => errorHandler(e));
|
||||
|
||||
return successMessage;
|
||||
}
|
||||
|
||||
// 如果服务器没有返回有效响应,标记为失败
|
||||
const failedMessage: MessageResponse = {
|
||||
...tempMessage,
|
||||
status: 'failed',
|
||||
};
|
||||
// 注意:addOrReplaceMessage 按 id 幂等(已存在则不更新),改用 patchMessages 更新 status
|
||||
const patches = new Map<string, Partial<MessageResponse>>();
|
||||
patches.set(String(failedMessage.id), { status: 'failed' });
|
||||
store.patchMessages(conversationId, patches);
|
||||
// Outbox:失败消息落库
|
||||
this.persistMessage(failedMessage);
|
||||
return null;
|
||||
} catch (error) {
|
||||
errorHandler(error);
|
||||
|
||||
// 6. 发送失败:更新消息状态为失败
|
||||
const failedMessage: MessageResponse = {
|
||||
...tempMessage,
|
||||
status: 'failed',
|
||||
};
|
||||
// addOrReplaceMessage 按 id 幂等不更新,改用 patchMessages 更新 status
|
||||
const patches = new Map<string, Partial<MessageResponse>>();
|
||||
patches.set(String(failedMessage.id), { status: 'failed' });
|
||||
store.patchMessages(conversationId, patches);
|
||||
// Outbox:失败消息落库
|
||||
this.persistMessage(failedMessage);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试发送失败的消息
|
||||
*
|
||||
* 实现要点:
|
||||
* 1. 先移除旧的失败消息(临时 ID),避免与即将插入的新 pending 消息并存出现两条。
|
||||
* 2. 复用原 segments 与 replyToId 调用 sendMessage,由它重新走完整乐观更新流程
|
||||
* (插入新 pending → 发送 → 成功替换为正式消息 / 失败再次标记 failed)。
|
||||
* 3. detailType 无法从失败消息反推,默认 private;
|
||||
* 群聊失败消息重试走 HTTP(sendMessage 的 group 分支)也能成功。
|
||||
*/
|
||||
async retrySendMessage(
|
||||
conversationId: string,
|
||||
failedMessage: MessageResponse
|
||||
): Promise<MessageResponse | null> {
|
||||
if (failedMessage.status !== 'failed') {
|
||||
console.warn('[MessageSendService] 只能重试发送失败的消息');
|
||||
return null;
|
||||
}
|
||||
|
||||
const store = useMessageStore.getState();
|
||||
// 移除旧的失败消息,避免 sendMessage 新插入的 pending 消息与之并存
|
||||
store.removeMessage(conversationId, String(failedMessage.id));
|
||||
// Outbox:同步删除 DB 中旧的 failed 记录,避免重启后 getPendingOrFailed 重复恢复
|
||||
// (sendMessage 会用新 tempId 重新落库 pending,重试成功后正常落库 normal 并删新 tempId)
|
||||
messageRepository.delete(String(failedMessage.id))
|
||||
.catch((e: unknown) => errorHandler(e));
|
||||
|
||||
// 重新发送:sendMessage 会重新插入 pending 临时消息并完成后续流程
|
||||
return this.sendMessage(conversationId, failedMessage.segments, {
|
||||
replyToId: failedMessage.reply_to_id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化消息到本地数据库(Outbox 机制)。
|
||||
* 把 MessageResponse(DTO)映射为 CachedMessage(DB 行)并落库(INSERT OR REPLACE 按 id 幂等)。
|
||||
* 用于 pending/failed/normal 三态消息的持久化,保证 App 重启后发送状态可恢复。
|
||||
* 落库失败不阻断 UI(fire-and-forget + 日志),最坏情况是重启后丢失该条未完成发送。
|
||||
*/
|
||||
private persistMessage(message: MessageResponse): void {
|
||||
const segments = message.segments || [];
|
||||
const textContent = segments
|
||||
?.filter((s: any) => s.type === 'text')
|
||||
.filter((s: any) => s.type === 'text')
|
||||
.map((s: any) => s.data?.text || '')
|
||||
.join('') || '';
|
||||
|
||||
messageRepository.saveMessage({
|
||||
id: response.id,
|
||||
conversationId,
|
||||
senderId: currentUserId || '',
|
||||
id: String(message.id),
|
||||
conversationId: message.conversation_id,
|
||||
senderId: message.sender_id,
|
||||
content: textContent,
|
||||
type: segments?.find((s: any) => s.type === 'image') ? 'image' : 'text',
|
||||
type: segments.find((s: any) => s.type === 'image') ? 'image' : 'text',
|
||||
isRead: true,
|
||||
createdAt: newMessage.created_at,
|
||||
seq: response.seq,
|
||||
status: 'normal',
|
||||
createdAt: message.created_at,
|
||||
seq: message.seq,
|
||||
status: message.status,
|
||||
segments,
|
||||
}).catch(console.error);
|
||||
|
||||
return newMessage;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[MessageSendService] 发送消息失败:', error);
|
||||
return null;
|
||||
}
|
||||
}).catch((e: unknown) => errorHandler(e));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,7 +215,7 @@ export class MessageSendService implements IMessageSendService {
|
||||
// 更新现有会话
|
||||
const updatedConv: ConversationResponse = {
|
||||
...conversation,
|
||||
last_seq: message.seq,
|
||||
last_seq: message.seq > 0 ? message.seq : conversation.last_seq,
|
||||
last_message: message,
|
||||
last_message_at: createdAt,
|
||||
updated_at: createdAt,
|
||||
|
||||
@@ -16,6 +16,14 @@ import {
|
||||
import type { IMessageSyncService, MessageManagerConversationListDeps, IUserCacheService } from '../types';
|
||||
import { useMessageStore, normalizeConversationId } from '../store';
|
||||
import { ReadReceiptManager } from './ReadReceiptManager';
|
||||
import {
|
||||
MESSAGES_PAGE_SIZE,
|
||||
MAX_GAP_FILL_ROUNDS,
|
||||
} from '../constants';
|
||||
import { createErrorHandler } from '@/services/core';
|
||||
|
||||
/** 本模块统一的错误处理器(带 context 分类,替代散落的 console.error) */
|
||||
const errorHandler = createErrorHandler('MessageSyncService');
|
||||
|
||||
export class MessageSyncService implements IMessageSyncService {
|
||||
private getCurrentUserId: () => string | null;
|
||||
@@ -36,7 +44,6 @@ export class MessageSyncService implements IMessageSyncService {
|
||||
private inflightFetches: Map<string, Promise<void>> = new Map();
|
||||
private inflightFetchConversations: Promise<void> | null = null;
|
||||
private inflightSyncBySeq: Promise<boolean> | null = null;
|
||||
private inflightSyncByVersion: Promise<boolean> | null = null;
|
||||
|
||||
constructor(
|
||||
getCurrentUserId: () => string | null,
|
||||
@@ -131,7 +138,7 @@ export class MessageSyncService implements IMessageSyncService {
|
||||
|
||||
this.persistConversationListCache();
|
||||
} catch (error) {
|
||||
console.error('[MessageSyncService] 获取会话列表失败:', error);
|
||||
errorHandler(error);
|
||||
if (store.getConversations().length === 0) {
|
||||
const recovered = await this.hydrateConversationsFromLocalSource();
|
||||
if (recovered) {
|
||||
@@ -168,7 +175,7 @@ export class MessageSyncService implements IMessageSyncService {
|
||||
this.recomputeConversationTotalUnread();
|
||||
this.persistConversationListCache();
|
||||
} catch (error) {
|
||||
console.error('[MessageSyncService] 加载更多会话失败:', error);
|
||||
errorHandler(error);
|
||||
} finally {
|
||||
this.loadingMoreConversations = false;
|
||||
}
|
||||
@@ -190,7 +197,7 @@ export class MessageSyncService implements IMessageSyncService {
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[MessageSyncService] 获取会话详情失败:', error);
|
||||
errorHandler(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -217,84 +224,39 @@ export class MessageSyncService implements IMessageSyncService {
|
||||
|
||||
/**
|
||||
* 获取会话消息(增量同步)— 核心实现
|
||||
*
|
||||
* 【hydration 管线,修复「进入聊天页只显示一条消息、历史被吞」】
|
||||
*
|
||||
* 未 hydrated 的会话走完整管线:
|
||||
* 1. 本地 DB 连续区间补全(方案 A:不信内存零散消息,始终读本地)
|
||||
* 2. 上缺口增量同步(拉 maxSeq 之后的新消息)
|
||||
* 3. 下缺口完整回填(循环 loadMore 直到连续到顶,方案 B)
|
||||
* 4. 标记 hydrated(方案 C)
|
||||
* 已 hydrated 的会话只做轻量增量同步(baseline 此时可信)。
|
||||
*/
|
||||
private async _doFetchMessages(conversationId: string, afterSeq?: number): Promise<void> {
|
||||
const store = useMessageStore.getState();
|
||||
|
||||
store.setLoadingMessages(conversationId, true);
|
||||
|
||||
// 同步状态机:未 hydrated 的会话进入 hydrating 态(供 UI 展示)
|
||||
const wasHydratedAtStart = store.isHydrated(conversationId);
|
||||
if (!wasHydratedAtStart) {
|
||||
store.setConversationSyncState(conversationId, 'hydrating');
|
||||
}
|
||||
|
||||
try {
|
||||
if (!afterSeq) {
|
||||
// 先从本地数据库加载(使用 merge 避免 WS 消息丢失)
|
||||
const existingMessagesAtStart = store.getMessages(conversationId);
|
||||
if (existingMessagesAtStart.length === 0) {
|
||||
try {
|
||||
const localMessages = await messageRepository.getByConversation(conversationId, 20);
|
||||
|
||||
if (localMessages.length > 0) {
|
||||
const formattedMessages: MessageResponse[] = localMessages.map(m => ({
|
||||
id: m.id,
|
||||
conversation_id: m.conversationId,
|
||||
sender_id: m.senderId,
|
||||
seq: m.seq,
|
||||
segments: m.segments || [],
|
||||
status: m.status as any,
|
||||
created_at: m.createdAt,
|
||||
}));
|
||||
|
||||
// 原子性合并本地缓存消息,防止并发 WS 消息丢失
|
||||
store.mergeMessages(conversationId, formattedMessages);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[MessageSyncService] 读取本地消息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 baseline:合并本地DB后重新计算
|
||||
const currentMessages = store.getMessages(conversationId);
|
||||
const baselineMaxSeq = currentMessages.reduce((max, m) => Math.max(max, m.seq || 0), 0);
|
||||
|
||||
// 策略:有本地数据时仅增量同步,无数据时拉快照
|
||||
if (baselineMaxSeq > 0) {
|
||||
// 增量同步:只拉 baselineMaxSeq 之后的消息
|
||||
try {
|
||||
const incrementalResp = await messageService.getMessages(conversationId, baselineMaxSeq);
|
||||
const newMessages = incrementalResp?.messages || [];
|
||||
|
||||
if (newMessages.length > 0) {
|
||||
store.mergeMessages(conversationId, newMessages);
|
||||
|
||||
messageRepository.saveMessagesBatch(
|
||||
MessageMapper.toCachedMessages(newMessages, conversationId)
|
||||
).catch(error => {
|
||||
console.error('[MessageSyncService] 保存增量消息到本地失败:', error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MessageSyncService] 增量同步失败:', error);
|
||||
const wasHydrated = store.isHydrated(conversationId);
|
||||
if (wasHydrated) {
|
||||
// 已完整加载过:baseline 可信,仅做轻量增量同步
|
||||
await this.incrementalSyncAbove(conversationId);
|
||||
} else {
|
||||
// 完整 hydration 管线(成功后内部会 markHydrated 并设 syncState='synced')
|
||||
await this.runHydrationPipeline(conversationId);
|
||||
}
|
||||
} else {
|
||||
// 冷启动:本地无数据,拉快照
|
||||
try {
|
||||
const snapshotResp = await messageService.getMessages(conversationId, undefined, undefined, 50);
|
||||
const snapshotMessages = snapshotResp?.messages || [];
|
||||
|
||||
if (snapshotMessages.length > 0) {
|
||||
// 快照来自分页接口,后端返回 DESC 顺序;mergeMessages 内部按 seq ASC 排序并合并
|
||||
store.mergeMessages(conversationId, snapshotMessages);
|
||||
|
||||
messageRepository.saveMessagesBatch(
|
||||
MessageMapper.toCachedMessages(snapshotMessages, conversationId)
|
||||
).catch(error => {
|
||||
console.error('[MessageSyncService] 保存快照消息到本地失败:', error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MessageSyncService] 快照同步失败:', error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 指定了 afterSeq
|
||||
// 指定了 afterSeq(焦点/重连增量同步):保持原行为
|
||||
const response = await messageService.getMessages(conversationId, afterSeq);
|
||||
|
||||
if (response?.messages && response.messages.length > 0) {
|
||||
@@ -304,12 +266,16 @@ export class MessageSyncService implements IMessageSyncService {
|
||||
messageRepository.saveMessagesBatch(
|
||||
MessageMapper.toCachedMessages(newMessages, conversationId)
|
||||
).catch(error => {
|
||||
console.error('[MessageSyncService] 保存消息到本地失败:', error);
|
||||
errorHandler(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MessageSyncService] 获取消息失败:', error);
|
||||
errorHandler(error);
|
||||
// hydration 失败:标记 error 态(供 UI 展示错误/重试)
|
||||
if (!wasHydratedAtStart) {
|
||||
useMessageStore.getState().setConversationSyncState(conversationId, 'error');
|
||||
}
|
||||
} finally {
|
||||
// 异步填充用户信息(不阻塞消息显示)
|
||||
// 使用 merge 而非覆盖,防止异步期间新到的 WS 消息丢失
|
||||
@@ -335,6 +301,191 @@ export class MessageSyncService implements IMessageSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量增量同步:以当前内存最大 seq 为 baseline,只拉取更新的消息。
|
||||
* 仅对已 hydrated 的会话调用——此时 baseline 是可信的(已通过完整管线加载)。
|
||||
*/
|
||||
private async incrementalSyncAbove(conversationId: string): Promise<void> {
|
||||
const store = useMessageStore.getState();
|
||||
const currentMessages = store.getMessages(conversationId);
|
||||
const baselineMaxSeq = currentMessages.reduce((max, m) => Math.max(max, m.seq || 0), 0);
|
||||
|
||||
if (baselineMaxSeq <= 0) {
|
||||
// 已 hydrated 但内存空(如清空后未清 hydrated 的边界):退回快照兜底
|
||||
await this.fetchSnapshot(conversationId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const incrementalResp = await messageService.getMessages(conversationId, baselineMaxSeq);
|
||||
const newMessages = incrementalResp?.messages || [];
|
||||
if (newMessages.length > 0) {
|
||||
store.mergeMessages(conversationId, newMessages);
|
||||
messageRepository.saveMessagesBatch(
|
||||
MessageMapper.toCachedMessages(newMessages, conversationId)
|
||||
).catch(error => {
|
||||
errorHandler(error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
errorHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整 hydration 管线(方案 A + B + C 的核心)。
|
||||
*
|
||||
* 流程:
|
||||
* Step 1(A):无条件读本地 DB 补全内存基线(修复「内存非空就跳过本地」的根因)
|
||||
* Step 2:上缺口增量同步(拉本地 maxSeq 之后的新消息)
|
||||
* Step 3(B):下缺口完整回填循环(向前 loadMore 直到连续到顶或触达上限)
|
||||
* Step 4(C):标记 hydrated
|
||||
*/
|
||||
private async runHydrationPipeline(conversationId: string): Promise<void> {
|
||||
const store = useMessageStore.getState();
|
||||
|
||||
// ---- Step 1(方案 A):本地 DB 连续区间补全 ----
|
||||
// 关键修复:移除「内存非空就跳过本地」的错误前提。WS 全局 ingest 会让内存非空,
|
||||
// 但那只是零散最新消息,不代表历史完整。始终用本地 DB 补全基线。
|
||||
try {
|
||||
const localMessages = await messageRepository.getByConversation(conversationId, MESSAGES_PAGE_SIZE);
|
||||
if (localMessages.length > 0) {
|
||||
const formattedMessages: MessageResponse[] = localMessages.map(m => ({
|
||||
id: m.id,
|
||||
conversation_id: m.conversationId,
|
||||
sender_id: m.senderId,
|
||||
seq: m.seq,
|
||||
segments: m.segments || [],
|
||||
status: m.status as any,
|
||||
created_at: m.createdAt,
|
||||
}));
|
||||
// mergeMessages 按 id 去重 + seq 升序,与内存现有 WS 消息安全合并
|
||||
useMessageStore.getState().mergeMessages(conversationId, formattedMessages);
|
||||
}
|
||||
} catch (error) {
|
||||
errorHandler(error);
|
||||
}
|
||||
|
||||
// ---- Step 2:上缺口增量同步(baselineMaxSeq 之后的新消息)----
|
||||
const messagesAfterLocal = useMessageStore.getState().getMessages(conversationId);
|
||||
const baselineMaxSeq = messagesAfterLocal.reduce((max, m) => Math.max(max, m.seq || 0), 0);
|
||||
|
||||
if (baselineMaxSeq > 0) {
|
||||
try {
|
||||
const incrementalResp = await messageService.getMessages(conversationId, baselineMaxSeq);
|
||||
const newMessages = incrementalResp?.messages || [];
|
||||
if (newMessages.length > 0) {
|
||||
useMessageStore.getState().mergeMessages(conversationId, newMessages);
|
||||
messageRepository.saveMessagesBatch(
|
||||
MessageMapper.toCachedMessages(newMessages, conversationId)
|
||||
).catch(error => {
|
||||
errorHandler(error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
errorHandler(error);
|
||||
}
|
||||
} else {
|
||||
// 本地与内存均空:冷启动快照兜底
|
||||
await this.fetchSnapshot(conversationId);
|
||||
}
|
||||
|
||||
// ---- Step 3(方案 B):下缺口完整回填循环 ----
|
||||
// 从最新端往前检查连续性,发现缺口就 loadMore 回填,直到连续到顶(minSeq<=1)
|
||||
// 或触达 MAX_GAP_FILL_ROUNDS 上限(防极端卡死,剩余靠用户上滑兜底)。
|
||||
await this.fillBackwardGaps(conversationId);
|
||||
|
||||
// ---- Step 4(方案 C):标记 hydrated ----
|
||||
// 无论回填是否完全到顶都标记,避免反复重试卡首屏;用户上滑仍可触发 loadMoreHistory。
|
||||
const finalStore = useMessageStore.getState();
|
||||
finalStore.markHydrated(conversationId);
|
||||
// 同步状态机:hydration 成功完成 → synced
|
||||
finalStore.setConversationSyncState(conversationId, 'synced');
|
||||
}
|
||||
|
||||
/**
|
||||
* 冷启动快照:本地与内存均无数据时,拉取最近一页快照。
|
||||
*/
|
||||
private async fetchSnapshot(conversationId: string): Promise<void> {
|
||||
try {
|
||||
const snapshotResp = await messageService.getMessages(conversationId, undefined, undefined, 50);
|
||||
const snapshotMessages = snapshotResp?.messages || [];
|
||||
if (snapshotMessages.length > 0) {
|
||||
// 快照来自分页接口,后端返回 DESC 顺序;mergeMessages 内部按 seq ASC 排序并合并
|
||||
useMessageStore.getState().mergeMessages(conversationId, snapshotMessages);
|
||||
messageRepository.saveMessagesBatch(
|
||||
MessageMapper.toCachedMessages(snapshotMessages, conversationId)
|
||||
).catch(error => {
|
||||
errorHandler(error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
errorHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下缺口回填循环(方案 B)。
|
||||
*
|
||||
* 从当前内存消息的最新端向前找第一个连续缺口,loadMore 一页补上,循环直到:
|
||||
* - 连续到顶(contiguousMinSeq <= 1),或
|
||||
* - 触达 MAX_GAP_FILL_ROUNDS 上限,或
|
||||
* - 某轮回填无新增(已到顶或网络空响应),防死循环。
|
||||
*
|
||||
* 连续区间在「内存消息数组」上计算(回填每轮都 merge 进内存,比每轮查 DB 更高效)。
|
||||
*/
|
||||
private async fillBackwardGaps(conversationId: string): Promise<void> {
|
||||
for (let round = 0; round < MAX_GAP_FILL_ROUNDS; round++) {
|
||||
const currentMessages = useMessageStore.getState().getMessages(conversationId);
|
||||
if (currentMessages.length === 0) return;
|
||||
|
||||
const contiguousMinSeq = this.computeContiguousMinSeq(currentMessages);
|
||||
// 连续到顶(minSeq <= 1 表示已到最早消息),无需回填
|
||||
if (contiguousMinSeq <= 1) return;
|
||||
|
||||
const beforeCount = currentMessages.length;
|
||||
try {
|
||||
// 复用现有 loadMoreMessages:本地不足则请求服务端,内部已 merge + 落库
|
||||
await this.loadMoreMessages(conversationId, contiguousMinSeq, MESSAGES_PAGE_SIZE);
|
||||
} catch (error) {
|
||||
errorHandler(error);
|
||||
return; // 本轮失败,停止回填(已加载部分正常展示)
|
||||
}
|
||||
|
||||
// 本轮无新增消息:已到顶或服务端无更多,停止回填
|
||||
const afterCount = useMessageStore.getState().getMessages(conversationId).length;
|
||||
if (afterCount <= beforeCount) return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算内存消息数组从最大 seq 往下的连续区间下沿(contiguousMinSeq)。
|
||||
* 遇到第一个缺口即停。返回值 <= 1 表示已连续到顶。
|
||||
*
|
||||
* 注意:seq<=0 的消息(发送中/失败的临时消息)不参与历史连续性判断,
|
||||
* 它们不代表真实的历史序号,过滤掉避免干扰回填决策。
|
||||
*/
|
||||
private computeContiguousMinSeq(messages: MessageResponse[]): number {
|
||||
if (messages.length === 0) return 0;
|
||||
// 按 seq 降序遍历找连续段,过滤掉 seq<=0 的临时消息
|
||||
const sorted = messages
|
||||
.filter(m => (m.seq || 0) > 0)
|
||||
.sort((a, b) => b.seq - a.seq);
|
||||
if (sorted.length === 0) return 0;
|
||||
|
||||
let expected = sorted[0].seq;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
if (sorted[i].seq === expected - 1) {
|
||||
expected = sorted[i].seq;
|
||||
} else if (sorted[i].seq < expected - 1) {
|
||||
// 遇到缺口
|
||||
break;
|
||||
}
|
||||
// sorted[i].seq === expected(重复,幂等已去重,理论不出现)→ 跳过
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载更多历史消息
|
||||
*/
|
||||
@@ -378,7 +529,7 @@ export class MessageSyncService implements IMessageSyncService {
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('[MessageSyncService] 加载更多消息失败:', error);
|
||||
errorHandler(error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -427,7 +578,7 @@ export class MessageSyncService implements IMessageSyncService {
|
||||
// 服务端汇总未读为 0:不主动清零本地未读,等 fetchConversations 提供权威数据
|
||||
// 避免后端缓存竞态导致的未读数抖动
|
||||
} catch (error) {
|
||||
console.error('[MessageSyncService] 获取未读数失败:', error);
|
||||
errorHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,78 +653,67 @@ export class MessageSyncService implements IMessageSyncService {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[MessageSyncService] syncBySeq 失败:', error);
|
||||
errorHandler(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于版本号的增量同步 — 去重入口
|
||||
* 基于版本号的精确增量同步(对标 Matrix resume 游标)。
|
||||
*
|
||||
* 用持久化的 syncVersion 调用 getSyncByVersion,服务端返回该版本之后的会话变更,
|
||||
* 按需 fetchConversationDetail/fetchMessages 补齐,比 syncBySeq 的全量轮询更精确。
|
||||
*
|
||||
* @returns true=成功并推进游标;false=需退化到 syncBySeq 或全量(full_sync 或异常)
|
||||
*/
|
||||
async syncByVersion(): Promise<boolean> {
|
||||
if (this.inflightSyncByVersion) return this.inflightSyncByVersion;
|
||||
this.inflightSyncByVersion = this._doSyncByVersion();
|
||||
async syncByVersion(version: number): Promise<boolean> {
|
||||
if (!version || version <= 0) return false;
|
||||
|
||||
try {
|
||||
return await this.inflightSyncByVersion;
|
||||
} finally {
|
||||
this.inflightSyncByVersion = null;
|
||||
}
|
||||
const resp = await messageService.getSyncByVersion(version);
|
||||
if (!resp) return false;
|
||||
|
||||
// 服务端要求全量同步(版本太旧或游标失效):退化
|
||||
if (resp.full_sync) {
|
||||
console.warn('[MessageSyncService] syncByVersion 服务端要求全量同步,退化');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于版本号的增量同步 — 核心实现
|
||||
*/
|
||||
private async _doSyncByVersion(): Promise<boolean> {
|
||||
try {
|
||||
const changes = resp.changes || [];
|
||||
const store = useMessageStore.getState();
|
||||
const version = store.syncVersion ?? 0;
|
||||
|
||||
const result = await messageService.getSyncByVersion(version, 100);
|
||||
if (!result) return false;
|
||||
// 按 change_type 处理每个会话的变更
|
||||
for (const change of changes) {
|
||||
try {
|
||||
const conv = store.getConversation(change.conversation_id);
|
||||
const localMaxSeq = conv?.last_seq || 0;
|
||||
|
||||
// full_sync 表示需要全量刷新
|
||||
if (result.full_sync) {
|
||||
return false;
|
||||
// 会话有新消息(change_type 含 message/seq 变更,或本地落后于服务端 max_seq)
|
||||
if (change.max_seq && change.max_seq > localMaxSeq) {
|
||||
await this.fetchConversationDetail(change.conversation_id);
|
||||
// 活动会话拉取新增消息
|
||||
if (store.getActiveConversation() === change.conversation_id) {
|
||||
await this.fetchMessages(change.conversation_id, localMaxSeq);
|
||||
}
|
||||
|
||||
if (result.changes.length === 0) {
|
||||
// 保存当前版本号,下次增量同步时使用
|
||||
useMessageStore.getState().setSyncVersion(result.current_version);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 按变更类型处理
|
||||
const staleIds: string[] = [];
|
||||
for (const change of result.changes) {
|
||||
const normId = normalizeConversationId(change.conversation_id);
|
||||
if (change.change_type === 'hide') {
|
||||
// 用户隐藏了会话,从本地移除
|
||||
store.removeConversation(normId);
|
||||
} else {
|
||||
// new_msg, read, pin, unpin, mute, unmute, group_update
|
||||
staleIds.push(normId);
|
||||
} catch (e) {
|
||||
// 单个会话变更处理失败不阻断整体(其余会话仍可推进游标)
|
||||
errorHandler(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新有变更的会话
|
||||
if (staleIds.length > 0) {
|
||||
const detailPromises = staleIds.map(id =>
|
||||
this.fetchConversationDetail(id).catch(() => {})
|
||||
);
|
||||
await Promise.all(detailPromises);
|
||||
// 推进持久化游标(has_more 表示还有更多变更,下次继续)
|
||||
const nextVersion = resp.current_version;
|
||||
if (nextVersion && nextVersion > version) {
|
||||
store.setSyncVersion(nextVersion);
|
||||
}
|
||||
|
||||
// 保存版本号
|
||||
useMessageStore.getState().setSyncVersion(result.current_version);
|
||||
|
||||
// has_more 时继续拉取
|
||||
if (result.has_more) {
|
||||
return this.syncByVersion();
|
||||
}
|
||||
// 刷新未读数(变更可能影响未读)
|
||||
await this.fetchUnreadCount();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[MessageSyncService] syncByVersion 失败:', error);
|
||||
errorHandler(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -636,7 +776,7 @@ export class MessageSyncService implements IMessageSyncService {
|
||||
.filter((group): group is NonNullable<ConversationResponse['group']> => Boolean(group));
|
||||
|
||||
conversationRepository.saveWithRelated(list, users, groups).catch(error => {
|
||||
console.error('[MessageSyncService] 持久化会话列表失败:', error);
|
||||
errorHandler(error);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ import { messageRepository, conversationRepository } from '@/database';
|
||||
import type { ReadStateRecord, IReadReceiptManager } from '../types';
|
||||
import { READ_STATE_PROTECTION_DELAY } from '../constants';
|
||||
import { useMessageStore, normalizeConversationId } from '../store';
|
||||
import { createErrorHandler } from '@/services/core';
|
||||
|
||||
const errorHandler = createErrorHandler('ReadReceiptManager');
|
||||
|
||||
export class ReadReceiptManager implements IReadReceiptManager {
|
||||
/**
|
||||
@@ -75,13 +78,13 @@ export class ReadReceiptManager implements IReadReceiptManager {
|
||||
store.updateConversationWithUnread(updatedConv, newTotalUnread, currentUnread.system);
|
||||
|
||||
// 3. 更新本地数据库
|
||||
messageRepository.markConversationAsRead(normalizedId).catch(console.error);
|
||||
messageRepository.markConversationAsRead(normalizedId).catch((e: unknown) => errorHandler(e));
|
||||
|
||||
// 4. 调用 API,完成后设置延迟清除保护
|
||||
try {
|
||||
await messageService.markAsRead(normalizedId, seq);
|
||||
} catch (error) {
|
||||
console.error('[ReadReceiptManager] 标记已读API失败:', error);
|
||||
errorHandler(error);
|
||||
|
||||
// 条件回滚(含 totalUnreadCount):仅在 my_last_read_seq 仍是本次写入值时回滚,
|
||||
// 避免覆盖并发 markAsRead 已写入的更大读游标或更小未读
|
||||
@@ -160,7 +163,7 @@ export class ReadReceiptManager implements IReadReceiptManager {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ReadReceiptManager] 标记所有已读失败:', error);
|
||||
errorHandler(error);
|
||||
// 条件回滚:仅在 conversations 的关键字段未被其他操作修改时还原,
|
||||
// 避免覆盖期间到达的 markAsRead / WS 推送造成的新未读
|
||||
const current = useMessageStore.getState().conversations;
|
||||
@@ -180,69 +183,6 @@ export class ReadReceiptManager implements IReadReceiptManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始已读操作,返回版本号
|
||||
*/
|
||||
beginReadOperation(conversationId: string, seq: number): number {
|
||||
this.readStateVersion++;
|
||||
const currentVersion = this.readStateVersion;
|
||||
|
||||
this.pendingReadMap.set(conversationId, {
|
||||
timestamp: Date.now(),
|
||||
version: currentVersion,
|
||||
lastReadSeq: seq,
|
||||
});
|
||||
|
||||
return currentVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束已读操作
|
||||
*/
|
||||
endReadOperation(conversationId: string, version: number, success: boolean): void {
|
||||
if (!success) {
|
||||
this.pendingReadMap.delete(conversationId);
|
||||
return;
|
||||
}
|
||||
|
||||
// API 成功后,设置延迟清除保护
|
||||
const clearTimer = setTimeout(() => {
|
||||
const record = this.pendingReadMap.get(conversationId);
|
||||
if (record && record.version === version) {
|
||||
this.pendingReadMap.delete(conversationId);
|
||||
}
|
||||
}, READ_STATE_PROTECTION_DELAY);
|
||||
|
||||
const existingRecord = this.pendingReadMap.get(conversationId);
|
||||
if (existingRecord) {
|
||||
this.pendingReadMap.set(conversationId, {
|
||||
...existingRecord,
|
||||
clearTimer,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有进行中的已读操作
|
||||
*/
|
||||
isReadOperationPending(conversationId: string): boolean {
|
||||
return this.pendingReadMap.has(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前已读状态版本号
|
||||
*/
|
||||
getReadStateVersion(): number {
|
||||
return this.readStateVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待处理的已读映射
|
||||
*/
|
||||
getPendingReadMap(): Map<string, ReadStateRecord> {
|
||||
return this.pendingReadMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据已读保护状态智能合并会话
|
||||
*/
|
||||
|
||||
608
src/stores/message/services/RealtimeIngestionPipeline.ts
Normal file
608
src/stores/message/services/RealtimeIngestionPipeline.ts
Normal file
@@ -0,0 +1,608 @@
|
||||
/**
|
||||
* RealtimeIngestionPipeline — 实时消息单一入口与幂等处理
|
||||
*
|
||||
* 设计目标(对标 Matrix/Element 的 idempotent event ingestion 与 Discord 的 gateway
|
||||
* event deduplication):
|
||||
*
|
||||
* 1. **幂等键下沉到 store**:不再在上层维护独立的「已处理消息 id」集合。
|
||||
* 去重的唯一真理源是 `useMessageStore.addOrReplaceMessage`,它在 zustand set 的
|
||||
* 原子边界内判断消息是否已存在。落库与去重原子绑定,**永不丢消息**
|
||||
* (最坏情况是重复 ingest,被 store 静默去重)。
|
||||
*
|
||||
* 2. **副作用与状态变更分离**:未读递增、震动、本地持久化、自动已读、会话更新都是
|
||||
* 「副作用」,只有在消息**确认新入库后**(`addOrReplaceMessage` 返回 true)才执行。
|
||||
* 任何副作用失败都不回滚已落库的消息——彻底消除旧实现「标记早于落库」导致的
|
||||
* 永久丢消息竞态。
|
||||
*
|
||||
* 3. **显式监听器生命周期**:所有 `wsService.on/onConnect/onDisconnect` 返回的
|
||||
* unsubscribe 函数被收集到 `unsubscribers`,`start()` 先彻底注销旧监听器再注册,
|
||||
* `stop()` 一并释放——杜绝重复注册与重登录后的监听器泄漏。
|
||||
*
|
||||
* 该类取代旧的 `WSMessageHandler` + `MessageDeduplication`。
|
||||
*/
|
||||
|
||||
import type {
|
||||
WSChatMessage,
|
||||
WSGroupChatMessage,
|
||||
WSReadMessage,
|
||||
WSGroupReadMessage,
|
||||
WSRecallMessage,
|
||||
WSGroupRecallMessage,
|
||||
WSGroupTypingMessage,
|
||||
WSGroupNoticeMessage,
|
||||
WSNotificationMessage,
|
||||
} from '@/services/core';
|
||||
import { wsService, GroupNoticeType } from '@/services/core';
|
||||
import { eventBus } from '@/core/events/EventBus';
|
||||
import { messageRepository } from '@/database';
|
||||
import type { MessageResponse } from '../../../types/dto';
|
||||
import {
|
||||
MAX_FLUSH_ITERATIONS,
|
||||
MIN_RECONNECT_SYNC_INTERVAL,
|
||||
} from '../constants';
|
||||
import { useMessageStore, normalizeConversationId } from '../store';
|
||||
import type { IUserCacheService } from '../types';
|
||||
import { createErrorHandler } from '@/services/core';
|
||||
|
||||
const errorHandler = createErrorHandler('RealtimeIngestionPipeline');
|
||||
|
||||
/** 实时事件统一缓冲类型(bootstrap 阶段暂存,初始化完成后回放) */
|
||||
type BufferedEvent =
|
||||
| { kind: 'chat'; message: WSChatMessage | WSGroupChatMessage }
|
||||
| { kind: 'read'; message: WSReadMessage | WSGroupReadMessage };
|
||||
|
||||
/** ingest 时可抑制的副作用开关 */
|
||||
export interface IngestOptions {
|
||||
suppressUnreadIncrement?: boolean;
|
||||
suppressVibration?: boolean;
|
||||
suppressConversationUpdates?: boolean;
|
||||
suppressAutoMarkAsRead?: boolean;
|
||||
}
|
||||
|
||||
/** Pipeline 对外依赖(由 MessageManager 注入) */
|
||||
export interface RealtimeIngestionPipelineDeps {
|
||||
userCacheService: IUserCacheService;
|
||||
getCurrentUserId: () => string | null;
|
||||
/** 活动会话收到新消息时回调(用于自动标记已读) */
|
||||
onActiveConvMessage: (conversationId: string, seq: number) => Promise<void>;
|
||||
/** 重连 / sync_required 时触发的增量同步入口 */
|
||||
triggerSync: (source: string) => Promise<void>;
|
||||
/** 权威刷新会话列表入口(flush 阶段调用) */
|
||||
fetchConversations: (forceRefresh: boolean, source: string) => Promise<void>;
|
||||
/** 权威刷新未读数入口(flush 阶段调用) */
|
||||
fetchUnreadCount: () => Promise<void>;
|
||||
}
|
||||
|
||||
export class RealtimeIngestionPipeline {
|
||||
private deps: RealtimeIngestionPipelineDeps;
|
||||
/** 所有 wsService 监听器的注销函数;start() 先清空再注册,stop() 全部释放 */
|
||||
private unsubscribers: Array<() => void> = [];
|
||||
/** bootstrap 阶段是否缓冲事件(初始化完成前为 true) */
|
||||
private isBootstrapping = false;
|
||||
/** bootstrap 期间缓冲的实时事件 */
|
||||
private bufferedEvents: BufferedEvent[] = [];
|
||||
|
||||
/** 同步触发器状态:防止 onConnect 和 sync_required 并发 */
|
||||
private syncInProgress = false;
|
||||
private lastSyncTriggerAt = 0;
|
||||
|
||||
/** 系统通知去重(防止重连时重复递增系统未读) */
|
||||
private processedNotificationIds: Set<string> = new Set();
|
||||
|
||||
constructor(deps: RealtimeIngestionPipelineDeps) {
|
||||
this.deps = deps;
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
/**
|
||||
* 注册所有 wsService 监听器。重复调用安全:先注销旧监听器再注册新的。
|
||||
*/
|
||||
start(): void {
|
||||
this.stop();
|
||||
|
||||
const push = <T>(unsub: () => void) => this.unsubscribers.push(unsub);
|
||||
|
||||
// 聊天消息:bootstrap 期间缓冲,否则直接 ingest
|
||||
push(
|
||||
wsService.on('chat', (message: WSChatMessage) => {
|
||||
if (!this.isReady()) {
|
||||
this.bufferedEvents.push({ kind: 'chat', message });
|
||||
return;
|
||||
}
|
||||
this.ingestChatMessage(message).catch(error => {
|
||||
errorHandler(error);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
push(
|
||||
wsService.on('group_message', (message: WSGroupChatMessage) => {
|
||||
if (!this.isReady()) {
|
||||
this.bufferedEvents.push({ kind: 'chat', message });
|
||||
return;
|
||||
}
|
||||
this.ingestChatMessage(message).catch(error => {
|
||||
errorHandler(error);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// 已读回执:bootstrap 期间缓冲
|
||||
push(
|
||||
wsService.on('read', (message: WSReadMessage) => {
|
||||
if (!this.isReady()) {
|
||||
this.bufferedEvents.push({ kind: 'read', message });
|
||||
return;
|
||||
}
|
||||
this.handleReadReceipt(message);
|
||||
})
|
||||
);
|
||||
|
||||
push(
|
||||
wsService.on('group_read', (message: WSGroupReadMessage) => {
|
||||
if (!this.isReady()) {
|
||||
this.bufferedEvents.push({ kind: 'read', message });
|
||||
return;
|
||||
}
|
||||
this.handleGroupReadReceipt(message);
|
||||
})
|
||||
);
|
||||
|
||||
// 撤回、输入状态、群通知:直接处理(这些操作幂等或无副作用丢失风险)
|
||||
push(
|
||||
wsService.on('recall', (message: WSRecallMessage) => this.handleRecallMessage(message))
|
||||
);
|
||||
push(
|
||||
wsService.on('group_recall', (message: WSGroupRecallMessage) => this.handleGroupRecallMessage(message))
|
||||
);
|
||||
push(
|
||||
wsService.on('group_typing', (message: WSGroupTypingMessage) => this.handleGroupTyping(message))
|
||||
);
|
||||
push(
|
||||
wsService.on('group_notice', (message: WSGroupNoticeMessage) => this.handleGroupNotice(message))
|
||||
);
|
||||
|
||||
// 系统通知:去重后递增系统未读
|
||||
push(
|
||||
wsService.on('notification', (message: WSNotificationMessage) => this.handleNotification(message))
|
||||
);
|
||||
|
||||
// 连接状态
|
||||
push(
|
||||
wsService.onConnect(() => {
|
||||
useMessageStore.getState().setSSEConnected(true);
|
||||
this.triggerSync('sse-reconnect').catch(error => {
|
||||
errorHandler(error);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
push(
|
||||
wsService.on('sync_required', () => {
|
||||
this.triggerSync('sync_required').catch(error => {
|
||||
errorHandler(error);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
push(
|
||||
wsService.onDisconnect(() => {
|
||||
useMessageStore.getState().setSSEConnected(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销所有监听器并清空缓冲。重复调用安全。
|
||||
*/
|
||||
stop(): void {
|
||||
for (const unsub of this.unsubscribers) {
|
||||
try {
|
||||
unsub();
|
||||
} catch (error) {
|
||||
errorHandler(error);
|
||||
}
|
||||
}
|
||||
this.unsubscribers = [];
|
||||
}
|
||||
|
||||
/** 设置 bootstrap 标志 */
|
||||
setBootstrapping(value: boolean): void {
|
||||
this.isBootstrapping = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化完成后,回放缓冲的实时事件。
|
||||
* 每轮取走当前 buffer 后立即清空(flush 期间新到事件走正常路径),直到稳定。
|
||||
*/
|
||||
async flushBufferedEvents(): Promise<void> {
|
||||
let lastLen = -1;
|
||||
for (let i = 0; i < MAX_FLUSH_ITERATIONS; i++) {
|
||||
const events = this.bufferedEvents;
|
||||
if (events.length === lastLen) {
|
||||
return;
|
||||
}
|
||||
lastLen = events.length;
|
||||
this.bufferedEvents = [];
|
||||
|
||||
for (const event of events) {
|
||||
if (event.kind === 'chat') {
|
||||
await this.ingestChatMessage(event.message, {
|
||||
suppressUnreadIncrement: true,
|
||||
suppressVibration: true,
|
||||
suppressConversationUpdates: true,
|
||||
suppressAutoMarkAsRead: true,
|
||||
});
|
||||
} else if (event.message.type === 'read') {
|
||||
this.handleReadReceipt(event.message as WSReadMessage);
|
||||
} else {
|
||||
this.handleGroupReadReceipt(event.message as WSGroupReadMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用服务器权威数据刷新会话与未读
|
||||
await this.deps.fetchConversations(true, 'sse-buffer-flush');
|
||||
await this.deps.fetchUnreadCount();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 核心:聊天消息幂等 ingest ====================
|
||||
|
||||
/**
|
||||
* 幂等 ingest 一条聊天消息(私聊 chat / 群聊 group_message)。
|
||||
*
|
||||
* 流程(关键:先落库,落库成功后才发副作用):
|
||||
* 1. 构造 MessageResponse
|
||||
* 2. 通过 store.addOrReplaceMessage 原子写入并取得「是否新写入」标志
|
||||
* 3. 始终发送 ACK(服务器侧幂等;ACK 失败不影响消息)
|
||||
* 4. 仅当 inserted === true 时触发副作用:会话更新 / 未读递增 / 震动 /
|
||||
* 自动已读 / 本地持久化 / 群聊 sender 补全
|
||||
*/
|
||||
async ingestChatMessage(
|
||||
message: (WSChatMessage | WSGroupChatMessage) & { _isAck?: boolean },
|
||||
options?: IngestOptions
|
||||
): Promise<void> {
|
||||
const store = useMessageStore.getState();
|
||||
const { conversation_id, id, sender_id, seq, segments, created_at, _isAck } = message;
|
||||
const normalizedConversationId = normalizeConversationId(conversation_id);
|
||||
const currentUserId = this.deps.getCurrentUserId();
|
||||
|
||||
const suppressUnreadIncrement = options?.suppressUnreadIncrement ?? false;
|
||||
const suppressVibration = options?.suppressVibration ?? false;
|
||||
const suppressConversationUpdates = options?.suppressConversationUpdates ?? false;
|
||||
const suppressAutoMarkAsRead = options?.suppressAutoMarkAsRead ?? false;
|
||||
|
||||
// 1. 构造消息对象
|
||||
const newMessage: MessageResponse = {
|
||||
id,
|
||||
conversation_id: normalizedConversationId,
|
||||
sender_id,
|
||||
seq: typeof seq === 'string' ? parseInt(seq, 10) : (seq ?? 0),
|
||||
segments: segments || [],
|
||||
created_at,
|
||||
status: 'normal',
|
||||
};
|
||||
|
||||
// 2. 原子幂等写入;inserted === true 才是真正的新消息
|
||||
const inserted = useMessageStore.getState().addOrReplaceMessage(normalizedConversationId, newMessage);
|
||||
|
||||
// 3. 始终 ACK(即使重复,服务器侧幂等;ACK 失败不回滚消息)
|
||||
wsService.sendAck(id);
|
||||
|
||||
// 4. 幂等:已存在的消息跳过所有副作用
|
||||
if (!inserted) return;
|
||||
|
||||
// —— 以下副作用仅在消息新入库后执行,任一失败都不回滚消息 ——
|
||||
|
||||
if (!suppressConversationUpdates) {
|
||||
this.applyConversationUpdate(normalizedConversationId, newMessage, created_at);
|
||||
}
|
||||
|
||||
// 重新读取 store 以获得最新的活动会话状态(避免闭包陈旧)
|
||||
const latestStore = useMessageStore.getState();
|
||||
const isActiveConversation = normalizedConversationId === latestStore.getActiveConversation();
|
||||
const isCurrentUserMessage = sender_id === currentUserId;
|
||||
|
||||
const shouldIncrementUnread =
|
||||
!suppressUnreadIncrement &&
|
||||
!isCurrentUserMessage &&
|
||||
!isActiveConversation &&
|
||||
!!currentUserId &&
|
||||
!_isAck;
|
||||
|
||||
if (shouldIncrementUnread) {
|
||||
const isNotificationMuted = latestStore.isNotificationMuted(normalizedConversationId);
|
||||
if (!suppressVibration && !isNotificationMuted) {
|
||||
const vibrationType = message.type === 'group_message' ? 'group_message' : 'message';
|
||||
eventBus.emit({ type: 'VIBRATE', payload: { type: vibrationType } });
|
||||
}
|
||||
this.incrementUnreadCount(normalizedConversationId);
|
||||
}
|
||||
|
||||
// 活动会话且非本人消息:自动标记已读
|
||||
if (isActiveConversation && !isCurrentUserMessage && !suppressAutoMarkAsRead) {
|
||||
this.deps.onActiveConvMessage(normalizedConversationId, seq).catch(error => {
|
||||
errorHandler(error);
|
||||
});
|
||||
}
|
||||
|
||||
// 群聊消息:异步补 sender 信息(patchMessages 自身幂等,不影响去重)
|
||||
if (
|
||||
message.type === 'group_message' &&
|
||||
sender_id &&
|
||||
sender_id !== currentUserId &&
|
||||
sender_id !== '10000'
|
||||
) {
|
||||
this.deps.userCacheService
|
||||
.getSenderInfo(sender_id)
|
||||
.then(user => {
|
||||
if (!user) return;
|
||||
const patches = new Map<string, Partial<MessageResponse>>();
|
||||
patches.set(String(id), { sender: user });
|
||||
useMessageStore.getState().patchMessages(normalizedConversationId, patches);
|
||||
})
|
||||
.catch(error => {
|
||||
errorHandler(error);
|
||||
});
|
||||
}
|
||||
|
||||
// 异步保存到本地数据库(fire-and-forget)
|
||||
const textContent =
|
||||
segments?.filter((s: any) => s.type === 'text').map((s: any) => s.data?.text || '').join('') || '';
|
||||
messageRepository
|
||||
.saveMessage({
|
||||
id,
|
||||
conversationId: normalizedConversationId,
|
||||
senderId: sender_id || '',
|
||||
content: textContent,
|
||||
type: segments?.find((s: any) => s.type === 'image') ? 'image' : 'text',
|
||||
isRead: isActiveConversation,
|
||||
createdAt: (() => {
|
||||
const d = new Date(created_at);
|
||||
return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();
|
||||
})(),
|
||||
seq,
|
||||
status: 'normal',
|
||||
segments,
|
||||
})
|
||||
.catch(error => {
|
||||
errorHandler(error);
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 其他事件处理 ====================
|
||||
|
||||
/** 私聊已读回执(当前为占位,保留扩展点) */
|
||||
handleReadReceipt(_message: WSReadMessage): void {
|
||||
// 预留:可在此更新对方已读状态
|
||||
}
|
||||
|
||||
/** 群聊已读回执(当前为占位,保留扩展点) */
|
||||
handleGroupReadReceipt(_message: WSGroupReadMessage): void {
|
||||
// 预留:可在此更新成员已读状态
|
||||
}
|
||||
|
||||
/** 私聊消息撤回 */
|
||||
handleRecallMessage(message: WSRecallMessage): void {
|
||||
const { conversation_id, message_id } = message;
|
||||
const normalizedConversationId = normalizeConversationId(conversation_id);
|
||||
|
||||
this.markMessageAsRecalled(normalizedConversationId, message_id);
|
||||
this.syncConversationLastMessageOnRecall(normalizedConversationId, message_id);
|
||||
|
||||
messageRepository.updateStatus(message_id, 'recalled', true).catch(error => {
|
||||
errorHandler(error);
|
||||
});
|
||||
}
|
||||
|
||||
/** 群聊消息撤回 */
|
||||
handleGroupRecallMessage(message: WSGroupRecallMessage): void {
|
||||
const { conversation_id, message_id } = message;
|
||||
const normalizedConversationId = normalizeConversationId(conversation_id);
|
||||
|
||||
this.markMessageAsRecalled(normalizedConversationId, message_id);
|
||||
this.syncConversationLastMessageOnRecall(normalizedConversationId, message_id);
|
||||
|
||||
messageRepository.updateStatus(message_id, 'recalled', true).catch(error => {
|
||||
errorHandler(error);
|
||||
});
|
||||
}
|
||||
|
||||
/** 群聊输入状态 */
|
||||
handleGroupTyping(message: WSGroupTypingMessage): void {
|
||||
const store = useMessageStore.getState();
|
||||
const { group_id, user_id, is_typing } = message;
|
||||
const groupIdStr = String(group_id);
|
||||
|
||||
const currentTypingUsers = store.getTypingUsers(groupIdStr);
|
||||
let updatedTypingUsers: string[];
|
||||
|
||||
if (is_typing) {
|
||||
updatedTypingUsers = currentTypingUsers.includes(user_id)
|
||||
? currentTypingUsers
|
||||
: [...currentTypingUsers, user_id];
|
||||
} else {
|
||||
updatedTypingUsers = currentTypingUsers.filter(id => id !== user_id);
|
||||
}
|
||||
|
||||
if (updatedTypingUsers.length !== currentTypingUsers.length) {
|
||||
store.setTypingUsers(groupIdStr, updatedTypingUsers);
|
||||
}
|
||||
}
|
||||
|
||||
/** 群通知(禁言/系统消息等) */
|
||||
handleGroupNotice(message: WSGroupNoticeMessage): void {
|
||||
const store = useMessageStore.getState();
|
||||
const { notice_type, group_id, data, timestamp, message_id, seq } = message;
|
||||
const groupIdStr = String(group_id);
|
||||
|
||||
// 处理禁言/解除禁言(仅本人相关)
|
||||
if (notice_type === 'muted' || notice_type === 'unmuted') {
|
||||
const mutedUserId = data?.user_id;
|
||||
const currentUserId = this.deps.getCurrentUserId();
|
||||
if (mutedUserId && mutedUserId === currentUserId) {
|
||||
store.setMutedStatus(groupIdStr, notice_type === 'muted');
|
||||
}
|
||||
}
|
||||
|
||||
// 补一条系统消息到会话消息流(幂等:addOrReplaceMessage 内部去重)
|
||||
if (message_id && typeof seq === 'number' && seq > 0) {
|
||||
const conversationId = this.findConversationIdByGroupId(groupIdStr);
|
||||
if (conversationId) {
|
||||
const noticeText = this.buildGroupNoticeText(notice_type, data);
|
||||
const systemNoticeMessage: MessageResponse = {
|
||||
id: String(message_id),
|
||||
conversation_id: conversationId,
|
||||
sender_id: '10000',
|
||||
seq,
|
||||
segments: noticeText
|
||||
? [{ type: 'text', data: { text: noticeText } as any }]
|
||||
: [],
|
||||
status: 'normal',
|
||||
category: 'notification',
|
||||
created_at: (() => {
|
||||
const d = new Date(timestamp || Date.now());
|
||||
return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();
|
||||
})(),
|
||||
};
|
||||
useMessageStore.getState().addOrReplaceMessage(conversationId, systemNoticeMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 系统通知:去重后递增系统未读 */
|
||||
handleNotification(message: WSNotificationMessage): void {
|
||||
if (message.is_read) return;
|
||||
|
||||
// 按通知 ID 去重,防止重连时重复递增
|
||||
const notificationId = (message as any).id || (message as any).message_id;
|
||||
if (notificationId) {
|
||||
const idStr = String(notificationId);
|
||||
if (this.processedNotificationIds.has(idStr)) return;
|
||||
this.processedNotificationIds.add(idStr);
|
||||
// 防止内存泄漏:超过 500 条淘汰最旧 100 条
|
||||
if (this.processedNotificationIds.size > 500) {
|
||||
const firstIds = Array.from(this.processedNotificationIds).slice(0, 100);
|
||||
firstIds.forEach(id => this.processedNotificationIds.delete(id));
|
||||
}
|
||||
}
|
||||
|
||||
useMessageStore.getState().incrementSystemUnreadAtomic();
|
||||
|
||||
if (message.created_at) {
|
||||
const store = useMessageStore.getState();
|
||||
const currentLast = store.lastSystemMessageAt;
|
||||
const msgDate = new Date(message.created_at);
|
||||
if (!Number.isNaN(msgDate.getTime()) && (!currentLast || msgDate > new Date(currentLast))) {
|
||||
store.setLastSystemMessageAt(message.created_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 同步触发器 ====================
|
||||
|
||||
/**
|
||||
* 统一同步入口:节流 + 去重,onConnect 与 sync_required 共用。
|
||||
* bootstrap 期间跳过(由 flushBufferedEvents 统一回放)。
|
||||
*/
|
||||
private async triggerSync(source: string): Promise<void> {
|
||||
if (this.isBootstrapping) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - this.lastSyncTriggerAt < MIN_RECONNECT_SYNC_INTERVAL) return;
|
||||
if (this.syncInProgress) return;
|
||||
|
||||
this.syncInProgress = true;
|
||||
this.lastSyncTriggerAt = now;
|
||||
|
||||
try {
|
||||
await this.deps.triggerSync(source);
|
||||
} catch (error) {
|
||||
errorHandler(error);
|
||||
} finally {
|
||||
this.syncInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 私有工具方法 ====================
|
||||
|
||||
/** store 已初始化且非 bootstrap 阶段时才直接处理事件 */
|
||||
private isReady(): boolean {
|
||||
return useMessageStore.getState().isInitialized && !this.isBootstrapping;
|
||||
}
|
||||
|
||||
private applyConversationUpdate(
|
||||
conversationId: string,
|
||||
message: MessageResponse,
|
||||
createdAt: string
|
||||
): void {
|
||||
const store = useMessageStore.getState();
|
||||
const conversation = store.getConversation(conversationId);
|
||||
if (!conversation) return;
|
||||
|
||||
const updatedConv = {
|
||||
...conversation,
|
||||
last_seq: message.seq,
|
||||
last_message: message,
|
||||
last_message_at: createdAt,
|
||||
updated_at: createdAt,
|
||||
};
|
||||
store.updateConversation(updatedConv);
|
||||
}
|
||||
|
||||
private incrementUnreadCount(conversationId: string): void {
|
||||
useMessageStore.getState().incrementUnreadAtomic(conversationId);
|
||||
}
|
||||
|
||||
private markMessageAsRecalled(conversationId: string, messageId: string): void {
|
||||
const patches = new Map<string, Partial<MessageResponse>>();
|
||||
patches.set(String(messageId), {
|
||||
status: 'recalled' as MessageResponse['status'],
|
||||
segments: [],
|
||||
});
|
||||
useMessageStore.getState().patchMessages(conversationId, patches);
|
||||
}
|
||||
|
||||
private syncConversationLastMessageOnRecall(conversationId: string, messageId: string): void {
|
||||
const store = useMessageStore.getState();
|
||||
const conversation = store.getConversation(conversationId);
|
||||
if (!conversation?.last_message) return;
|
||||
if (String(conversation.last_message.id) !== String(messageId)) return;
|
||||
if (conversation.last_message.status === 'recalled') return;
|
||||
|
||||
const updatedConversation = {
|
||||
...conversation,
|
||||
last_message: { ...conversation.last_message, status: 'recalled' as const },
|
||||
};
|
||||
store.updateConversation(updatedConversation);
|
||||
}
|
||||
|
||||
private findConversationIdByGroupId(groupId: string): string | null {
|
||||
const conversations = useMessageStore.getState().getConversations();
|
||||
for (const conv of conversations) {
|
||||
if (String(conv.group?.id || '') === groupId) {
|
||||
return conv.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildGroupNoticeText(noticeType: GroupNoticeType, data: any): string {
|
||||
const username = data?.username || '用户';
|
||||
switch (noticeType) {
|
||||
case 'member_join':
|
||||
return `"${username}" 加入了群聊`;
|
||||
case 'member_leave':
|
||||
return `"${username}" 退出了群聊`;
|
||||
case 'member_removed':
|
||||
return `"${username}" 被移出群聊`;
|
||||
case 'muted':
|
||||
return `"${username}" 已被管理员禁言`;
|
||||
case 'unmuted':
|
||||
return `"${username}" 已被管理员解除禁言`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import { api } from '@/services/core';
|
||||
import { userCacheRepository } from '@/database';
|
||||
import { useMessageStore } from '../store';
|
||||
import type { IUserCacheService } from '../types';
|
||||
import { createErrorHandler } from '@/services/core';
|
||||
|
||||
const errorHandler = createErrorHandler('UserCacheService');
|
||||
|
||||
export class UserCacheService implements IUserCacheService {
|
||||
// 正在获取用户信息的请求映射(用于去重)
|
||||
@@ -55,7 +58,7 @@ export class UserCacheService implements IUserCacheService {
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`[UserCacheService] 获取用户信息失败: ${userId}`, error);
|
||||
errorHandler(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -109,7 +112,7 @@ export class UserCacheService implements IUserCacheService {
|
||||
notifyUpdate(conversationId, updated);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[UserCacheService] 批量获取 sender 信息失败:', error);
|
||||
errorHandler(error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,6 +124,3 @@ export class UserCacheService implements IUserCacheService {
|
||||
this.pendingUserRequests.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// 单例导出
|
||||
export const userCacheService = new UserCacheService();
|
||||
@@ -1,597 +0,0 @@
|
||||
/**
|
||||
* WebSocket 消息处理器
|
||||
* 处理所有来自 WebSocket 的消息事件
|
||||
*/
|
||||
|
||||
import type {
|
||||
WSChatMessage,
|
||||
WSGroupChatMessage,
|
||||
WSReadMessage,
|
||||
WSGroupReadMessage,
|
||||
WSRecallMessage,
|
||||
WSGroupRecallMessage,
|
||||
WSGroupTypingMessage,
|
||||
WSGroupNoticeMessage,
|
||||
WSNotificationMessage,
|
||||
} from '@/services/core';
|
||||
import { wsService, GroupNoticeType } from '@/services/core';
|
||||
import { eventBus } from '@/core/events/EventBus';
|
||||
import { messageRepository } from '@/database';
|
||||
import type { MessageResponse, ConversationResponse } from '../../../types/dto';
|
||||
import type {
|
||||
IWSMessageHandler,
|
||||
IMessageDeduplication,
|
||||
IUserCacheService,
|
||||
HandleNewMessageOptions,
|
||||
} from '../types';
|
||||
import { MAX_FLUSH_ITERATIONS, MIN_RECONNECT_SYNC_INTERVAL } from '../constants';
|
||||
import { useMessageStore, normalizeConversationId } from '../store';
|
||||
|
||||
export class WSMessageHandler implements IWSMessageHandler {
|
||||
private deduplication: IMessageDeduplication;
|
||||
private userCacheService: IUserCacheService;
|
||||
private getCurrentUserId: () => string | null;
|
||||
private markAsReadCallback: (conversationId: string, seq: number) => Promise<void>;
|
||||
private fetchConversationsCallback: (forceRefresh: boolean, source: string) => Promise<void>;
|
||||
private fetchUnreadCountCallback: () => Promise<void>;
|
||||
private fetchMessagesCallback: (conversationId: string) => Promise<void>;
|
||||
private syncBySeqCallback: () => Promise<boolean>;
|
||||
|
||||
private sseUnsubscribe: (() => void) | null = null;
|
||||
private isBootstrapping: boolean = false;
|
||||
private lastReconnectSyncAt: number = 0;
|
||||
|
||||
// 同步触发器状态:防止 onConnect 和 sync_required 并发
|
||||
private syncInProgress: boolean = false;
|
||||
private lastSyncTriggerAt: number = 0;
|
||||
|
||||
// 系统通知去重(防止重连时重复递增系统未读)
|
||||
private processedNotificationIds: Set<string> = new Set();
|
||||
|
||||
// 初始化阶段缓冲来自 SSE 的消息事件
|
||||
private bufferedChatMessages: Array<(WSChatMessage | WSGroupChatMessage) & { _isAck?: boolean }> = [];
|
||||
private bufferedReadReceipts: Array<WSReadMessage | WSGroupReadMessage> = [];
|
||||
|
||||
constructor(
|
||||
deduplication: IMessageDeduplication,
|
||||
userCacheService: IUserCacheService,
|
||||
getCurrentUserId: () => string | null,
|
||||
callbacks: {
|
||||
markAsRead: (conversationId: string, seq: number) => Promise<void>;
|
||||
fetchConversations: (forceRefresh: boolean, source: string) => Promise<void>;
|
||||
fetchUnreadCount: () => Promise<void>;
|
||||
fetchMessages: (conversationId: string) => Promise<void>;
|
||||
syncBySeq: () => Promise<boolean>;
|
||||
}
|
||||
) {
|
||||
this.deduplication = deduplication;
|
||||
this.userCacheService = userCacheService;
|
||||
this.getCurrentUserId = getCurrentUserId;
|
||||
this.markAsReadCallback = callbacks.markAsRead;
|
||||
this.fetchConversationsCallback = callbacks.fetchConversations;
|
||||
this.fetchUnreadCountCallback = callbacks.fetchUnreadCount;
|
||||
this.fetchMessagesCallback = callbacks.fetchMessages;
|
||||
this.syncBySeqCallback = callbacks.syncBySeq;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 SSE 监听器
|
||||
*/
|
||||
connect(): void {
|
||||
if (this.sseUnsubscribe) {
|
||||
this.sseUnsubscribe();
|
||||
}
|
||||
|
||||
wsService.on('chat', (message: WSChatMessage) => {
|
||||
if (!useMessageStore.getState().isInitialized || this.isBootstrapping) {
|
||||
this.bufferedChatMessages.push(message);
|
||||
return;
|
||||
}
|
||||
this.handleNewMessage(message);
|
||||
});
|
||||
|
||||
wsService.on('group_message', (message: WSGroupChatMessage) => {
|
||||
if (!useMessageStore.getState().isInitialized || this.isBootstrapping) {
|
||||
this.bufferedChatMessages.push(message);
|
||||
return;
|
||||
}
|
||||
this.handleNewMessage(message);
|
||||
});
|
||||
|
||||
wsService.on('read', (message: WSReadMessage) => {
|
||||
if (!useMessageStore.getState().isInitialized || this.isBootstrapping) {
|
||||
this.bufferedReadReceipts.push(message);
|
||||
return;
|
||||
}
|
||||
this.handleReadReceipt(message);
|
||||
});
|
||||
|
||||
// 监听群聊已读回执
|
||||
wsService.on('group_read', (message: WSGroupReadMessage) => {
|
||||
if (!useMessageStore.getState().isInitialized || this.isBootstrapping) {
|
||||
this.bufferedReadReceipts.push(message);
|
||||
return;
|
||||
}
|
||||
this.handleGroupReadReceipt(message);
|
||||
});
|
||||
|
||||
// 监听私聊消息撤回
|
||||
wsService.on('recall', (message: WSRecallMessage) => {
|
||||
this.handleRecallMessage(message);
|
||||
});
|
||||
|
||||
// 监听群聊消息撤回
|
||||
wsService.on('group_recall', (message: WSGroupRecallMessage) => {
|
||||
this.handleGroupRecallMessage(message);
|
||||
});
|
||||
|
||||
// 监听群聊输入状态
|
||||
wsService.on('group_typing', (message: WSGroupTypingMessage) => {
|
||||
this.handleGroupTyping(message);
|
||||
});
|
||||
|
||||
// 监听群通知
|
||||
wsService.on('group_notice', (message: WSGroupNoticeMessage) => {
|
||||
this.handleGroupNotice(message);
|
||||
});
|
||||
|
||||
// 监听系统通知,实时更新系统未读数
|
||||
wsService.on('notification', (message: WSNotificationMessage) => {
|
||||
if (message.is_read) {
|
||||
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();
|
||||
if (message.created_at) {
|
||||
const store = useMessageStore.getState();
|
||||
const currentLast = store.lastSystemMessageAt;
|
||||
const msgDate = new Date(message.created_at);
|
||||
if (Number.isNaN(msgDate.getTime())) return;
|
||||
if (!currentLast || msgDate > new Date(currentLast)) {
|
||||
store.setLastSystemMessageAt(message.created_at);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 监听连接状态
|
||||
wsService.onConnect(() => {
|
||||
useMessageStore.getState().setSSEConnected(true);
|
||||
this.triggerSync('sse-reconnect');
|
||||
});
|
||||
|
||||
// 监听 sync_required 事件,触发增量同步
|
||||
wsService.on('sync_required', () => {
|
||||
console.log('[WSMessageHandler] 收到 sync_required,开始增量同步');
|
||||
this.triggerSync('sync_required');
|
||||
});
|
||||
|
||||
wsService.onDisconnect(() => {
|
||||
useMessageStore.getState().setSSEConnected(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开 SSE 连接
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.sseUnsubscribe) {
|
||||
this.sseUnsubscribe();
|
||||
this.sseUnsubscribe = null;
|
||||
}
|
||||
this.processedNotificationIds.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已连接
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return useMessageStore.getState().isConnected();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置启动状态
|
||||
*/
|
||||
setBootstrapping(value: boolean): void {
|
||||
this.isBootstrapping = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一同步入口 — 节流 + 去重
|
||||
* onConnect 和 sync_required 共用此入口,防止并发触发重复同步
|
||||
* 启动阶段(isBootstrapping=true)跳过,由 MessageManager.initialize 完成后统一触发
|
||||
*/
|
||||
private async triggerSync(source: string): Promise<void> {
|
||||
if (this.isBootstrapping) return;
|
||||
|
||||
const now = Date.now();
|
||||
// 最小间隔节流:避免连续 sync_required 事件短时间内重复触发全量同步
|
||||
if (now - this.lastSyncTriggerAt < MIN_RECONNECT_SYNC_INTERVAL) return;
|
||||
if (this.syncInProgress) return;
|
||||
|
||||
this.syncInProgress = true;
|
||||
this.lastSyncTriggerAt = now;
|
||||
|
||||
try {
|
||||
const ok = await this.syncBySeqCallback();
|
||||
if (!ok) {
|
||||
await this.fetchConversationsCallback(true, source);
|
||||
const activeConv = useMessageStore.getState().getActiveConversation();
|
||||
if (activeConv) {
|
||||
await this.fetchMessagesCallback(activeConv).catch(() => {});
|
||||
}
|
||||
}
|
||||
await this.fetchUnreadCountCallback().catch(() => {});
|
||||
} catch {
|
||||
await this.fetchConversationsCallback(true, source).catch(() => {});
|
||||
const activeConv = useMessageStore.getState().getActiveConversation();
|
||||
if (activeConv) {
|
||||
await this.fetchMessagesCallback(activeConv).catch(() => {});
|
||||
}
|
||||
await this.fetchUnreadCountCallback().catch(() => {});
|
||||
} finally {
|
||||
this.syncInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化完成后,处理缓冲的 SSE 事件
|
||||
* 每次循环:取走当前 buffer 后立即清空(避免在 fetch 期间新事件再被覆盖)
|
||||
* 退出条件:连续一轮没有新事件,确保 flush 期间到达的事件也被消费
|
||||
*/
|
||||
async flushBufferedSSEEvents(): Promise<void> {
|
||||
let lastChatLen = -1;
|
||||
let lastReadLen = -1;
|
||||
for (let i = 0; i < MAX_FLUSH_ITERATIONS; i++) {
|
||||
const chatEvents = this.bufferedChatMessages;
|
||||
const readEvents = this.bufferedReadReceipts;
|
||||
// 上一轮长度未变且都已清空,说明已稳定
|
||||
if (chatEvents.length === lastChatLen && readEvents.length === lastReadLen) {
|
||||
return;
|
||||
}
|
||||
lastChatLen = chatEvents.length;
|
||||
lastReadLen = readEvents.length;
|
||||
// 立即清空 buffer;flush 期间新到的事件将走正常处理路径(isBootstrapping 已是 false)
|
||||
this.bufferedChatMessages = [];
|
||||
this.bufferedReadReceipts = [];
|
||||
|
||||
// 处理消息
|
||||
for (const m of chatEvents) {
|
||||
await this.handleNewMessage(m, {
|
||||
suppressUnreadIncrement: true,
|
||||
suppressVibration: true,
|
||||
suppressConversationUpdates: true,
|
||||
suppressAutoMarkAsRead: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 处理已读回执
|
||||
for (const r of readEvents) {
|
||||
if (r.type === 'read') {
|
||||
this.handleReadReceipt(r);
|
||||
} else {
|
||||
this.handleGroupReadReceipt(r as WSGroupReadMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用服务器权威刷新
|
||||
await this.fetchConversationsCallback(true, 'sse-buffer-flush');
|
||||
await this.fetchUnreadCountCallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理新消息(SSE推送)
|
||||
*/
|
||||
async handleNewMessage(
|
||||
message: (WSChatMessage | WSGroupChatMessage) & { _isAck?: boolean },
|
||||
options?: HandleNewMessageOptions
|
||||
): Promise<void> {
|
||||
const store = useMessageStore.getState();
|
||||
const { conversation_id, id, sender_id, seq, segments, created_at, _isAck } = message;
|
||||
const normalizedConversationId = normalizeConversationId(conversation_id);
|
||||
const currentUserId = this.getCurrentUserId();
|
||||
const suppressUnreadIncrement = options?.suppressUnreadIncrement ?? false;
|
||||
const suppressVibration = options?.suppressVibration ?? false;
|
||||
const suppressConversationUpdates = options?.suppressConversationUpdates ?? false;
|
||||
const suppressAutoMarkAsRead = options?.suppressAutoMarkAsRead ?? false;
|
||||
|
||||
// 消息去重检查
|
||||
if (this.deduplication.isMessageProcessed(id)) {
|
||||
return;
|
||||
}
|
||||
this.deduplication.markMessageAsProcessed(id);
|
||||
|
||||
// 发送 ACK 确认
|
||||
wsService.sendAck(id);
|
||||
|
||||
// 对于群聊消息,获取发送者信息
|
||||
if (message.type === 'group_message' && sender_id && sender_id !== currentUserId && sender_id !== '10000') {
|
||||
this.userCacheService.getSenderInfo(sender_id).then(user => {
|
||||
if (user) {
|
||||
const patches = new Map<string, Partial<MessageResponse>>();
|
||||
patches.set(String(id), { sender: user });
|
||||
useMessageStore.getState().patchMessages(normalizedConversationId, patches);
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('[WSMessageHandler] 获取发送者信息失败:', { userId: sender_id, error });
|
||||
});
|
||||
}
|
||||
|
||||
// 构造消息对象
|
||||
const newMessage: MessageResponse = {
|
||||
id,
|
||||
conversation_id: normalizedConversationId,
|
||||
sender_id,
|
||||
seq: typeof seq === 'string' ? parseInt(seq, 10) : (seq ?? 0),
|
||||
segments: segments || [],
|
||||
created_at,
|
||||
status: 'normal',
|
||||
};
|
||||
|
||||
// 立即更新内存中的消息列表 — 使用原子性 mergeMessages 防止并发写入丢失
|
||||
const messageExists = useMessageStore.getState().getMessages(normalizedConversationId).some(m => String(m.id) === String(id));
|
||||
|
||||
if (!messageExists) {
|
||||
useMessageStore.getState().mergeMessages(normalizedConversationId, [newMessage]);
|
||||
}
|
||||
|
||||
// 更新会话信息
|
||||
if (!suppressConversationUpdates) {
|
||||
this.updateConversationWithNewMessage(normalizedConversationId, newMessage, created_at);
|
||||
}
|
||||
|
||||
// 更新未读数
|
||||
const isCurrentUserMessage = sender_id === currentUserId;
|
||||
const isActiveConversation = normalizedConversationId === store.getActiveConversation();
|
||||
|
||||
const shouldIncrementUnread =
|
||||
!suppressUnreadIncrement &&
|
||||
!isCurrentUserMessage &&
|
||||
!isActiveConversation &&
|
||||
!!currentUserId &&
|
||||
!_isAck;
|
||||
|
||||
if (shouldIncrementUnread) {
|
||||
const isNotificationMuted = store.isNotificationMuted(normalizedConversationId);
|
||||
if (!suppressVibration && !isNotificationMuted) {
|
||||
const vibrationType = message.type === 'group_message' ? 'group_message' : 'message';
|
||||
eventBus.emit({ type: 'VIBRATE', payload: { type: vibrationType } });
|
||||
}
|
||||
this.incrementUnreadCount(normalizedConversationId);
|
||||
}
|
||||
|
||||
// 如果是当前活动会话,自动标记已读
|
||||
if (isActiveConversation && !isCurrentUserMessage && !suppressAutoMarkAsRead) {
|
||||
this.markAsReadCallback(normalizedConversationId, seq).catch(() => {});
|
||||
}
|
||||
|
||||
// 异步保存到本地数据库
|
||||
const textContent = segments?.filter((s: any) => s.type === 'text').map((s: any) => s.data?.text || '').join('') || '';
|
||||
messageRepository.saveMessage({
|
||||
id,
|
||||
conversationId: normalizedConversationId,
|
||||
senderId: sender_id || '',
|
||||
content: textContent,
|
||||
type: segments?.find((s: any) => s.type === 'image') ? 'image' : 'text',
|
||||
isRead: isActiveConversation,
|
||||
createdAt: (() => { const d = new Date(created_at); return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString(); })(),
|
||||
seq,
|
||||
status: 'normal',
|
||||
segments,
|
||||
}).catch(error => {
|
||||
console.error('[WSMessageHandler] 保存消息到本地失败:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理私聊已读回执
|
||||
*/
|
||||
handleReadReceipt(message: WSReadMessage): void {
|
||||
// 可以在这里处理对方已读的状态更新
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理群聊已读回执
|
||||
*/
|
||||
handleGroupReadReceipt(message: WSGroupReadMessage): void {
|
||||
// 群聊已读回执处理
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理私聊消息撤回
|
||||
*/
|
||||
handleRecallMessage(message: WSRecallMessage): void {
|
||||
const { conversation_id, message_id } = message;
|
||||
const normalizedConversationId = normalizeConversationId(conversation_id);
|
||||
|
||||
this.markMessageAsRecalled(normalizedConversationId, message_id);
|
||||
this.syncConversationLastMessageOnRecall(normalizedConversationId, message_id);
|
||||
|
||||
messageRepository.updateStatus(message_id, 'recalled', true).catch(error => {
|
||||
console.error('[WSMessageHandler] 更新本地消息撤回状态失败:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理群聊消息撤回
|
||||
*/
|
||||
handleGroupRecallMessage(message: WSGroupRecallMessage): void {
|
||||
const { conversation_id, message_id } = message;
|
||||
const normalizedConversationId = normalizeConversationId(conversation_id);
|
||||
|
||||
this.markMessageAsRecalled(normalizedConversationId, message_id);
|
||||
this.syncConversationLastMessageOnRecall(normalizedConversationId, message_id);
|
||||
|
||||
messageRepository.updateStatus(message_id, 'recalled', true).catch(error => {
|
||||
console.error('[WSMessageHandler] 更新本地消息撤回状态失败:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理群聊输入状态
|
||||
*/
|
||||
handleGroupTyping(message: WSGroupTypingMessage): void {
|
||||
const store = useMessageStore.getState();
|
||||
const { group_id, user_id, is_typing } = message;
|
||||
const groupIdStr = String(group_id);
|
||||
|
||||
const currentTypingUsers = store.getTypingUsers(groupIdStr);
|
||||
let updatedTypingUsers: string[];
|
||||
|
||||
if (is_typing) {
|
||||
if (!currentTypingUsers.includes(user_id)) {
|
||||
updatedTypingUsers = [...currentTypingUsers, user_id];
|
||||
} else {
|
||||
updatedTypingUsers = currentTypingUsers;
|
||||
}
|
||||
} else {
|
||||
updatedTypingUsers = currentTypingUsers.filter(id => id !== user_id);
|
||||
}
|
||||
|
||||
if (updatedTypingUsers.length !== currentTypingUsers.length) {
|
||||
store.setTypingUsers(groupIdStr, updatedTypingUsers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理群通知
|
||||
*/
|
||||
handleGroupNotice(message: WSGroupNoticeMessage): void {
|
||||
const store = useMessageStore.getState();
|
||||
const { notice_type, group_id, data, timestamp, message_id, seq } = message;
|
||||
const groupIdStr = String(group_id);
|
||||
|
||||
// 处理禁言/解除禁言通知
|
||||
if (notice_type === 'muted' || notice_type === 'unmuted') {
|
||||
const mutedUserId = data?.user_id;
|
||||
const currentUserId = this.getCurrentUserId();
|
||||
|
||||
if (mutedUserId && mutedUserId === currentUserId) {
|
||||
const isMuted = notice_type === 'muted';
|
||||
store.setMutedStatus(groupIdStr, isMuted);
|
||||
}
|
||||
}
|
||||
|
||||
// 补一条系统消息到当前会话消息流(原子性 merge)
|
||||
if (message_id && typeof seq === 'number' && seq > 0) {
|
||||
const conversationId = this.findConversationIdByGroupId(groupIdStr);
|
||||
if (conversationId) {
|
||||
const messageExists = store.getMessages(conversationId).some(m => String(m.id) === String(message_id));
|
||||
if (!messageExists) {
|
||||
const noticeText = this.buildGroupNoticeText(notice_type, data);
|
||||
const systemNoticeMessage: MessageResponse = {
|
||||
id: String(message_id),
|
||||
conversation_id: conversationId,
|
||||
sender_id: '10000',
|
||||
seq,
|
||||
segments: noticeText
|
||||
? [{ type: 'text', data: { text: noticeText } as any }]
|
||||
: [],
|
||||
status: 'normal',
|
||||
category: 'notification',
|
||||
created_at: (() => { const d = new Date(timestamp || Date.now()); return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString(); })(),
|
||||
};
|
||||
|
||||
useMessageStore.getState().mergeMessages(conversationId, [systemNoticeMessage]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 私有工具方法 ====================
|
||||
|
||||
private updateConversationWithNewMessage(
|
||||
conversationId: string,
|
||||
message: MessageResponse,
|
||||
createdAt: string
|
||||
): void {
|
||||
const store = useMessageStore.getState();
|
||||
const conversation = store.getConversation(conversationId);
|
||||
|
||||
if (conversation) {
|
||||
const updatedConv: ConversationResponse = {
|
||||
...conversation,
|
||||
last_seq: message.seq,
|
||||
last_message: message,
|
||||
last_message_at: createdAt,
|
||||
updated_at: createdAt,
|
||||
};
|
||||
store.updateConversation(updatedConv);
|
||||
}
|
||||
}
|
||||
|
||||
private incrementUnreadCount(conversationId: string): void {
|
||||
useMessageStore.getState().incrementUnreadAtomic(conversationId);
|
||||
}
|
||||
|
||||
private incrementSystemUnread(): void {
|
||||
// 原子递增:避免外部 read-modify-write 造成丢计数
|
||||
useMessageStore.getState().incrementSystemUnreadAtomic();
|
||||
}
|
||||
|
||||
private markMessageAsRecalled(conversationId: string, messageId: string): void {
|
||||
const patches = new Map<string, Partial<MessageResponse>>();
|
||||
patches.set(String(messageId), { status: 'recalled' as MessageResponse['status'], segments: [] });
|
||||
useMessageStore.getState().patchMessages(conversationId, patches);
|
||||
}
|
||||
|
||||
private syncConversationLastMessageOnRecall(conversationId: string, messageId: string): void {
|
||||
const store = useMessageStore.getState();
|
||||
const conversation = store.getConversation(conversationId);
|
||||
if (!conversation?.last_message) return;
|
||||
if (String(conversation.last_message.id) !== String(messageId)) return;
|
||||
if (conversation.last_message.status === 'recalled') return;
|
||||
|
||||
const updatedConversation: ConversationResponse = {
|
||||
...conversation,
|
||||
last_message: {
|
||||
...conversation.last_message,
|
||||
status: 'recalled',
|
||||
},
|
||||
};
|
||||
store.updateConversation(updatedConversation);
|
||||
}
|
||||
|
||||
private findConversationIdByGroupId(groupId: string): string | null {
|
||||
const store = useMessageStore.getState();
|
||||
const conversations = store.getConversations();
|
||||
for (const conv of conversations) {
|
||||
if (String(conv.group?.id || '') === groupId) {
|
||||
return conv.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildGroupNoticeText(noticeType: GroupNoticeType, data: any): string {
|
||||
const username = data?.username || '用户';
|
||||
switch (noticeType) {
|
||||
case 'member_join':
|
||||
return `"${username}" 加入了群聊`;
|
||||
case 'member_leave':
|
||||
return `"${username}" 退出了群聊`;
|
||||
case 'member_removed':
|
||||
return `"${username}" 被移出群聊`;
|
||||
case 'muted':
|
||||
return `"${username}" 已被管理员禁言`;
|
||||
case 'unmuted':
|
||||
return `"${username}" 已被管理员解除禁言`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,15 @@
|
||||
* 消息服务层统一导出
|
||||
*/
|
||||
|
||||
// 消息去重服务
|
||||
export { MessageDeduplication, messageDeduplication } from './MessageDeduplication';
|
||||
// 实时消息入口(幂等 ingest + 监听器生命周期)
|
||||
export { RealtimeIngestionPipeline } from './RealtimeIngestionPipeline';
|
||||
export type {
|
||||
RealtimeIngestionPipelineDeps,
|
||||
IngestOptions,
|
||||
} from './RealtimeIngestionPipeline';
|
||||
|
||||
// 用户缓存服务
|
||||
export { UserCacheService, userCacheService } from './UserCacheService';
|
||||
export { UserCacheService } from './UserCacheService';
|
||||
|
||||
// 已读回执管理器
|
||||
export { ReadReceiptManager } from './ReadReceiptManager';
|
||||
@@ -19,6 +23,3 @@ export { MessageSendService } from './MessageSendService';
|
||||
|
||||
// 消息同步服务
|
||||
export { MessageSyncService } from './MessageSyncService';
|
||||
|
||||
// WebSocket 消息处理器
|
||||
export { WSMessageHandler } from './WSMessageHandler';
|
||||
@@ -5,6 +5,9 @@
|
||||
import { ConversationResponse } from '../../types/dto';
|
||||
import { messageService } from '@/services/message';
|
||||
import { conversationRepository } from '@/database';
|
||||
import { createErrorHandler } from '@/services/core';
|
||||
|
||||
const errorHandler = createErrorHandler('ConversationListSource');
|
||||
|
||||
export const CONVERSATION_LIST_PAGE_SIZE = 20;
|
||||
|
||||
@@ -97,7 +100,7 @@ export class SqliteConversationListPagedSource implements IConversationListPaged
|
||||
const items = await conversationRepository.getListCache();
|
||||
return { items, hasMore: false };
|
||||
} catch (e) {
|
||||
console.warn('[SqliteConversationListPagedSource] 读取会话列表缓存失败:', e);
|
||||
errorHandler(e);
|
||||
return { items: [], hasMore: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
} from '../../types/dto';
|
||||
|
||||
const LAST_SYSTEM_MESSAGE_AT_KEY = 'last_system_message_at';
|
||||
/** 同步游标持久化 key(WS resume / syncByVersion 用) */
|
||||
const SYNC_VERSION_KEY = 'sync_version';
|
||||
|
||||
// ==================== 状态接口 ====================
|
||||
|
||||
@@ -42,6 +44,30 @@ export interface MessageState {
|
||||
isLoadingConversations: boolean;
|
||||
loadingMessagesSet: Set<string>;
|
||||
|
||||
/**
|
||||
* 已完成完整 hydration(连续区间补全)的会话集合。
|
||||
*
|
||||
* 区别于 loadingMessagesSet(仅表示「正在拉取」),hydrated 标志语义是
|
||||
* 「该会话的消息历史已通过完整管线加载到本地连续,后续进入只需轻量增量同步」。
|
||||
* 用于修复「从消息栏进入聊天页只显示零散 WS 消息、历史被吞」的问题:
|
||||
* WS 全局 ingest 会让内存非空,但那不代表历史已连续加载,必须以 hydrated
|
||||
* 作为「是否需要执行完整 hydration 管线」的判据。
|
||||
*/
|
||||
hydratedConversations: Set<string>;
|
||||
|
||||
/**
|
||||
* 会话同步状态机(收敛 hydration/loading/error 的派生上层视图)。
|
||||
*
|
||||
* - 'idle' 初始 / 未激活
|
||||
* - 'hydrating' 正在执行完整 hydration 管线(对应 loadingMessagesSet && !hydrated)
|
||||
* - 'synced' 已完成完整 hydration(对应 hydrated)
|
||||
* - 'error' hydration 失败(原无此概念,新增供 UI 展示错误态/重试)
|
||||
*
|
||||
* 设计为「派生层」,不删除底层 loadingMessagesSet/hydratedConversations
|
||||
* (UI 仍订阅后者),由 hydration 管线同步维护,零回归风险。
|
||||
*/
|
||||
conversationSyncStateMap: Map<string, ConversationSyncState>;
|
||||
|
||||
// 初始化状态
|
||||
isInitialized: boolean;
|
||||
|
||||
@@ -85,18 +111,58 @@ export interface MessageActions {
|
||||
removeConversation: (conversationId: string) => void;
|
||||
setMessages: (conversationId: string, messages: MessageResponse[]) => void;
|
||||
mergeMessages: (conversationId: string, incoming: MessageResponse[]) => void;
|
||||
/**
|
||||
* 幂等写入单条消息:以 message id 为幂等键,在 zustand set 原子边界内去重。
|
||||
* 返回 true 表示这条消息是新写入的(store 之前没有同 id 的消息),
|
||||
* 返回 false 表示已存在(被静默去重,副作用调用方应据此跳过未读/通知等副作用)。
|
||||
*
|
||||
* 这是消息幂等性的唯一真理源:任何来源(WS 推送、REST 拉取、发送回显、
|
||||
* loadMore)都应通过 addOrReplaceMessage/mergeMessages 走 store,避免上层
|
||||
* 维护独立去重表带来的「标记早于落库」竞态。
|
||||
*/
|
||||
addOrReplaceMessage: (conversationId: string, message: MessageResponse) => boolean;
|
||||
removeMessage: (conversationId: string, messageId: string) => void;
|
||||
/**
|
||||
* 清空某会话的内存消息并清除 hydrated 标志。
|
||||
*
|
||||
* 用于「清空聊天记录」场景:调用方先 messageRepository.clearConversation 清 DB,
|
||||
* 再调本 action 同步内存状态,保证内存/DB/hydrated 三者一致。
|
||||
* 清除 hydrated 后,下次进入会话会重新走完整 hydration 管线(重新从服务端加载)。
|
||||
*/
|
||||
clearMessages: (conversationId: string) => void;
|
||||
patchMessages: (conversationId: string, patches: Map<string, Partial<MessageResponse>>) => void;
|
||||
setUnreadCount: (total: number, system: number) => void;
|
||||
/**
|
||||
* 原子递增系统未读数:避免外部 RMW 造成丢失
|
||||
*/
|
||||
incrementSystemUnreadAtomic: () => void;
|
||||
/**
|
||||
* 原子设置系统未读数:保持 totalUnreadCount 一致性(total = sum(各会话未读) + system),
|
||||
* 在 zustand set 回调内一次性完成读-改-写,避免外部 RMW 丢更新。
|
||||
*/
|
||||
setSystemUnreadAtomic: (count: number) => void;
|
||||
/**
|
||||
* 原子递减系统未读数(不低于 0):避免外部 RMW 造成丢失。
|
||||
*/
|
||||
decrementSystemUnreadAtomic: (count?: number) => void;
|
||||
setLastSystemMessageAt: (time: string | null) => void;
|
||||
setSSEConnected: (connected: boolean) => void;
|
||||
setCurrentConversation: (conversationId: string | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setLoadingMessages: (conversationId: string, loading: boolean) => void;
|
||||
/**
|
||||
* 是否已完成完整 hydration(连续区间补全)。
|
||||
* hydration 管线据此门控:未 hydrated 才执行完整的本地补全 + 缺口回填。
|
||||
*/
|
||||
isHydrated: (conversationId: string) => boolean;
|
||||
/** 标记某会话已完成完整 hydration(幂等,已存在则不变)。 */
|
||||
markHydrated: (conversationId: string) => void;
|
||||
/** 清除某会话的 hydrated 标志(删除会话/清空记录时调用,避免残留)。 */
|
||||
clearHydrated: (conversationId: string) => void;
|
||||
/** 设置某会话的同步状态(hydration 管线维护)。 */
|
||||
setConversationSyncState: (conversationId: string, state: ConversationSyncState) => void;
|
||||
/** 查询某会话的同步状态。 */
|
||||
getConversationSyncState: (conversationId: string) => ConversationSyncState;
|
||||
setTypingUsers: (groupId: string, users: string[]) => void;
|
||||
setMutedStatus: (groupId: string, isMuted: boolean) => void;
|
||||
setNotificationMuted: (conversationId: string, notificationMuted: boolean) => void;
|
||||
@@ -138,6 +204,8 @@ const initialState: MessageState = {
|
||||
currentConversationId: null,
|
||||
isLoadingConversations: false,
|
||||
loadingMessagesSet: new Set(),
|
||||
hydratedConversations: new Set(),
|
||||
conversationSyncStateMap: new Map(),
|
||||
isInitialized: false,
|
||||
typingUsersMap: new Map(),
|
||||
mutedStatusMap: new Map(),
|
||||
@@ -158,6 +226,11 @@ export function normalizeConversationId(conversationId: string | number | null |
|
||||
* 更新会话列表排序
|
||||
* 排序规则:置顶优先,再按最后消息时间排序
|
||||
*/
|
||||
/**
|
||||
* 会话同步状态机枚举。详见 store 的 conversationSyncStateMap 字段注释。
|
||||
*/
|
||||
export type ConversationSyncState = 'idle' | 'hydrating' | 'synced' | 'error';
|
||||
|
||||
function sortConversationList(conversations: Map<string, ConversationResponse>): ConversationResponse[] {
|
||||
return Array.from(conversations.values()).sort((a, b) => {
|
||||
const aPinned = a.is_pinned ? 1 : 0;
|
||||
@@ -207,6 +280,8 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
|
||||
currentConversationId: state.currentConversationId,
|
||||
isLoadingConversations: state.isLoadingConversations,
|
||||
loadingMessagesSet: state.loadingMessagesSet,
|
||||
hydratedConversations: state.hydratedConversations,
|
||||
conversationSyncStateMap: state.conversationSyncStateMap,
|
||||
isInitialized: state.isInitialized,
|
||||
typingUsersMap: state.typingUsersMap,
|
||||
mutedStatusMap: state.mutedStatusMap,
|
||||
@@ -320,6 +395,14 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
|
||||
const newLoadingMessagesSet = new Set(state.loadingMessagesSet);
|
||||
newLoadingMessagesSet.delete(normalizedId);
|
||||
|
||||
// 同步清除 hydrated 标志,避免会话重建后误判为「已完整加载」
|
||||
const newHydratedConversations = new Set(state.hydratedConversations);
|
||||
newHydratedConversations.delete(normalizedId);
|
||||
|
||||
// 同步重置同步状态机,会话删除后回到 idle
|
||||
const newSyncStateMap = new Map(state.conversationSyncStateMap);
|
||||
newSyncStateMap.delete(normalizedId);
|
||||
|
||||
const newTotalUnreadCount = Math.max(0, state.totalUnreadCount - removedUnread);
|
||||
|
||||
const newCurrentConversationId = state.currentConversationId === normalizedId
|
||||
@@ -335,6 +418,8 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
|
||||
totalUnreadCount: newTotalUnreadCount,
|
||||
currentConversationId: newCurrentConversationId,
|
||||
loadingMessagesSet: newLoadingMessagesSet,
|
||||
hydratedConversations: newHydratedConversations,
|
||||
conversationSyncStateMap: newSyncStateMap,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -364,6 +449,31 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 幂等写入单条消息,返回是否为新写入。
|
||||
* 去重发生在 zustand set 的原子边界内,调用方据此决定是否触发未读/通知等副作用。
|
||||
*/
|
||||
addOrReplaceMessage: (conversationId: string, message: MessageResponse): boolean => {
|
||||
const normalizedId = normalizeConversationId(conversationId);
|
||||
let inserted = false;
|
||||
set(state => {
|
||||
const existing = state.messagesMap.get(normalizedId);
|
||||
const targetId = String(message.id);
|
||||
// 已存在同 id:静默去重(幂等),不视为新增
|
||||
if (existing && existing.some(m => String(m.id) === targetId)) {
|
||||
inserted = false;
|
||||
return state;
|
||||
}
|
||||
inserted = true;
|
||||
const base = existing || [];
|
||||
const merged = mergeMessagesById(base, [message]);
|
||||
const newMessagesMap = new Map(state.messagesMap);
|
||||
newMessagesMap.set(normalizedId, merged);
|
||||
return { messagesMap: newMessagesMap };
|
||||
});
|
||||
return inserted;
|
||||
},
|
||||
|
||||
removeMessage: (conversationId: string, messageId: string) => {
|
||||
const normalizedId = normalizeConversationId(conversationId);
|
||||
set(state => {
|
||||
@@ -377,6 +487,29 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
clearMessages: (conversationId: string) => {
|
||||
const normalizedId = normalizeConversationId(conversationId);
|
||||
set(state => {
|
||||
const newMessagesMap = new Map(state.messagesMap);
|
||||
newMessagesMap.delete(normalizedId);
|
||||
// 同步清除 hydrated 标志,下次进入会重新走完整 hydration 管线
|
||||
const newHydratedConversations = new Set(state.hydratedConversations);
|
||||
newHydratedConversations.delete(normalizedId);
|
||||
// 同步清除 loading 锁,避免残留锁阻断后续 fetchMessages
|
||||
const newLoadingMessagesSet = new Set(state.loadingMessagesSet);
|
||||
newLoadingMessagesSet.delete(normalizedId);
|
||||
// 同步重置同步状态机,回到 idle
|
||||
const newSyncStateMap = new Map(state.conversationSyncStateMap);
|
||||
newSyncStateMap.delete(normalizedId);
|
||||
return {
|
||||
messagesMap: newMessagesMap,
|
||||
hydratedConversations: newHydratedConversations,
|
||||
loadingMessagesSet: newLoadingMessagesSet,
|
||||
conversationSyncStateMap: newSyncStateMap,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 原子性 patch:按消息 ID 更新指定字段(如 sender、status)
|
||||
* 不影响其他消息,不丢失并发写入
|
||||
@@ -414,6 +547,27 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
|
||||
}));
|
||||
},
|
||||
|
||||
setSystemUnreadAtomic: (count: number) => {
|
||||
set(state => {
|
||||
const delta = count - state.systemUnreadCount;
|
||||
return {
|
||||
systemUnreadCount: count,
|
||||
totalUnreadCount: Math.max(0, state.totalUnreadCount + delta),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
decrementSystemUnreadAtomic: (count = 1) => {
|
||||
set(state => {
|
||||
const newSystemCount = Math.max(0, state.systemUnreadCount - count);
|
||||
const actualDecrement = state.systemUnreadCount - newSystemCount;
|
||||
return {
|
||||
systemUnreadCount: newSystemCount,
|
||||
totalUnreadCount: Math.max(0, state.totalUnreadCount - actualDecrement),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setLastSystemMessageAt: (time: string | null) => {
|
||||
set({ lastSystemMessageAt: time });
|
||||
if (time) {
|
||||
@@ -448,6 +602,44 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
isHydrated: (conversationId: string) => {
|
||||
return get().hydratedConversations.has(normalizeConversationId(conversationId));
|
||||
},
|
||||
|
||||
markHydrated: (conversationId: string) => {
|
||||
const normalizedId = normalizeConversationId(conversationId);
|
||||
set(state => {
|
||||
if (state.hydratedConversations.has(normalizedId)) return state;
|
||||
const next = new Set(state.hydratedConversations);
|
||||
next.add(normalizedId);
|
||||
return { hydratedConversations: next };
|
||||
});
|
||||
},
|
||||
|
||||
clearHydrated: (conversationId: string) => {
|
||||
const normalizedId = normalizeConversationId(conversationId);
|
||||
set(state => {
|
||||
if (!state.hydratedConversations.has(normalizedId)) return state;
|
||||
const next = new Set(state.hydratedConversations);
|
||||
next.delete(normalizedId);
|
||||
return { hydratedConversations: next };
|
||||
});
|
||||
},
|
||||
|
||||
setConversationSyncState: (conversationId: string, syncState: ConversationSyncState) => {
|
||||
const normalizedId = normalizeConversationId(conversationId);
|
||||
set(state => {
|
||||
if (state.conversationSyncStateMap.get(normalizedId) === syncState) return state;
|
||||
const next = new Map(state.conversationSyncStateMap);
|
||||
next.set(normalizedId, syncState);
|
||||
return { conversationSyncStateMap: next };
|
||||
});
|
||||
},
|
||||
|
||||
getConversationSyncState: (conversationId: string) => {
|
||||
return get().conversationSyncStateMap.get(normalizeConversationId(conversationId)) || 'idle';
|
||||
},
|
||||
|
||||
setTypingUsers: (groupId: string, users: string[]) => {
|
||||
set(state => {
|
||||
const newTypingUsersMap = new Map(state.typingUsersMap);
|
||||
@@ -474,6 +666,8 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
|
||||
|
||||
setSyncVersion: (version: number) => {
|
||||
set({ syncVersion: version });
|
||||
// 持久化同步游标,App 重启后用于 syncByVersion 精确增量恢复(替代全量轮询)
|
||||
AsyncStorage.setItem(SYNC_VERSION_KEY, String(version)).catch(() => {});
|
||||
},
|
||||
|
||||
setInitialized: (initialized: boolean) => {
|
||||
@@ -565,11 +759,14 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
|
||||
conversations: new Map(),
|
||||
messagesMap: new Map(),
|
||||
loadingMessagesSet: new Set(),
|
||||
hydratedConversations: new Set(),
|
||||
conversationSyncStateMap: new Map(),
|
||||
typingUsersMap: new Map(),
|
||||
mutedStatusMap: new Map(),
|
||||
notificationMutedMap: new Map(),
|
||||
});
|
||||
AsyncStorage.removeItem(LAST_SYSTEM_MESSAGE_AT_KEY).catch(() => {});
|
||||
AsyncStorage.removeItem(SYNC_VERSION_KEY).catch(() => {});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -580,3 +777,19 @@ export async function loadPersistedLastSystemMessageAt(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 AsyncStorage 恢复持久化的同步游标。
|
||||
* App 启动时调用,供 syncByVersion 做精确增量恢复(替代全量 syncBySeq 轮询)。
|
||||
*/
|
||||
export async function loadPersistedSyncVersion(): Promise<number | null> {
|
||||
const raw = await AsyncStorage.getItem(SYNC_VERSION_KEY);
|
||||
if (raw) {
|
||||
const version = Number(raw);
|
||||
if (!Number.isNaN(version) && version > 0) {
|
||||
useMessageStore.setState({ syncVersion: version });
|
||||
return version;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,12 +22,6 @@ export interface HandleNewMessageOptions {
|
||||
suppressAutoMarkAsRead?: boolean;
|
||||
}
|
||||
|
||||
export interface IMessageDeduplication {
|
||||
isMessageProcessed(messageId: string): boolean;
|
||||
markMessageAsProcessed(messageId: string): void;
|
||||
cleanupProcessedMessageIds(): void;
|
||||
}
|
||||
|
||||
export interface IUserCacheService {
|
||||
getSenderInfo(userId: string): Promise<UserDTO | null>;
|
||||
enrichMessagesWithSenderInfo(
|
||||
@@ -40,11 +34,6 @@ export interface IUserCacheService {
|
||||
export interface IReadReceiptManager {
|
||||
markAsRead(conversationId: string, seq: number): Promise<void>;
|
||||
markAllAsRead(): Promise<void>;
|
||||
beginReadOperation(conversationId: string, seq: number): number;
|
||||
endReadOperation(conversationId: string, version: number, success: boolean): void;
|
||||
isReadOperationPending(conversationId: string): boolean;
|
||||
getReadStateVersion(): number;
|
||||
getPendingReadMap(): Map<string, ReadStateRecord>;
|
||||
}
|
||||
|
||||
export interface IConversationOperations {
|
||||
@@ -62,7 +51,11 @@ export interface IMessageSendService {
|
||||
sendMessage(
|
||||
conversationId: string,
|
||||
segments: any[],
|
||||
options?: { replyToId?: string }
|
||||
options?: { replyToId?: string; detailType?: 'private' | 'group' }
|
||||
): Promise<MessageResponse | null>;
|
||||
retrySendMessage(
|
||||
conversationId: string,
|
||||
failedMessage: MessageResponse
|
||||
): Promise<MessageResponse | null>;
|
||||
}
|
||||
|
||||
@@ -74,12 +67,11 @@ export interface IMessageSyncService {
|
||||
loadMoreMessages(conversationId: string, beforeSeq: number, limit?: number): Promise<MessageResponse[]>;
|
||||
fetchUnreadCount(): Promise<void>;
|
||||
syncBySeq(): Promise<boolean>;
|
||||
/**
|
||||
* 基于版本号的精确增量同步(优先于 syncBySeq,对标 Matrix resume 游标)。
|
||||
* @returns true=成功同步并推进游标;false=需退化到 syncBySeq 或全量
|
||||
*/
|
||||
syncByVersion(version: number): Promise<boolean>;
|
||||
canLoadMoreConversations(): boolean;
|
||||
restartSources(): void;
|
||||
}
|
||||
|
||||
export interface IWSMessageHandler {
|
||||
connect(): void;
|
||||
disconnect(): void;
|
||||
isConnected(): boolean;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@ import { createDedupe } from '../utils/requestDedupe';
|
||||
|
||||
const dedupe = createDedupe(() => useUserManagerStore);
|
||||
|
||||
// 将 fetchCurrentUserFromAPI 的判别结果转为 User | null。
|
||||
// 网络故障/认证失败时返回 null,由上层缓存兜底(避免空数据覆盖缓存)。
|
||||
// 注意:UserManager 只负责缓存,不触发登出,登出由 authStore 统一处理。
|
||||
async function fetchCurrentUserOrNull(): Promise<UserDTO | null> {
|
||||
const result = await authService.fetchCurrentUserFromAPI();
|
||||
return result.kind === 'user' ? result.user : null;
|
||||
}
|
||||
|
||||
// ==================== UserManager 类 ====================
|
||||
|
||||
class UserManager {
|
||||
@@ -41,11 +49,11 @@ class UserManager {
|
||||
}
|
||||
|
||||
return dedupe('users:current', async () => {
|
||||
const user = await authService.fetchCurrentUserFromAPI();
|
||||
const user = await fetchCurrentUserOrNull();
|
||||
// 仅在拿到真实用户时刷新缓存;网络/认证失败时保留旧缓存,避免误清空
|
||||
if (user) {
|
||||
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
|
||||
store.setCurrentUserEntry(newEntry);
|
||||
|
||||
if (user) {
|
||||
userCacheRepository.saveCurrent(user).catch(() => {});
|
||||
userCacheRepository.save(user).catch(() => {});
|
||||
}
|
||||
@@ -56,11 +64,10 @@ class UserManager {
|
||||
private refreshCurrentUserInBackground(): void {
|
||||
dedupe('users:current:bg', async () => {
|
||||
const store = useUserManagerStore.getState();
|
||||
const user = await authService.fetchCurrentUserFromAPI();
|
||||
const user = await fetchCurrentUserOrNull();
|
||||
if (user) {
|
||||
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
|
||||
store.setCurrentUserEntry(newEntry);
|
||||
|
||||
if (user) {
|
||||
userCacheRepository.saveCurrent(user).catch(() => {});
|
||||
userCacheRepository.save(user).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { GroupResponse } from './group';
|
||||
|
||||
export type ConversationType = 'private' | 'group';
|
||||
export type MessageContentType = 'text' | 'image' | 'video' | 'audio' | 'file';
|
||||
export type MessageStatus = 'normal' | 'recalled' | 'deleted';
|
||||
export type MessageStatus = 'normal' | 'recalled' | 'deleted' | 'pending' | 'failed';
|
||||
|
||||
// ==================== Segment Types ====================
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Pagination, Vote, Privacy & Other DTOs
|
||||
*/
|
||||
|
||||
import type { MessageSegment } from './message';
|
||||
|
||||
// ==================== Common ====================
|
||||
|
||||
export interface SuccessDTO {
|
||||
@@ -56,11 +58,15 @@ export interface VoteResultDTO {
|
||||
export interface CreateVotePostRequest {
|
||||
title: string;
|
||||
content?: string;
|
||||
// 长文模式(块编辑器)下传入的正文段:文本/图片/@ 等。与 images 二选一。
|
||||
segments?: MessageSegment[];
|
||||
images?: string[];
|
||||
vote_options: Array<{
|
||||
content: string;
|
||||
}>;
|
||||
channel_id?: string;
|
||||
// 客户端幂等键:同一 key 在服务端 TTL 内重复提交只创建一次,避免重复发帖
|
||||
client_request_id?: string;
|
||||
}
|
||||
|
||||
// ==================== Privacy & Account ====================
|
||||
|
||||
68
src/utils/idempotency.ts
Normal file
68
src/utils/idempotency.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 客户端幂等键工具
|
||||
*
|
||||
* 用于发帖、发评论等"创建型"请求,避免因重复点击 / 网络重试导致重复创建。
|
||||
* 客户端为每次"用户意图"生成一个唯一 ID(client_request_id),
|
||||
* 后端基于 userID + client_request_id 做幂等去重:
|
||||
* - 同一 key 在 TTL 内的重复请求直接返回首次创建的资源。
|
||||
*
|
||||
* 实现说明:
|
||||
* - React Native 的 `crypto` 全局由 @livekit/react-native 的 registerGlobals 注入
|
||||
* (见 src/polyfills.ts)。优先使用 crypto.randomUUID(),回退到基于
|
||||
* getRandomValues 的 v4 UUID,再回退到 timestamp+random 拼接。
|
||||
*/
|
||||
|
||||
const HEX_CHARS = '0123456789abcdef';
|
||||
|
||||
/**
|
||||
* 生成一个 RFC4122 v4 风格的 UUID 字符串。
|
||||
* 优先使用平台 crypto.randomUUID;不可用时手写 v4。
|
||||
*/
|
||||
export function generateUUID(): string {
|
||||
const g = globalThis as any;
|
||||
|
||||
// 1) 现代 RN / Web:crypto.randomUUID
|
||||
try {
|
||||
if (g?.crypto?.randomUUID) {
|
||||
const id = g.crypto.randomUUID();
|
||||
if (typeof id === 'string' && id.length > 0) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fallthrough
|
||||
}
|
||||
|
||||
// 2) crypto.getRandomValues:手写 v4
|
||||
try {
|
||||
if (g?.crypto?.getRandomValues) {
|
||||
const bytes = new Uint8Array(16);
|
||||
g.crypto.getRandomValues(bytes);
|
||||
// v4: version 0100, variant 10xx
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
let out = '';
|
||||
for (let i = 0; i < 16; i++) {
|
||||
out += HEX_CHARS[bytes[i] >> 4] + HEX_CHARS[bytes[i] & 0x0f];
|
||||
if (i === 3 || i === 5 || i === 7 || i === 9) {
|
||||
out += '-';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
} catch {
|
||||
// fallthrough
|
||||
}
|
||||
|
||||
// 3) 退化兜底:timestamp + Math.random(碰撞概率极低,仅用于无 crypto 的极端环境)
|
||||
const rnd = () => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0');
|
||||
const now = Date.now().toString(16).padStart(12, '0');
|
||||
return `${now.slice(0, 8)}-${now.slice(8, 12)}-${rnd()}-${rnd()}-${rnd()}${rnd()}${rnd()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成发帖请求的幂等键。每次发帖意图生成一次,重试时复用同一个。
|
||||
*/
|
||||
export function generateClientRequestId(): string {
|
||||
return generateUUID();
|
||||
}
|
||||
@@ -9,5 +9,6 @@
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "**/__tests__/**", "**/*.test.ts", "**/*.test.tsx", "jest.config.js"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user