16 Commits

Author SHA1 Message Date
lafay
b589f1f32b style(ui): apply QQ-style design to message screens
Some checks failed
Frontend CI / ota (android) (push) Successful in 3m19s
Frontend CI / ota (ios) (push) Successful in 2m34s
Frontend CI / build-and-push-web (push) Failing after 5m8s
Frontend CI / build-android-apk (push) Successful in 43m30s
Remove decorative MaterialCommunityIcons and simplify UI to text-based
lists across GroupInfoScreen, GroupMembersScreen, PrivateChatInfoScreen,
and CommentItem components
2026-07-08 01:47:05 +08:00
lafay
995e0664d5 feat(auth): implement user ban detection and optional auth path handling
All checks were successful
Frontend CI / ota (android) (push) Successful in 3m6s
Frontend CI / ota (ios) (push) Successful in 2m34s
Frontend CI / build-and-push-web (push) Successful in 6m19s
Frontend CI / build-android-apk (push) Successful in 45m8s
Add USER_BANNED (403) handling across auth flow:
- Extend FetchCurrentUserResult and RefreshResult with 'banned' type
- Detect USER_BANNED errorCode in authService and api client
- Clear token and show "账号已被封禁,请联系管理员" on ban detection
- Throw non-network refresh errors up to caller instead of silent clear

Add optional auth path support:
- Extract optional auth path prefixes to optionalAuthPaths.ts
- Handle 401 on optional auth GET endpoints by clearing expired token
  and retrying as guest (without triggering navigateToLogin)
- Export helpers for existing consumers

Handle backend behavior change:
- Guest logout now returns 401 instead of 200; treat as silent success

Add tests for optional auth path logic.
2026-07-05 20:18:35 +08:00
lafay
45df579b72 feat(messaging): implement three-tier reply message lazy loading with offline fallback
Some checks failed
Frontend CI / ota (android) (push) Successful in 2m56s
Frontend CI / ota (ios) (push) Failing after 2m23s
Frontend CI / build-and-push-web (push) Failing after 1m31s
Frontend CI / build-android-apk (push) Successful in 47m22s
Add lazy loading pipeline for reply message previews that checks memory → SQLite → server in order, enabling offline hit and graceful degradation for deleted/inaccessible messages. Includes `getReplyMessage` API endpoint and `jumpToMessageSeq` helper for navigation to referenced messages.

feat(create): extract MomentComposer and LongPostComposer for dual posting modes

Refactor CreatePostScreen shell to delegate content editing to specialized composers based on postMode ('moment' or 'long'), enabling long posts with voting support. Add expandable FAB with mode selection on HomeScreen.

fix(navigation): use navigate instead of push to prevent duplicate chat instances

Replace router.push with router.navigate in notification bootstrap to avoid creating duplicate chat instances when already on the chat screen.

fix(ui): update author badge styling with proper text contrast

Change author badge background to use hex transparency and add dedicated text color for better readability on both light and dark themes.

chore: normalize filename handling in file uploads and improve file segment rendering

Use asset.name as authoritative filename, remove redundant shadows from file cards inside bubbles.
2026-07-02 23:26:11 +08:00
lafay
f9b0999112 feat(messaging): implement Outbox persistence with three-tier sync strategy
Some checks failed
Frontend CI / build-android-apk (push) Waiting to run
Frontend CI / ota (android) (push) Successful in 1m52s
Frontend CI / ota (ios) (push) Successful in 2m36s
Frontend CI / build-and-push-web (push) Failing after 1m15s
Add Outbox pattern for reliable message sending with offline recovery:

- Persist pending/failed messages to SQLite (status field extended with 'pending'|'failed')
- App restart scans pending messages and auto-retries failed sends
- Add versioned migration system (PRAGMA user_version) for future schema evolution

Implement three-tier sync strategy for precision and efficiency:

- syncByVersion: precise incremental sync using persisted cursor (Matrix-style)
- syncBySeq: fallback seq-based incremental sync
- Full refresh: last resort for recovery

Add conversation sync state machine for UI error visibility:

- 'idle' | 'hydrating' | 'synced' | 'error' states exposed via useMessages hook
- Enables loading/error indicators during hydration pipeline

Standardize error handling across message services using createErrorHandler.

Add tests: messageOutbox.test.ts, syncByVersion.test.ts, syncState.test.ts
2026-06-28 23:19:52 +08:00
lafay
d8386b5f76 fix(messaging): implement hydration pipeline to prevent history loss on page entry
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 1m50s
Frontend CI / ota (android) (push) Successful in 2m8s
Frontend CI / ota (ios) (push) Successful in 2m47s
Frontend CI / build-android-apk (push) Successful in 45m48s
This addresses the issue where entering a chat page would only display recent
websocket messages, swallowing historical messages.

The fix implements a proper hydration pipeline in MessageSyncService that:
1. Tracks hydrated state per conversation via `hydratedConversations` Set
2. Unhydrated conversations run full pipeline: local contiguous range check,
   incremental sync above maxSeq, gap fill below minSeq, mark hydrated
3. Hydrated conversations only do lightweight incremental sync using trusted baseline
4. Clear conversation now also clears in-memory state and hydrated flag

Also adds:
- `getContiguousRange()` method to find local message continuity gaps
- `MESSAGES_PAGE_SIZE` (20) and `MAX_GAP_FILL_ROUNDS` (5) constants
- Test mocks with configurable state for hydration scenarios
- `messageHydration.test.ts` unit tests
2026-06-28 14:15:36 +08:00
lafay
be77a9d04c feat(messaging): implement optimistic updates with retry for failed messages
All checks were successful
Frontend CI / ota (android) (push) Successful in 3m24s
Frontend CI / ota (ios) (push) Successful in 3m22s
Frontend CI / build-and-push-web (push) Successful in 21m12s
Frontend CI / build-android-apk (push) Successful in 41m40s
- Add pending/failed message status to track send state
- Implement optimistic UI: show message immediately, update on server response
- Add retry functionality for failed messages via tap-to-retry
- Change send button from icon to text "发送" for clarity
- Add MessageSendService with temp ID generation to prevent collisions

feat(notification): add JPush notification deduplication and clear on tap

- Deduplicate notificationArrived events from dual JPush/vendor channels
- Clear all notifications on tap (QQ-style behavior)

feat(post): add client-side idempotency key for post creation

- Generate client_request_id per publish intent to prevent duplicates on retry
- Pass idempotency key through to postService and voteService
2026-06-26 17:01:09 +08:00
lafay
2bad59afbb feat(profile): add help screen and fix upload field name
All checks were successful
Frontend CI / ota (ios) (push) Successful in 2m46s
Frontend CI / ota (android) (push) Successful in 2m50s
Frontend CI / build-and-push-web (push) Successful in 3m54s
Frontend CI / build-android-apk (push) Successful in 40m29s
Add dedicated Help screen to profile tab with navigation integration.
Refactor SettingsScreen to navigate to HelpScreen instead of showing placeholder alert.
Fix API upload method to use correct field names ("file" for documents, "image" for media)
based on endpoint path, resolving upload failures for file uploads.
2026-06-22 07:56:06 +08:00
lafay
761f315a60 build(updates): enable full git history and use semantic version for runtimeVersion
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 1m23s
Frontend CI / ota (ios) (push) Successful in 1m47s
Frontend CI / ota (android) (push) Successful in 2m41s
Frontend CI / build-android-apk (push) Successful in 46m5s
Enable fetch-depth: 0 in checkout steps to support commit counting.
Switch runtimeVersion from buildNumber to appJson.expo.version so that
builds with the same semantic version share OTA update channels,
while versionCode/buildNumber continue using commit count for app store monotonic versioning.
2026-06-21 17:36:17 +08:00
lafay
488323377c ci(build): resolve runtimeVersion from app.config.js instead of app.json
Some checks failed
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / ota (android) (push) Has been cancelled
Frontend CI / ota (ios) (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
2026-06-21 17:26:40 +08:00
lafay
705b365536 refactor(core): distinguish network errors from auth failures and refactor real-time messaging pipeline
Some checks failed
Frontend CI / ota (android) (push) Successful in 3m11s
Frontend CI / ota (ios) (push) Successful in 3m31s
Frontend CI / build-and-push-web (push) Successful in 4m38s
Frontend CI / build-android-apk (push) Has been cancelled
- Add isNetworkError() helper and FetchCurrentUserResult discriminated union to prevent logout on network failures
- Refactor authService to return { kind: 'user' } | { kind: 'auth_failed' } | { kind: 'network_error' }
- Update authStore to preserve login state on network errors, only logout on auth failures
- Replace WSMessageHandler with RealtimeIngestionPipeline for improved real-time message handling
- Remove MessageDeduplication service; add store-level idempotent addOrReplaceMessage for message dedup
- Add atomic systemUnreadCount operations to prevent race conditions
- Add SearchHeader component for consistent search UI across screens
- Add 'home' TabBar variant with underline style matching HomeScreen
- Add Jest test infrastructure and exclude tests from tsconfig
- Fix ChatScreen history lock being stuck when scrolling to latest messages
- Remove unused call_participant_joined/left WS event types
2026-06-21 17:17:15 +08:00
lan
94a202506b ci(build): prevent gradle.properties corruption and improve key validation
All checks were successful
Frontend CI / ota (ios) (push) Successful in 1m35s
Frontend CI / ota (android) (push) Successful in 2m27s
Frontend CI / build-and-push-web (push) Successful in 6m24s
Frontend CI / build-android-apk (push) Successful in 38m59s
Add newline prepending before appending properties to prevent concatenation with the last line of gradle.properties if it lacks a trailing newline. Replace single key check with a loop that validates all signing keys are correctly placed at column 0.
2026-06-18 18:13:22 +08:00
lafay
c1f8acd4fc fix(ci): use explicit echo blocks instead of heredocs to prevent leading whitespace in gradle.properties
Some checks failed
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / ota (android) (push) Has been cancelled
Frontend CI / ota (ios) (push) Has been cancelled
Frontend CI / build-android-apk (push) Successful in 45m59s
The heredoc approach (`cat >> file << EOF`) was introducing leading whitespace to each line,
causing Gradle to treat whitespace as part of property keys. This made hasProperty() return
false for signingConfig values, breaking release builds. The fix uses explicit echo blocks
that write column-0 strings without indentation artifacts.
2026-06-18 15:37:56 +08:00
lafay
1018cd7ea2 ci(build): add expo.inlineModules.watchedDirectories to fix ExpoAutolinkingPlugin
All checks were successful
Frontend CI / ota (ios) (push) Successful in 2m5s
Frontend CI / ota (android) (push) Successful in 2m12s
Frontend CI / build-and-push-web (push) Successful in 3m55s
Frontend CI / build-android-apk (push) Successful in 43m31s
The ExpoAutolinkingPlugin requires expo.inlineModules.watchedDirectories to be set.
Without it, Gradle calls `node ... --watched-directories-serialized ''` and
node JSON.parse('') throws SyntaxError, exiting with code 1.
2026-06-18 14:42:38 +08:00
lafay
ce0cb75248 ci: add missing gradle properties and enable build logging
Some checks failed
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / ota (android) (push) Has been cancelled
Frontend CI / ota (ios) (push) Has been cancelled
Frontend CI / build-android-apk (push) Successful in 12m56s
Restore critical build properties that expo prebuild does not generate:
- ndkVersion for native compilation
- Gradle HTTP connection and socket timeouts for reliability
- Capture full build output with stacktrace for CI debugging
2026-06-18 14:18:36 +08:00
lafay
9cba25da00 refactor(push): unify file modification approach and improve dependency resolution
Some checks failed
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / ota (android) (push) Has been cancelled
Frontend CI / ota (ios) (push) Has been cancelled
Frontend CI / build-android-apk (push) Failing after 12m49s
Simplify Honor push plugin to use withDangerousMod consistently for all file
modifications, replacing the withSettingsGradle approach that silently drops
writes in Expo SDK 56+ multi-vendor plugin scenarios.

Enhance Huawei push plugin to create missing dependencyResolutionManagement
block instead of only appending to existing ones, ensuring CI build paths work
reliably when Gradle's dependencyResolutionManagement takes precedence.

Remove inline settings.gradle/build.gradle generation from CI workflow,
relying on expo prebuild for Gradle file generation.
2026-06-18 13:53:36 +08:00
lafay
3c452cfbd3 fix(push): add allprojects.repositories for runtime dependency resolution
Some checks failed
Frontend CI / ota (android) (push) Successful in 2m2s
Frontend CI / ota (ios) (push) Successful in 2m3s
Frontend CI / build-and-push-web (push) Successful in 3m6s
Frontend CI / build-android-apk (push) Failing after 15m6s
Ensure Honor and Huawei Maven repos are added to both buildscript.repositories
and allprojects.repositories in root build.gradle. Runtime dependencies like
com.hihonor.mcs:push and com.huawei.hms:push are resolved via allprojects,
not buildscript. Also consolidate all build.gradle modifications to use
withDangerousMod directly, avoiding silent hook drops in Expo SDK 56+.
2026-06-18 13:22:15 +08:00
97 changed files with 9763 additions and 3687 deletions

View File

@@ -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}"

View File

@@ -66,9 +66,10 @@ const filteredPlugins = isWeb
module.exports = {
...expo,
name: isDevVariant ? `${expo.name} Dev` : expo.name,
// runtimeVersion 用 build numbercommit 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,

View File

@@ -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" />

View File

@@ -0,0 +1,5 @@
import { HelpScreen } from '../../../../src/screens/profile';
export default function HelpRoute() {
return <HelpScreen />;
}

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
]
}
}

View File

@@ -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.**')) {

View File

@@ -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');

View File

@@ -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 */}

View 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;

View File

@@ -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 变体的字号,默认 18HomeScreen 可传 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;
}

View File

@@ -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';

View File

@@ -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;

View File

@@ -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();

View File

@@ -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;

View File

@@ -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: 该会话本地最大的 seq0 表示无消息)
* - 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}%`]

View File

@@ -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 0CREATE 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;
}
}
}

View File

@@ -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

View 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',
},
});
}

View 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,
},
});
}

View 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',
},
});
}

View 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,
},
});
}

View 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;

View File

@@ -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>

View File

@@ -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: {

View File

@@ -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,
},

View File

@@ -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']}>

View File

@@ -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,
},
});
}

View File

@@ -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,
},

View File

@@ -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`,
},

View File

@@ -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',

View File

@@ -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',
},
});
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
});

View File

@@ -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,
},
// 输入框

View File

@@ -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;
}
/** 输入框中待发送的本地图片(未上传) */

View File

@@ -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,
};
};

View 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 不缓存 senderUI 侧由 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 };
}

View File

@@ -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',

View 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;

View File

@@ -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':

View File

@@ -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';

View File

@@ -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 + TokenDB/缓存清理交给 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);

View File

@@ -12,6 +12,7 @@ export type {
BlockStatusResponse,
QRCodeSession,
ScanResponse,
FetchCurrentUserResult,
} from './authService';
export { verificationService } from './verificationService';

View 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);
});
});
});

View File

@@ -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,

View File

@@ -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;

View File

@@ -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();

View File

@@ -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';

View 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);
}

View File

@@ -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`;
}

View File

@@ -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

View File

@@ -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();
}
}

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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: '获取用户信息失败',
});

View File

@@ -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) {

View File

@@ -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);
}

View 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();
});
});
});

View 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('方案 Abaseline 修正(核心回归)', () => {
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~49minSeq=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('方案 Chydrated 门控', () => {
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);
});
});
});

View 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');
});
});

View 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();
},
};

View 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,
}));

View 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 mockon/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 {};

View File

@@ -0,0 +1,190 @@
/**
* 测试用 database mockmessageRepository / 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) || [];
// 模拟 SQLORDER 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 () => {},
};

View 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();

View 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 };

View 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 增量同步竞态)', () => {
// 两条独立 mergestore 在 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']);
});
});
});

View 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);
});
});

View 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 失败后状态转为 errorstore 层验证)', () => {
// 直接验证状态机 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 }));
// 重新 fetcherror → 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');
});
});

View File

@@ -0,0 +1,11 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"rootDir": "../../../../",
"isolatedModules": true,
"noEmit": true,
"types": ["jest", "node"]
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "mocks/**"]
}

View File

@@ -0,0 +1,112 @@
/**
* 未读计数原子性单测
*
* 锁定的不变量:
* 1. store 原子 actionsincrementSystemUnreadAtomic / 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);
});
});
});

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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
* 群聊失败消息重试走 HTTPsendMessage 的 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 机制)。
* 把 MessageResponseDTO映射为 CachedMessageDB 行并落库INSERT OR REPLACE 按 id 幂等)。
* 用于 pending/failed/normal 三态消息的持久化,保证 App 重启后发送状态可恢复。
* 落库失败不阻断 UIfire-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,

View File

@@ -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 1A无条件读本地 DB 补全内存基线(修复「内存非空就跳过本地」的根因)
* Step 2上缺口增量同步拉本地 maxSeq 之后的新消息)
* Step 3B下缺口完整回填循环向前 loadMore 直到连续到顶或触达上限)
* Step 4C标记 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);
});
}

View File

@@ -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;
}
/**
* 根据已读保护状态智能合并会话
*/

View 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 '';
}
}
}

View File

@@ -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();

View File

@@ -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;
// 立即清空 bufferflush 期间新到的事件将走正常处理路径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 '';
}
}
}

View File

@@ -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';

View File

@@ -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 };
}
}

View File

@@ -11,6 +11,8 @@ import type {
} from '../../types/dto';
const LAST_SYSTEM_MESSAGE_AT_KEY = 'last_system_message_at';
/** 同步游标持久化 keyWS 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;
}

View File

@@ -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;
}

View File

@@ -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(() => {});
}

View File

@@ -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 ====================

View File

@@ -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
View File

@@ -0,0 +1,68 @@
/**
* 客户端幂等键工具
*
* 用于发帖、发评论等"创建型"请求,避免因重复点击 / 网络重试导致重复创建。
* 客户端为每次"用户意图"生成一个唯一 IDclient_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 / Webcrypto.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();
}

View File

@@ -9,5 +9,6 @@
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true
}
},
"exclude": ["node_modules", "**/__tests__/**", "**/*.test.ts", "**/*.test.tsx", "jest.config.js"]
}