38 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
lafay
0e79dbf655 feat(push): add OPPO and vivo push plugins, bump JPush to 6.1.0
Some checks failed
Frontend CI / ota (ios) (push) Successful in 1m56s
Frontend CI / ota (android) (push) Successful in 2m49s
Frontend CI / build-and-push-web (push) Successful in 3m9s
Frontend CI / build-android-apk (push) Failing after 15m8s
- Add withOppoPush.js and withVivoPush.js config plugins
- Register new plugins in app.json
- Bump JPush version from 5.8.0 to 6.1.0 in Honor/Huawei/Xiaomi plugins
2026-06-18 12:46:34 +08:00
lafay
a921aacefd refactor: streamline post sync, fix image gallery, and clean up chat screen
- **Post sync optimization**: Clear store immediately when params change to prevent flashing old posts; replace instead of merge on refresh
- **ImageGallery fix**: Use idempotent download option, migrate to Asset.create() API, fix Android file path requirement, ensure proper cleanup
- **UserProfileScreen**: Add ImageGallery for post image viewing with consistent implementation
- **SearchScreen**: Add entrance animation and empty state
- **ChatScreen**: Remove unused state variables (lastSeq, firstSeq, isProgrammaticScrollRef) and dead imports
2026-06-18 02:29:54 +08:00
lafay
96e8de18bf feat(push): integrate Honor and Xiaomi push plugins with related updates 2026-06-18 00:03:37 +08:00
lafay
b2979311bb feat(auth): add cold-start token verification with loading state to app layout
All checks were successful
Frontend CI / ota (android) (push) Successful in 1m34s
Frontend CI / ota (ios) (push) Successful in 1m31s
Frontend CI / build-and-push-web (push) Successful in 18m8s
Frontend CI / build-android-apk (push) Successful in 35m6s
Move token verification from root layout SessionGate to (app) group layout,
enabling cold-start verification without blocking public pages (privacy/terms).
Show ActivityIndicator while verification is in progress, then redirect to
login if unverified.

BREAKING CHANGE: Authentication flow changed - token verification now occurs
in the (app) layout group instead of root layout SessionGate wrapper.
2026-06-16 19:03:26 +08:00
lafay
9b5e76b310 build(docker): add git dependency and increase Node.js memory allocation
Some checks failed
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
Frontend CI / build-and-push-web (push) Successful in 2m44s
2026-06-16 18:31:58 +08:00
lafay
b05da4e4a6 feat(ui): add privacy policy and terms screens to navigation
Some checks failed
Frontend CI / ota (android) (push) Successful in 1m48s
Frontend CI / ota (ios) (push) Successful in 1m52s
Frontend CI / build-and-push-web (push) Failing after 9m40s
Frontend CI / build-android-apk (push) Has been cancelled
2026-06-16 18:09:56 +08:00
lafay
641de98dc1 perf(ci): optimize JVM memory allocation for Android build
Some checks failed
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / ota (ios) (push) Has been cancelled
Frontend CI / ota (android) (push) Has been cancelled
Frontend CI / build-android-apk (push) Successful in 32m56s
- Consolidate gradle properties with kotlin.daemon.jvmargs
- Increase heap and metaspace limits (3g→4g heap, 512m→1g metaspace)
- Add kotlin daemon memory options to gradlew command
- Remove redundant environment variables and CMAKE settings
2026-06-16 12:42:27 +08:00
lafay
33a9c2fad1 perf(android): add taskset CPU affinity for Android release build
Some checks failed
Frontend CI / ota (ios) (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / ota (android) (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
2026-06-16 12:07:24 +08:00
lafay
2195fe2c2b perf(ci): incrementally tighten resource constraints across CI jobs
Some checks failed
Frontend CI / ota (ios) (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / ota (android) (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
Further reduce worker counts, JVM heap, Node memory, and CMake parallelism to lower CI costs. These incremental adjustments complement recent optimizations.
2026-06-16 11:44:05 +08:00
lafay
03a735b6ac perf(ci): reduce build memory and parallelism settings
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 28m56s
Lower memory allocation from 8GB to 4GB and reduce worker counts across
Gradle, Node, and CMake to minimize resource consumption during CI builds.
Remove Gradle caching to simplify workflow configuration.
2026-06-16 11:13:02 +08:00
lafay
c06463f576 perf(ci): reduce build memory and CPU allocations
Some checks failed
Frontend CI / ota (ios) (push) Successful in 1m30s
Frontend CI / ota (android) (push) Successful in 1m49s
Frontend CI / build-and-push-web (push) Successful in 2m47s
Frontend CI / build-android-apk (push) Failing after 14m11s
Scale down memory limits and parallelization settings across CI build pipeline. Reduce heap from 8g to 4g, metaspace from 1g to 512m, worker count from 4 to 2, and CMAKE build parallel level from 8 to 4. Remove Gradle caching and update web export to use explicit NODE_OPTIONS for memory allocation. These optimizations reduce resource usage while maintaining stable builds.
2026-06-16 09:07:56 +08:00
lafay
c46260e4c0 chore(plugins,ci): remove livekit audio switch patch plugin and simplify build config
Some checks failed
Frontend CI / ota (ios) (push) Has been cancelled
Frontend CI / ota (android) (push) Has been cancelled
Frontend CI / build-and-push-web (push) Failing after 3m58s
Frontend CI / build-android-apk (push) Failing after 45m17s
Remove the LiveKit audio switch patch plugin as it's no longer needed after the JitPack
repository was restricted to GitHub packages only. Simplify CI by removing npm caching and
Android NDK caching/installation steps. Update privacy policy and terms of service to
reflect JPush SDK naming changes.
2026-06-16 08:38:07 +08:00
lafay
4c4d28f0e2 feat(plugins): add LiveKit audio switch patch plugin
Some checks failed
Frontend CI / ota (ios) (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / ota (android) (push) Has been cancelled
Frontend CI / build-android-apk (push) Failing after 18m50s
Introduce withLivekitAudioswitchPatch plugin for handling LiveKit audio routing and switching functionality.
2026-06-16 01:10:20 +08:00
lafay
a31225dce2 build(ci): restrict JitPack repository to GitHub packages only
Some checks failed
Frontend CI / ota (ios) (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / ota (android) (push) Has been cancelled
Frontend CI / build-android-apk (push) Failing after 18m17s
Add group filtering to JitPack Maven repository configuration to limit
dependency resolution to com.github.* groups, improving build security
and reducing potential resolution of unintended packages.
2026-06-16 00:41:13 +08:00
lafay
b0b868593d feat:修复
Some checks failed
Frontend CI / ota (ios) (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / ota (android) (push) Has been cancelled
Frontend CI / build-android-apk (push) Failing after 25m17s
2026-06-16 00:27:58 +08:00
lafay
ed06c10f25 build(ci): remove Jiguang Maven repository from build workflows and Huawei plugin
Some checks failed
Frontend CI / ota (ios) (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / ota (android) (push) Has been cancelled
Frontend CI / build-android-apk (push) Failing after 15m46s
2026-06-16 00:07:50 +08:00
lafay
ce56824c2e refactor(ci): simplify CMake job limit plugin by removing app/build.gradle patching
Some checks failed
Frontend CI / ota (ios) (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / ota (android) (push) Has been cancelled
Frontend CI / build-android-apk (push) Failing after 13m52s
2026-06-15 23:51:20 +08:00
lafay
2ae5999940 build: restructure Gradle plugins placement and remove .npmrc config
Some checks failed
Frontend CI / ota (android) (push) Has been cancelled
Frontend CI / ota (ios) (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / build-android-apk (push) Failing after 10m22s
2026-06-15 23:38:41 +08:00
lafay
edde8f1c30 fix(huawei-push): simplify Jiguang Maven repository injection
Some checks failed
Frontend CI / ota (android) (push) Successful in 1m41s
Frontend CI / ota (ios) (push) Successful in 1m36s
Frontend CI / build-and-push-web (push) Successful in 10m38s
Frontend CI / build-android-apk (push) Failing after 16m7s
Clean up duplicate and malformed Jiguang repo addition code in the Huawei push plugin. The previous implementation had redundant logic for injecting the repository into dependencyResolutionManagement, resulting in duplicate entries and syntax errors. Simplified to directly append the maven URL using the `$&` backreference pattern for cleaner, more maintainable code.
2026-06-15 23:05:55 +08:00
lafay
1012337e57 build(deps): add Jiguang SDK repositories and CMake job limit plugin
Some checks failed
Frontend CI / ota (android) (push) Failing after 54s
Frontend CI / ota (ios) (push) Failing after 52s
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
Add Jiguang Maven repository (developer.jiguang.cn) to dependency resolution management for JPush SDK integration. Integrate new Expo config plugins for CMake job limiting and JCore patching.

- Add jiguang maven repo to dependencyResolutionManagement and all buildscript repositories
- Create withCmakeJobLimit plugin to set cmake job limits in gradle properties
- Create withJcorePatch plugin for JCore SDK configuration
- Increase CMAKE_BUILD_PARALLEL_LEVEL from 4 to 8 for faster native builds
- Add gradle HTTP timeout settings (30s connection/socket timeout)
- Remove npmmirror registry from npmrc and CI workflows (use default npm registry)
- Downgrade upload-artifact action from v4 to v3 for compatibility
- Remove docker layer caching for frontend-web build
2026-06-15 22:58:48 +08:00
lafay
f3d54a3f4c feat(background): implement user consent mechanism for auto-start functionality
Some checks failed
Frontend CI / ota (android) (push) Has been cancelled
Frontend CI / ota (ios) (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / build-android-apk (push) Failing after 7m23s
Add auto-start consent system that requires explicit user permission before enabling background sync services. Users can now choose between silent mode (no auto-start) or background mode (15-minute sync intervals).

- Add consent service with AutoStartMode enum and storage utilities
- Create withRemoveAutoStart Expo plugin to disable Android auto-start defaults
- Integrate consent checks into JPush service, background task manager, and foreground service
- Add auto-start consent UI in notification settings with descriptive dialog
- Update privacy policy with section 8 explaining auto-start scenarios and user controls
- Change default sync mode from BATTERY_SAVER to DISABLED
- Export reinitBackgroundService function for re-initializing after consent changes
- Merge OTA Android and iOS workflows into matrix build
- Add release signing config in withSigning plugin for Android
- Expand .dockerignore with native credential and build artifact exclusions
2026-06-15 20:43:15 +08:00
lan
d8ef51fa13 fix: improve cache management and sticker deletion
Some checks failed
Frontend CI / ota-android (push) Successful in 4m46s
Frontend CI / ota-ios (push) Successful in 3m24s
Frontend CI / build-and-push-web (push) Failing after 24m8s
Frontend CI / build-android-apk (push) Successful in 34m17s
Update cache directory scanning to use platform-specific paths for iOS and Android.
Add a delay after clearing cache to ensure native deletion completes.
Correct the request body structure for sticker deletion API calls.
2026-06-15 03:59:24 +08:00
128 changed files with 12103 additions and 4236 deletions

View File

@@ -1,10 +1,40 @@
# Dependencies
node_modules
# Source control
.git
.gitignore
# Expo caches
.expo
.expo-shared
# Build artifacts
dist
dist-web
dist-android
dist-ios
dist-android-update.zip
dist-ios-update.zip
android
ios
web-build
# Native credentials (never ship into image)
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
# Misc
screenshots
*.log
.vscode
.idea
.DS_Store
*.swp
*.swo
coverage
__tests__

View File

@@ -21,32 +21,28 @@ on:
env:
REGISTRY: code.littlelan.cn
IMAGE_NAME: carrot_bbs/frontend-web
OTA_PLATFORM_ANDROID: android
OTA_PLATFORM_IOS: ios
OTA_PUBLISH_URL: https://updates.littlelan.cn/admin/publish
OTA_MANIFEST_URL: https://updates.littlelan.cn/api/manifest
jobs:
ota-android:
ota:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_ota == 'true')
permissions:
contents: read
strategy:
matrix:
platform: [android, ios]
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '25.6.1'
registry-url: 'https://registry.npmmirror.com'
- name: Remove deprecated always-auth npm config
run: |
if [ -f ~/.npmrc ]; then
sed -i '/always-auth/d' ~/.npmrc
fi
- name: Install dependencies
run: npm ci
@@ -54,121 +50,48 @@ jobs:
- name: Resolve runtime version
id: runtime
run: |
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
RUNTIME_VERSION="$(node -p "require('./app.config.js').runtimeVersion")"
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
- name: Export Android update bundle
- name: Export update bundle
run: |
rm -rf dist-android dist-android-update.zip
npx expo export --platform android --output-dir dist-android
npx expo config --type public --json > dist-android/expoConfig.json
rm -rf dist-${{ matrix.platform }} dist-${{ matrix.platform }}-update.zip
npx expo export --platform ${{ matrix.platform }} --output-dir dist-${{ matrix.platform }}
npx expo config --type public --json > dist-${{ matrix.platform }}/expoConfig.json
- name: Archive Android update bundle
- name: Archive update bundle
run: |
python - <<'PY'
import os
import zipfile
with zipfile.ZipFile('dist-android-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
for root, _, files in os.walk('dist-android'):
python -c "
import os, zipfile
p = '${{ matrix.platform }}'
dist = f'dist-{p}'
with zipfile.ZipFile(f'dist-{p}-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
for root, _, files in os.walk(dist):
for name in files:
src = os.path.join(root, name)
arc = os.path.relpath(src, 'dist-android')
arc = os.path.relpath(src, dist)
zf.write(src, arc)
PY
"
- name: Publish OTA
env:
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
run: |
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${OTA_PLATFORM_ANDROID}" \
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${{ matrix.platform }}" \
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
-H "Content-Type: application/zip" \
--data-binary @"dist-android-update.zip"
--data-binary @"dist-${{ matrix.platform }}-update.zip"
- name: Verify OTA manifest
run: |
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
-H "expo-platform: ${OTA_PLATFORM_ANDROID}" \
-H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
-H "expo-protocol-version: 1" \
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\\\"id\\\":.*\\\"extra\\\":\\{.*\\}\\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
LOCAL="$(python -c "import json; m=json.load(open('dist-android/metadata.json')); print(m['fileMetadata']['android']['bundle'].split('/')[-1])")"
echo "Remote bundle: ${REMOTE}"
echo "Local bundle: ${LOCAL}"
test "${REMOTE}" = "${LOCAL}"
ota-ios:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_ota == 'true')
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '25.6.1'
registry-url: 'https://registry.npmmirror.com'
- name: Remove deprecated always-auth npm config
run: |
if [ -f ~/.npmrc ]; then
sed -i '/always-auth/d' ~/.npmrc
fi
- name: Install dependencies
run: npm ci
- name: Resolve runtime version
id: runtime
run: |
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
- name: Export iOS update bundle
run: |
rm -rf dist-ios dist-ios-update.zip
npx expo export --platform ios --output-dir dist-ios
npx expo config --type public --json > dist-ios/expoConfig.json
- name: Archive iOS update bundle
run: |
python - <<'PY'
import os
import zipfile
with zipfile.ZipFile('dist-ios-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
for root, _, files in os.walk('dist-ios'):
for name in files:
src = os.path.join(root, name)
arc = os.path.relpath(src, 'dist-ios')
zf.write(src, arc)
PY
- name: Publish OTA
env:
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
run: |
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${OTA_PLATFORM_IOS}" \
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
-H "Content-Type: application/zip" \
--data-binary @"dist-ios-update.zip"
- name: Verify OTA manifest
run: |
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
-H "expo-platform: ${OTA_PLATFORM_IOS}" \
-H "expo-platform: ${{ matrix.platform }}" \
-H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
-H "expo-protocol-version: 1" \
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\"id\":.*\"extra\":\{.*\}\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
LOCAL="$(python -c "import json; m=json.load(open('dist-ios/metadata.json')); print(m['fileMetadata']['ios']['bundle'].split('/')[-1])")"
LOCAL="$(python -c "import json; m=json.load(open('dist-${{ matrix.platform }}/metadata.json')); print(m['fileMetadata']['${{ matrix.platform }}']['bundle'].split('/')[-1])")"
echo "Remote bundle: ${REMOTE}"
echo "Local bundle: ${LOCAL}"
test "${REMOTE}" = "${LOCAL}"
@@ -178,18 +101,17 @@ jobs:
container:
image: reactnativecommunity/react-native-android:latest
env:
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=4 -Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
_JAVA_OPTIONS: "-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
NODE_OPTIONS: "--max-old-space-size=8192"
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=false -Dorg.gradle.workers.max=1"
NODE_OPTIONS: "--max-old-space-size=2048"
NODE_ENV: "production"
NDK_NUM_JOBS: "4"
CMAKE_BUILD_PARALLEL_LEVEL: "4"
GRADLE_USER_HOME: /root/.gradle
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Java
uses: actions/setup-java@v4
@@ -201,208 +123,84 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '25.6.1'
registry-url: 'https://registry.npmmirror.com'
- name: Remove deprecated always-auth npm config
run: |
if [ -f ~/.npmrc ]; then
sed -i '/always-auth/d' ~/.npmrc
fi
- name: Cache Android NDK
uses: actions/cache@v4
id: cache-ndk
with:
path: /opt/android/ndk/27.1.12297006
key: ndk-27.1.12297006-v1
- name: Install Android NDK
if: steps.cache-ndk.outputs.cache-hit != 'true'
run: |
echo "Existing NDK versions:"
ls /opt/android/ndk/ 2>/dev/null || echo "No NDK dir"
echo "Installing NDK 27.1.12297006..."
yes | sdkmanager --install "ndk;27.1.12297006"
echo "NDK after install:"
ls /opt/android/ndk/
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.android/build-cache
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Cache node_modules
uses: actions/cache@v4
id: cache-node-modules
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-modules-
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- name: Generate Android native project
run: npx expo prebuild --platform android
- name: Switch Gradle distribution to Tencent mirror
run: |
PROPS="android/gradle/wrapper/gradle-wrapper.properties"
if [ -f "$PROPS" ]; then
sed -i 's|distributionUrl=https\\://services.gradle.org/distributions/|distributionUrl=https\\://mirrors.cloud.tencent.com/gradle/|' "$PROPS"
echo "Updated gradle-wrapper.properties:"
cat "$PROPS"
else
echo "gradle-wrapper.properties not found"
exit 1
fi
- name: Decode Android signing keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/app/withyou-release-key.keystore
- name: Configure Gradle with China Maven mirrors
- name: Configure Gradle with signing
run: |
cd android
# Update settings.gradle with China Maven mirrors
cat > settings.gradle << 'SETTINGS_EOF'
pluginManagement {
repositories {
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/central' }
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)
# Decode Android signing keystore
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > app/withyou-release-key.keystore
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)
}
# 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
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
}
# 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
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
}
expoAutolinking.useExpoModules()
# Verify signing config in app/build.gradle
echo "=== app/build.gradle signing section ==="
grep -A 20 'signingConfigs' app/build.gradle || echo "signingConfigs not found"
rootProject.name = '威友'
expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(expoAutolinking.reactNativeGradlePlugin)
SETTINGS_EOF
# Update build.gradle with China Maven mirrors
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://maven.aliyun.com/repository/gradle-plugin' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/central' }
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://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://www.jitpack.io' }
maven { url 'https://developer.huawei.com/repo/' }
google()
mavenCentral()
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"
BUILD_EOF
# Update gradle.properties
cat > gradle.properties << 'PROPS_EOF'
org.gradle.daemon=false
org.gradle.parallel=true
org.gradle.configureondemand=true
org.gradle.workers.max=4
org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
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
PROPS_EOF
- name: Configure Gradle signing properties
run: |
cd android
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
# 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
chmod +x gradlew
./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=4 --parallel
taskset -c 0-7 ./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=1 -Dkotlin.daemon.jvm.options="-Xmx2g,XX:MaxMetaspaceSize=1g"
- name: Upload APK artifact
uses: actions/upload-artifact@v3
@@ -413,7 +211,7 @@ jobs:
- name: Resolve runtime version
id: runtime
run: |
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
RUNTIME_VERSION="$(node -p "require('./app.config.js').runtimeVersion")"
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
@@ -488,8 +286,6 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
provenance: false
cache-from: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache
cache-to: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache,mode=max
- name: Show image tags
run: |

View File

@@ -6,7 +6,7 @@ COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npx expo export --platform web --output-dir dist-web
RUN node /app/node_modules/.bin/expo export --platform web --output-dir dist-web
FROM nginx:1.27-alpine

View File

@@ -6,15 +6,21 @@ const releaseApiBaseUrl = 'https://withyou.littlelan.cn/api/v1';
const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1';
function getGitShortHash() {
function getCommitCount() {
try {
return execSync('git rev-parse --short=4 HEAD', { encoding: 'utf-8' }).trim();
return execSync('git rev-list --count HEAD', { encoding: 'utf-8' }).trim();
} catch {
return '0000';
return '1';
}
}
const gitBuildSuffix = getGitShortHash();
// 在 commit count 上加偏移,保证 versionCode / buildNumber / runtimeVersion
// 始终大于历史最大值(之前的最后一个版本是 5547避免被系统/Play Store
// 误判为降级。后续即使 commit count 重置或换仓库,偏移也能保证单调递增。
const BUILD_NUMBER_OFFSET = 100000;
const commitCount = getCommitCount();
const buildNumber = String(parseInt(commitCount, 10) + BUILD_NUMBER_OFFSET);
function toManifestUrl(baseUrl, portOverride) {
const parsed = new URL(baseUrl);
@@ -60,9 +66,10 @@ const filteredPlugins = isWeb
module.exports = {
...expo,
name: isDevVariant ? `${expo.name} Dev` : expo.name,
runtimeVersion: {
policy: 'appVersion',
},
// runtimeVersion 用语义版本expo.version同一版本号的所有 build 共享 OTA 通道,
// 改原生代码/配置后应升级 version 来切换通道。
// versionCode / buildNumber 仍用 commit count + 偏移,保证应用商店版本号单调递增。
runtimeVersion: appJson.expo.version,
updates: {
...(expo.updates || {}),
url: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
@@ -71,12 +78,12 @@ module.exports = {
},
ios: {
...expo.ios,
buildNumber: gitBuildSuffix,
buildNumber: buildNumber,
},
android: {
...expo.android,
package: 'cn.qczlit.withyou',
versionCode: parseInt(gitBuildSuffix, 16),
versionCode: parseInt(buildNumber, 10),
},
// Web 端使用过滤后的插件
plugins: filteredPlugins,

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "威友",
"slug": "qojo",
"version": "0.0.2",
"version": "1.0.1",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -36,7 +36,6 @@
},
"predictiveBackGestureEnabled": false,
"package": "cn.qczlit.withyou",
"versionCode": 7,
"permissions": [
"VIBRATE",
"RECORD_AUDIO",
@@ -53,7 +52,6 @@
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
"android.permission.ACCESS_MEDIA_LOCATION",
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO",
"android.permission.READ_MEDIA_AUDIO",
@@ -66,6 +64,8 @@
"favicon": "./assets/favicon.png"
},
"plugins": [
"./plugins/withCmakeJobLimit",
"./plugins/withJcorePatch",
"./plugins/withSigning",
"./plugins/withMainActivityConfigChange",
[
@@ -100,12 +100,13 @@
"minimumInterval": 15
}
],
"./plugins/withRemoveAutoStart",
[
"expo-media-library",
{
"photosPermission": "允许威友访问您的照片以发布内容",
"savePhotosPermission": "允许威友保存照片到您的相册",
"isAccessMediaLocationEnabled": true
"isAccessMediaLocationEnabled": false
}
],
[
@@ -154,6 +155,10 @@
}
],
"./plugins/withHuaweiPush",
"./plugins/withXiaomiPush",
"./plugins/withHonorPush",
"./plugins/withVivoPush",
"./plugins/withOppoPush",
"expo-callkit-telecom",
[
"expo-splash-screen",

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

@@ -1,24 +1,54 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { ActivityIndicator, View } from 'react-native';
import { Redirect } from 'expo-router';
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
import { messageManager, useAuthStore } from '../../src/stores';
import { useRegisterPushDevice } from '../../src/hooks';
import { hrefAuthLogin } from '../../src/navigation/hrefs';
import { useAppColors } from '../../src/theme';
export default function AppLayout() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
const userID = useAuthStore((s) => s.currentUser?.id);
const colors = useAppColors();
const [verified, setVerified] = useState(false);
useEffect(() => {
// 冷启动 token 校验:仅鉴权分组 (app) 需要。
// 公开页(/privacy、/terms和 (auth) 分组在根布局直接渲染,不受此校验影响,
// 因此冷启动 401 不会把公开页顶到 /login。
fetchCurrentUser().finally(() => setVerified(true));
}, [fetchCurrentUser]);
useRegisterPushDevice(isAuthenticated, userID);
useEffect(() => {
messageManager.initialize();
}, []);
useRegisterPushDevice(isAuthenticated, userID);
if (!isAuthenticated) {
return <Redirect href={hrefAuthLogin()} />;
// 持久化状态显示已登录则立即渲染(后台静默校验)
if (isAuthenticated) {
return <AppRouteStack />;
}
return <AppRouteStack />;
// 未登录且校验未完成,显示 loading
if (!verified) {
return (
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.default,
}}
>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
);
}
// 校验完成仍未登录,跳转登录页
return <Redirect href={hrefAuthLogin()} />;
}

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { useWindowDimensions } from 'react-native';
import { useEffect, useState } from 'react';
import { ActivityIndicator, View, useWindowDimensions } from 'react-native';
import { Redirect } from 'expo-router';
import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell';
@@ -7,23 +7,50 @@ import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
import { BREAKPOINTS } from '../../src/hooks';
import { messageManager, useAuthStore } from '../../src/stores';
import { hrefAuthLogin } from '../../src/navigation/hrefs';
import { useAppColors } from '../../src/theme';
export default function AppLayoutWeb() {
const { width } = useWindowDimensions();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
const useDesktopShell = width >= BREAKPOINTS.desktop;
const colors = useAppColors();
const [verified, setVerified] = useState(false);
useEffect(() => {
// 冷启动 token 校验:仅鉴权分组 (app) 需要。
// 公开页(/privacy、/terms和 (auth) 分组在根布局直接渲染,不受此校验影响。
fetchCurrentUser().finally(() => setVerified(true));
}, [fetchCurrentUser]);
useEffect(() => {
messageManager.initialize();
}, []);
if (!isAuthenticated) {
return <Redirect href={hrefAuthLogin()} />;
// 持久化状态显示已登录则立即渲染(后台静默校验)
if (isAuthenticated) {
if (useDesktopShell) {
return <AppDesktopShell />;
}
return <AppRouteStack />;
}
if (useDesktopShell) {
return <AppDesktopShell />;
// 未登录且校验未完成,显示 loading
if (!verified) {
return (
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.default,
}}
>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
);
}
return <AppRouteStack />;
// 校验完成仍未登录,跳转登录页
return <Redirect href={hrefAuthLogin()} />;
}

View File

@@ -1,5 +1,5 @@
import '../src/polyfills';
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef } from 'react';
import { AppState, AppStateStatus, Platform, View, ActivityIndicator } from 'react-native';
import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
@@ -11,8 +11,6 @@ import * as SystemUI from 'expo-system-ui';
import { useFonts } from 'expo-font';
import { registerNotificationPresentationHandler } from '@/services/notification';
import { api } from '@/services/core';
import { wsService } from '@/services/core';
import { EventSubscriber } from '../src/infrastructure/EventSubscriber';
import {
ThemeBootstrap,
@@ -24,7 +22,6 @@ import {
import AppPromptBar from '../src/components/common/AppPromptBar';
import AppDialogHost from '../src/components/common/AppDialogHost';
import { installAlertOverride } from '@/services/ui';
import { useAuthStore } from '../src/stores';
import { checkForAPKUpdate } from '@/services/platform';
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
import { jpushService } from '@/services/notification/jpushService';
@@ -104,36 +101,6 @@ function SystemChrome() {
return null;
}
function SessionGate({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
const colors = useAppColors();
const [verified, setVerified] = useState(false);
useEffect(() => {
fetchCurrentUser().finally(() => setVerified(true));
}, [fetchCurrentUser]);
// If persisted state shows authenticated, render immediately (background verify)
// If not authenticated and not yet verified, show loading
if (!isAuthenticated && !verified) {
return (
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.default,
}}
>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
);
}
// If verified and not authenticated, the Redirect in (app)/_layout.tsx handles it
return <>{children}</>;
}
function NotificationBootstrap() {
const appState = useRef<AppStateStatus>(AppState.currentState);
const permissionRequested = useRef(false);
@@ -146,6 +113,7 @@ function NotificationBootstrap() {
const { systemNotificationService } = await import('@/services/notification');
await systemNotificationService.initialize();
const { initBackgroundService } = await import('@/services/background');
// 默认静默模式,不会注册后台任务,不会触发自启动
await initBackgroundService();
const subscription = AppState.addEventListener('change', (nextAppState) => {
@@ -160,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;
@@ -173,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,
@@ -184,10 +162,10 @@ function NotificationBootstrap() {
})
);
} else {
router.push(hrefs.hrefNotifications());
router.navigate(hrefs.hrefNotifications());
}
} else {
router.push(hrefs.hrefNotifications());
router.navigate(hrefs.hrefNotifications());
}
});
@@ -279,16 +257,16 @@ function ThemedStack() {
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
<SystemChrome />
<EventSubscriber />
<SessionGate>
<NotificationBootstrap />
<APKUpdateBootstrap />
<CallKeepBootstrap />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(app)" options={{ headerShown: false }} />
</Stack>
</SessionGate>
<NotificationBootstrap />
<APKUpdateBootstrap />
<CallKeepBootstrap />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(app)" options={{ headerShown: false }} />
<Stack.Screen name="privacy" options={{ headerShown: false }} />
<Stack.Screen name="terms" options={{ headerShown: false }} />
</Stack>
</>
);
}

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

2643
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "with_you",
"version": "0.0.2",
"version": "1.0.1",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
@@ -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,30 +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",
@@ -74,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

@@ -0,0 +1,40 @@
const { withDangerousMod } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
const MAX_JOBS = '3';
// React Native's hermes-engine build hardcodes
// Runtime.getRuntime().availableProcessors()
// for `cmake --build -j`, ignoring the CMAKE_BUILD_PARALLEL_LEVEL env var.
// On high-core CI runners this exhausts memory.
// This plugin caps it during prebuild.
const withCmakeJobLimit = (config) =>
withDangerousMod(config, [
'android',
async (config) => {
const root = config.modRequest.projectRoot;
const hermesPath = path.join(
root,
'node_modules',
'react-native',
'ReactAndroid',
'hermes-engine',
'build.gradle.kts',
);
if (fs.existsSync(hermesPath)) {
let content = fs.readFileSync(hermesPath, 'utf-8');
content = content.replace(
/Runtime\.getRuntime\(\)\.availableProcessors\(\)\.toString\(\)/,
`"${MAX_JOBS}"`,
);
fs.writeFileSync(hermesPath, content);
console.log(`[withCmakeJobLimit] capped hermes-engine jobs to ${MAX_JOBS}`);
}
return config;
},
]);
module.exports = withCmakeJobLimit;

233
plugins/withHonorPush.js Normal file
View File

@@ -0,0 +1,233 @@
const { withDangerousMod } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
// 荣耀厂商推送通道的 Expo config plugin.
//
// 客户端职责(依据极光官方文档 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 到 settings.gradle 和根 build.gradle
// 关键: 必须同时加到 settings.gradle 的 dependencyResolutionManagement 块CI build 走这条路径),
// 和根 build.gradle 的 buildscript+allprojects本地 build 兜底)。
// 4) 添加 Proguard 规则 (com.hihonor.push.** 保留 + 5 条 -keepattributes)
//
// 实现说明:所有文件改动统一用 withDangerousMod 直接读写磁盘。
// 原因Expo SDK 56+ 的 withSettingsGradle / withProjectBuildGradle / withAppBuildGradle hook 链中,
// 第二个以后的 plugin 写回 modResults.contents 会被静默忽略。withDangerousMod 走文件系统串行执行,
// 多 vendor plugin 共存可靠。
const withHonorPush = (config, options = {}) => {
const {
jpushVersion = '6.1.0',
appId = '104562917',
} = options;
const honorRepoUrl = 'https://developer.hihonor.com/repo';
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',
'build.gradle'
);
const proguardFilePath = path.join(
config.modRequest.platformProjectRoot,
'app',
'proguard-rules.pro'
);
// --- 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;
if (!gradle.includes('cn.jiguang.sdk.plugin:honor')) {
const depsPattern = /dependencies\s*\{/;
if (depsPattern.test(gradle)) {
gradle = gradle.replace(
depsPattern,
`dependencies {\n implementation 'cn.jiguang.sdk.plugin:honor:${jpushVersion}'`
);
changed = true;
}
}
const manifestPlaceholderRegex = /manifestPlaceholders\s*=\s*\[([\s\S]*?)\]/;
const match = gradle.match(manifestPlaceholderRegex);
const requiredPlaceholders = {
HONOR_APPID: appId,
};
if (match) {
const existingBlock = match[1];
const existingEntries = {};
const entryRegex = /(\w+)\s*:\s*"([^"]*)"/g;
let entryMatch;
while ((entryMatch = entryRegex.exec(existingBlock)) !== null) {
existingEntries[entryMatch[1]] = entryMatch[2];
}
let placeholdersChanged = false;
for (const [key, value] of Object.entries(requiredPlaceholders)) {
if (existingEntries[key] !== value) {
existingEntries[key] = value;
placeholdersChanged = true;
}
}
if (placeholdersChanged) {
const entriesStr = Object.entries(existingEntries)
.map(([k, v]) => `${k}: "${v}"`)
.join(',\n ');
const newBlock = `\n manifestPlaceholders = [\n ${entriesStr}\n ]`;
gradle = gradle.replace(manifestPlaceholderRegex, newBlock.trim());
changed = true;
}
} else if (gradle.includes('REACT_NATIVE_RELEASE_LEVEL')) {
const entriesStr = Object.entries(requiredPlaceholders)
.map(([k, v]) => `${k}: "${v}"`)
.join(',\n ');
const block = `\n manifestPlaceholders = [\n ${entriesStr}\n ]\n`;
const lines = gradle.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('REACT_NATIVE_RELEASE_LEVEL')) {
lines.splice(i + 1, 0, block);
break;
}
}
gradle = lines.join('\n');
changed = true;
}
if (changed) {
fs.writeFileSync(appBuildGradlePath, gradle);
console.log('[withHonorPush] updated app/build.gradle (AAR + manifestPlaceholders)');
}
}
// --- 4. proguard-rules.pro ---
if (fs.existsSync(proguardFilePath)) {
let proguard = fs.readFileSync(proguardFilePath, 'utf-8');
if (!proguard.includes('-keep class com.hihonor.push.**')) {
const honorRules = `
# JPush Honor vendor channel — keep rules (https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide)
-ignorewarnings
-keepattributes *Annotation*
-keepattributes Exceptions
-keepattributes InnerClasses
-keepattributes Signature
-keepattributes SourceFile,LineNumberTable
-keep class com.hihonor.push.**{*;}
`;
fs.writeFileSync(proguardFilePath, proguard + honorRules);
console.log('[withHonorPush] appended Honor Proguard rules to proguard-rules.pro');
}
}
return config;
},
]);
return config;
};
module.exports = withHonorPush;

View File

@@ -9,7 +9,7 @@ const fs = require('fs');
const path = require('path');
const withHuaweiPush = (config, options = {}) => {
const { jpushVersion = '5.8.0' } = options;
const { jpushVersion = '6.1.0' } = options;
config = withSettingsGradle(config, (config) => {
const contents = config.modResults.contents;
@@ -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');

43
plugins/withJcorePatch.js Normal file
View File

@@ -0,0 +1,43 @@
const { withDangerousMod } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
// Patch jcore-react-native's build.gradle to remove the legacy flatDir block
// and jniLibs.srcDirs. The 'libs' directory no longer exists in the package —
// dependencies are fetched from Maven — but the stale config causes Gradle
// warnings and can stall dependency resolution on newer Gradle versions.
const withJcorePatch = (config) =>
withDangerousMod(config, [
'android',
async (config) => {
const root = config.modRequest.projectRoot;
const gradlePath = path.join(
root,
'node_modules',
'jcore-react-native',
'android',
'build.gradle',
);
if (!fs.existsSync(gradlePath)) return config;
let content = fs.readFileSync(gradlePath, 'utf-8');
// Remove flatDir block
content = content.replace(
/repositories\s*\{\s*flatDir\s*\{\s*dirs\s+['"]libs['"]\s*\}\s*\}/,
'',
);
// Remove jniLibs.srcDirs line and its enclosing sourceSets block if it becomes empty
content = content.replace(
/\s*sourceSets\s*\{\s*main\s*\{\s*jniLibs\.srcDirs\s*=\s*\['libs'\]\s*\}\s*\}/,
'',
);
fs.writeFileSync(gradlePath, content);
return config;
},
]);
module.exports = withJcorePatch;

144
plugins/withOppoPush.js Normal file
View File

@@ -0,0 +1,144 @@
const { withDangerousMod } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
// OPPO 厂商推送通道的 Expo config plugin.
//
// 客户端职责(依据极光官方文档 https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide
// 1) 添加 cn.jiguang.sdk.plugin:oppo 依赖5.9.0+ 自动包含 heytap 官方 aar
// 2) 注入 manifestPlaceholders: OPPO_APPKEY / OPPO_APPID / OPPO_APPSECRET带 OP- 前缀)
// 3) 添加 Proguard 规则 (coloros.mcsdk / heytap / mcs 三个包保留)
//
// 注意事项:
// - 极光 JPUSH_CHANNEL 是「APK 分发渠道」统计字段,不是厂商通道开关,**不应覆盖**。
// - OPPO 与小米/vivo/荣耀不同:客户端需要 3 个占位符(含 AppSecret且必须带 `OP-` 前缀。
// - OPPO AAR 在 JPush 5.9.0+ 通过 maven 自动拉取,不需要手动下载 aar 文件。
// - OPPO SDK 3.1.0+ 依赖 gson 2.6.2 和 androidx.annotation 1.1.05.9.0 maven 已自动包含。
//
// 实现说明:用 withDangerousMod 直接读写磁盘文件。
// 原因Expo SDK 56+ 的 withAppBuildGradle hook 链中,第二个以后的 plugin 写回
// modResults.contents 会被静默忽略,导致多个 vendor plugin 只能写入第一个的修改。
const withOppoPush = (config, options = {}) => {
const {
jpushVersion = '6.1.0',
appId = '37219099',
appKey = '4eb532fcddd147c0bf7c356a149b2058',
appSecret = '9a0c290b2b8049e1893246ed7b9367d0',
} = options;
config = withDangerousMod(config, [
'android',
async (config) => {
const appBuildGradlePath = path.join(
config.modRequest.platformProjectRoot,
'app',
'build.gradle'
);
const proguardFilePath = path.join(
config.modRequest.platformProjectRoot,
'app',
'proguard-rules.pro'
);
// --- 1. 处理 build.gradle ---
if (fs.existsSync(appBuildGradlePath)) {
let gradle = fs.readFileSync(appBuildGradlePath, 'utf-8');
let changed = false;
// 1a. 注入 AAR 依赖
if (!gradle.includes('cn.jiguang.sdk.plugin:oppo')) {
const depsPattern = /dependencies\s*\{/;
if (depsPattern.test(gradle)) {
gradle = gradle.replace(
depsPattern,
`dependencies {\n implementation 'cn.jiguang.sdk.plugin:oppo:${jpushVersion}'`
);
changed = true;
}
}
// 1b. 注入 manifestPlaceholders带 OP- 前缀)
const manifestPlaceholderRegex = /manifestPlaceholders\s*=\s*\[([\s\S]*?)\]/;
const match = gradle.match(manifestPlaceholderRegex);
// OPPO 官方要求占位符值必须以 "OP-" 前缀开头
const requiredPlaceholders = {
OPPO_APPKEY: `OP-${appKey}`,
OPPO_APPID: `OP-${appId}`,
OPPO_APPSECRET: `OP-${appSecret}`,
};
if (match) {
const existingBlock = match[1];
const existingEntries = {};
const entryRegex = /(\w+)\s*:\s*"([^"]*)"/g;
let entryMatch;
while ((entryMatch = entryRegex.exec(existingBlock)) !== null) {
existingEntries[entryMatch[1]] = entryMatch[2];
}
let placeholdersChanged = false;
for (const [key, value] of Object.entries(requiredPlaceholders)) {
if (existingEntries[key] !== value) {
existingEntries[key] = value;
placeholdersChanged = true;
}
}
if (placeholdersChanged) {
const entriesStr = Object.entries(existingEntries)
.map(([k, v]) => `${k}: "${v}"`)
.join(',\n ');
const newBlock = `\n manifestPlaceholders = [\n ${entriesStr}\n ]`;
gradle = gradle.replace(manifestPlaceholderRegex, newBlock.trim());
changed = true;
}
} else if (gradle.includes('REACT_NATIVE_RELEASE_LEVEL')) {
// 没有 manifestPlaceholders block 时新建
const entriesStr = Object.entries(requiredPlaceholders)
.map(([k, v]) => `${k}: "${v}"`)
.join(',\n ');
const block = `\n manifestPlaceholders = [\n ${entriesStr}\n ]\n`;
const lines = gradle.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('REACT_NATIVE_RELEASE_LEVEL')) {
lines.splice(i + 1, 0, block);
break;
}
}
gradle = lines.join('\n');
changed = true;
}
if (changed) {
fs.writeFileSync(appBuildGradlePath, gradle);
console.log('[withOppoPush] updated app/build.gradle (AAR + manifestPlaceholders)');
}
}
// --- 2. 处理 proguard-rules.pro ---
if (fs.existsSync(proguardFilePath)) {
let proguard = fs.readFileSync(proguardFilePath, 'utf-8');
if (!proguard.includes('-keep class com.heytap.**')) {
const oppoRules = `
# JPush OPPO vendor channel — keep rules (https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide)
-dontwarn com.coloros.mcsdk.**
-dontwarn com.heytap.**
-dontwarn com.mcs.**
-keep class com.coloros.mcsdk.** { *; }
-keep class com.heytap.** { *; }
-keep class com.mcs.** { *; }
`;
fs.writeFileSync(proguardFilePath, proguard + oppoRules);
console.log('[withOppoPush] appended OPPO Proguard rules to proguard-rules.pro');
}
}
return config;
},
]);
return config;
};
module.exports = withOppoPush;

View File

@@ -0,0 +1,87 @@
/**
* withRemoveAutoStart — 移除应用退出后的自启动/关联启动行为
*
* 整改 2.2.6 APP频繁自启动和关联启动存在风险
*
* 问题:以下 SDK 注册了 BOOT_COMPLETED 等 intent-filter
* 导致设备重启时应用被自动唤醒:
* - expo.modules.taskManager.TaskBroadcastReceiver
* - expo.modules.notifications.service.NotificationsService
* - androidx.work.impl.background.systemalarm.RescheduleReceiver
* - androidx.work.impl.background.systemalarm.ConstraintProxy* (多个)
*
* 本插件在 final manifest 合并阶段移除这些 intent-filter action
* 从根本上消除应用退出后的自启动行为。
*/
const { withAndroidManifest } = require('expo/config-plugins');
// 需要从 receivers 中移除的自启动相关 action
const AUTO_START_ACTIONS = new Set([
'android.intent.action.BOOT_COMPLETED',
'android.intent.action.REBOOT',
'android.intent.action.QUICKBOOT_POWERON',
'com.htc.intent.action.QUICKBOOT_POWERON',
]);
function removeAutoStartActionsFromManifest(androidManifest) {
const app = androidManifest.manifest.application?.[0];
if (!app) return androidManifest;
// 处理 <receiver> 标签
const receivers = app.receiver || [];
for (const receiver of receivers) {
if (!receiver['intent-filter']) continue;
receiver['intent-filter'] = receiver['intent-filter'].map((filter) => {
if (!filter.action) return filter;
const originalCount = filter.action.length;
filter.action = filter.action.filter(
(action) => !AUTO_START_ACTIONS.has(action.$?.['android:name'])
);
if (filter.action.length !== originalCount) {
const removed = originalCount - filter.action.length;
console.log(
`[withRemoveAutoStart] 移除了 ${removed} 个自启动 action (receiver)`
);
}
return filter;
});
// 如果 intent-filter 中没有 action 了,移除整个 intent-filter
receiver['intent-filter'] = receiver['intent-filter'].filter(
(filter) => filter.action && filter.action.length > 0
);
}
// 移除 RECEIVE_BOOT_COMPLETED 权限声明
if (androidManifest.manifest['uses-permission']) {
const originalPerms = androidManifest.manifest['uses-permission'].length;
androidManifest.manifest['uses-permission'] = androidManifest.manifest[
'uses-permission'
].filter((perm) => {
const name = perm.$?.['android:name'];
return name !== 'android.permission.RECEIVE_BOOT_COMPLETED';
});
const removedPerms = originalPerms - androidManifest.manifest['uses-permission'].length;
if (removedPerms > 0) {
console.log(
`[withRemoveAutoStart] 移除了 RECEIVE_BOOT_COMPLETED 权限声明 (${removedPerms} 处)`
);
}
}
return androidManifest;
}
function withRemoveAutoStart(config) {
return withAndroidManifest(config, (config) => {
config.modResults = removeAutoStartActionsFromManifest(config.modResults);
return config;
});
}
module.exports = withRemoveAutoStart;

View File

@@ -35,6 +35,94 @@ const withSigning = (config, options = {}) => {
},
]);
config = withDangerousMod(config, [
'android',
async (config) => {
const platformRoot = config.modRequest.platformProjectRoot;
const buildGradlePath = path.join(platformRoot, 'app', 'build.gradle');
if (!fs.existsSync(buildGradlePath)) return config;
let lines = fs.readFileSync(buildGradlePath, 'utf-8').split('\n');
// 1. Add release signing config that reads from gradle.properties
const hasReleaseSigning = lines.some((l) => l.includes('MYAPP_UPLOAD_STORE_FILE'));
if (!hasReleaseSigning) {
// Find the debug signingConfig closing brace and insert release after it
let debugBraceLine = -1;
let inSigningConfigs = false;
let braceDepth = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes('signingConfigs {') || line.match(/^\s*signingConfigs\s*\{/)) {
inSigningConfigs = true;
braceDepth = 1;
continue;
}
if (inSigningConfigs) {
braceDepth += (line.match(/\{/g) || []).length;
braceDepth -= (line.match(/\}/g) || []).length;
if (braceDepth === 0) {
debugBraceLine = i; // This is the closing } of signingConfigs
break;
}
}
}
if (debugBraceLine > 0) {
const releaseBlock = [
' release {',
" if (project.hasProperty('MYAPP_UPLOAD_STORE_FILE')) {",
' storeFile file(MYAPP_UPLOAD_STORE_FILE)',
' storePassword MYAPP_UPLOAD_STORE_PASSWORD',
' keyAlias MYAPP_UPLOAD_KEY_ALIAS',
' keyPassword MYAPP_UPLOAD_KEY_PASSWORD',
' }',
' }',
];
lines.splice(debugBraceLine, 0, ...releaseBlock);
}
}
// 2. Change release buildType to use release signingConfig
// Walk through buildTypes > release and replace signingConfigs.debug -> signingConfigs.release
let inBuildTypes = false;
let inRelease = false;
let buildTypesDepth = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes('buildTypes {') || line.match(/^\s*buildTypes\s*\{/)) {
inBuildTypes = true;
buildTypesDepth = 1;
continue;
}
if (inBuildTypes) {
buildTypesDepth += (line.match(/\{/g) || []).length;
buildTypesDepth -= (line.match(/\}/g) || []).length;
if (line.match(/^\s*release\s*\{/)) {
inRelease = true;
}
if (inRelease && line.includes('signingConfig signingConfigs.debug')) {
lines[i] = line.replace('signingConfig signingConfigs.debug', 'signingConfig signingConfigs.release');
inRelease = false;
}
if (buildTypesDepth === 0) {
inBuildTypes = false;
inRelease = false;
}
}
}
fs.writeFileSync(buildGradlePath, lines.join('\n'));
return config;
},
]);
return config;
};

141
plugins/withVivoPush.js Normal file
View File

@@ -0,0 +1,141 @@
const { withDangerousMod } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
// vivo 厂商推送通道的 Expo config plugin.
//
// 客户端职责(依据极光官方文档 https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide
// 1) 添加 cn.jiguang.sdk.plugin:vivo 依赖
// 2) 注入 manifestPlaceholders: VIVO_APPKEY / VIVO_APPIDAAR 自带 vivo Receiver
// 3) 添加 Proguard 规则 (com.vivo.push.** + com.vivo.vms.** 保留)
//
// 注意事项:
// - 极光 JPUSH_CHANNEL 是「APK 分发渠道」统计字段,不是厂商通道开关,**不应覆盖**。
// - 客户端仅需要 VIVO_APPKEY / VIVO_APPID 两个占位符。
// VIVO_APP_SECRET 只在极光控制台「集成设置」页面填写,客户端不需要。
// - vivo AAR 已发布到 Maven Central不需要额外 Maven 仓库。
// - vivo 平台限制:若应用未上架 vivo 商店,测试推送时需在 vivo 推送平台添加测试设备,
// 且需通过 API 指定 push_mode=1测试推送下发。
//
// 实现说明:用 withDangerousMod 直接读写磁盘文件。
// 原因Expo SDK 56+ 的 withAppBuildGradle hook 链中,第二个以后的 plugin 写回
// modResults.contents 会被静默忽略,导致多个 vendor plugin 只能写入第一个的修改。
// withDangerousMod 走文件系统,串行、可靠,幂等检查保证可重复执行 prebuild。
const withVivoPush = (config, options = {}) => {
const {
jpushVersion = '6.1.0',
appId = '106100295',
appKey = '2110cb5f6dc5bd7abea82c38f8d0a1ec',
} = options;
config = withDangerousMod(config, [
'android',
async (config) => {
const appBuildGradlePath = path.join(
config.modRequest.platformProjectRoot,
'app',
'build.gradle'
);
const proguardFilePath = path.join(
config.modRequest.platformProjectRoot,
'app',
'proguard-rules.pro'
);
// --- 1. 处理 build.gradle ---
if (fs.existsSync(appBuildGradlePath)) {
let gradle = fs.readFileSync(appBuildGradlePath, 'utf-8');
let changed = false;
// 1a. 注入 AAR 依赖
if (!gradle.includes('cn.jiguang.sdk.plugin:vivo')) {
const depsPattern = /dependencies\s*\{/;
if (depsPattern.test(gradle)) {
gradle = gradle.replace(
depsPattern,
`dependencies {\n implementation 'cn.jiguang.sdk.plugin:vivo:${jpushVersion}'`
);
changed = true;
}
}
// 1b. 注入 manifestPlaceholders
const manifestPlaceholderRegex = /manifestPlaceholders\s*=\s*\[([\s\S]*?)\]/;
const match = gradle.match(manifestPlaceholderRegex);
const requiredPlaceholders = {
VIVO_APPKEY: appKey,
VIVO_APPID: appId,
};
if (match) {
const existingBlock = match[1];
const existingEntries = {};
const entryRegex = /(\w+)\s*:\s*"([^"]*)"/g;
let entryMatch;
while ((entryMatch = entryRegex.exec(existingBlock)) !== null) {
existingEntries[entryMatch[1]] = entryMatch[2];
}
let placeholdersChanged = false;
for (const [key, value] of Object.entries(requiredPlaceholders)) {
if (existingEntries[key] !== value) {
existingEntries[key] = value;
placeholdersChanged = true;
}
}
if (placeholdersChanged) {
const entriesStr = Object.entries(existingEntries)
.map(([k, v]) => `${k}: "${v}"`)
.join(',\n ');
const newBlock = `\n manifestPlaceholders = [\n ${entriesStr}\n ]`;
gradle = gradle.replace(manifestPlaceholderRegex, newBlock.trim());
changed = true;
}
} else if (gradle.includes('REACT_NATIVE_RELEASE_LEVEL')) {
// 没有 manifestPlaceholders block 时新建
const entriesStr = Object.entries(requiredPlaceholders)
.map(([k, v]) => `${k}: "${v}"`)
.join(',\n ');
const block = `\n manifestPlaceholders = [\n ${entriesStr}\n ]\n`;
const lines = gradle.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('REACT_NATIVE_RELEASE_LEVEL')) {
lines.splice(i + 1, 0, block);
break;
}
}
gradle = lines.join('\n');
changed = true;
}
if (changed) {
fs.writeFileSync(appBuildGradlePath, gradle);
console.log('[withVivoPush] updated app/build.gradle (AAR + manifestPlaceholders)');
}
}
// --- 2. 处理 proguard-rules.pro ---
if (fs.existsSync(proguardFilePath)) {
let proguard = fs.readFileSync(proguardFilePath, 'utf-8');
if (!proguard.includes('-keep class com.vivo.push.**')) {
const vivoRules = `
# JPush vivo vendor channel — keep rules (https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide)
-dontwarn com.vivo.push.**
-keep class com.vivo.push.** { *; }
-keep class com.vivo.vms.** { *; }
`;
fs.writeFileSync(proguardFilePath, proguard + vivoRules);
console.log('[withVivoPush] appended vivo Proguard rules to proguard-rules.pro');
}
}
return config;
},
]);
return config;
};
module.exports = withVivoPush;

133
plugins/withXiaomiPush.js Normal file
View File

@@ -0,0 +1,133 @@
const { withDangerousMod } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
// 小米厂商推送通道的 Expo config plugin.
//
// 客户端职责(依据极光官方文档 https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide
// 1) 添加 cn.jiguang.sdk.plugin:xiaomi 依赖
// 2) 注入 manifestPlaceholders: XIAOMI_APPKEY / XIAOMI_APPID
// 3) 添加 Proguard 规则 (com.xiaomi.push.** 保留)
//
// 实现说明:用 withDangerousMod 直接读写磁盘文件。
// 原因Expo SDK 56+ 的 withAppBuildGradle hook 链中,第二个以后的 plugin 写回
// modResults.contents 会被静默忽略(基于 withAndroidProjectBuildGradleBaseMod
// 导致多个 vendor plugin 只能写入第一个的修改。withDangerousMod 走文件系统,
// 串行、可靠,幂等检查保证可重复执行 prebuild。
const withXiaomiPush = (config, options = {}) => {
const {
jpushVersion = '6.1.0',
appId = '2882303761520539252',
appKey = '5792053978252',
} = options;
config = withDangerousMod(config, [
'android',
async (config) => {
const appBuildGradlePath = path.join(
config.modRequest.platformProjectRoot,
'app',
'build.gradle'
);
const proguardFilePath = path.join(
config.modRequest.platformProjectRoot,
'app',
'proguard-rules.pro'
);
// --- 1. 处理 build.gradle ---
if (fs.existsSync(appBuildGradlePath)) {
let gradle = fs.readFileSync(appBuildGradlePath, 'utf-8');
let changed = false;
// 1a. 注入 AAR 依赖
if (!gradle.includes('cn.jiguang.sdk.plugin:xiaomi')) {
const depsPattern = /dependencies\s*\{/;
if (depsPattern.test(gradle)) {
gradle = gradle.replace(
depsPattern,
`dependencies {\n implementation 'cn.jiguang.sdk.plugin:xiaomi:${jpushVersion}'`
);
changed = true;
}
}
// 1b. 注入 manifestPlaceholders
const manifestPlaceholderRegex = /manifestPlaceholders\s*=\s*\[([\s\S]*?)\]/;
const match = gradle.match(manifestPlaceholderRegex);
const requiredPlaceholders = {
XIAOMI_APPKEY: appKey,
XIAOMI_APPID: appId,
};
if (match) {
const existingBlock = match[1];
const existingEntries = {};
const entryRegex = /(\w+)\s*:\s*"([^"]*)"/g;
let entryMatch;
while ((entryMatch = entryRegex.exec(existingBlock)) !== null) {
existingEntries[entryMatch[1]] = entryMatch[2];
}
let placeholdersChanged = false;
for (const [key, value] of Object.entries(requiredPlaceholders)) {
if (existingEntries[key] !== value) {
existingEntries[key] = value;
placeholdersChanged = true;
}
}
if (placeholdersChanged) {
const entriesStr = Object.entries(existingEntries)
.map(([k, v]) => `${k}: "${v}"`)
.join(',\n ');
const newBlock = `\n manifestPlaceholders = [\n ${entriesStr}\n ]`;
gradle = gradle.replace(manifestPlaceholderRegex, newBlock.trim());
changed = true;
}
} else if (gradle.includes('REACT_NATIVE_RELEASE_LEVEL')) {
// 没有 manifestPlaceholders block 时新建
const entriesStr = Object.entries(requiredPlaceholders)
.map(([k, v]) => `${k}: "${v}"`)
.join(',\n ');
const block = `\n manifestPlaceholders = [\n ${entriesStr}\n ]\n`;
const lines = gradle.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('REACT_NATIVE_RELEASE_LEVEL')) {
lines.splice(i + 1, 0, block);
break;
}
}
gradle = lines.join('\n');
changed = true;
}
if (changed) {
fs.writeFileSync(appBuildGradlePath, gradle);
console.log('[withXiaomiPush] updated app/build.gradle (AAR + manifestPlaceholders)');
}
}
// --- 2. 处理 proguard-rules.pro ---
if (fs.existsSync(proguardFilePath)) {
let proguard = fs.readFileSync(proguardFilePath, 'utf-8');
if (!proguard.includes('-keep class com.xiaomi.push.**')) {
const xiaomiRules = `
# JPush Xiaomi vendor channel — keep rules (https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide)
-dontwarn com.xiaomi.push.**
-keep class com.xiaomi.push.** { *; }
`;
fs.writeFileSync(proguardFilePath, proguard + xiaomiRules);
console.log('[withXiaomiPush] appended Xiaomi Proguard rules to proguard-rules.pro');
}
}
return config;
},
]);
return config;
};
module.exports = withXiaomiPush;

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

@@ -4,7 +4,7 @@
* 检测输入中的 @ 字符,弹出关注者列表供选择
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
import {
View,
TextInput,
@@ -51,7 +51,12 @@ interface PostMentionInputProps {
returnKeyType?: 'default' | 'send' | 'done';
}
const PostMentionInput: React.FC<PostMentionInputProps> = ({
export interface PostMentionInputHandle {
focus: () => void;
blur: () => void;
}
const PostMentionInput = forwardRef<PostMentionInputHandle, PostMentionInputProps>(({
value,
onChangeText,
onSegmentsChange,
@@ -63,7 +68,7 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
onPostRefPasted,
onSubmitEditing,
returnKeyType = 'default',
}) => {
}, ref) => {
const colors = useAppColors();
const currentUser = useAuthStore(s => s.currentUser);
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
@@ -75,6 +80,21 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
const [postSearchTimer, setPostSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
const inputRef = useRef<TextInput>(null);
// 暴露 focus / blur 方法给父组件,用于在模式切换(如点击回复)后自动唤起键盘
useImperativeHandle(ref, () => ({
focus: () => {
// 展开动画/挂载需要时间,延迟一帧后再聚焦,避免在折叠态未真正展开时调用无效
requestAnimationFrame(() => {
setTimeout(() => {
inputRef.current?.focus();
}, 50);
});
},
blur: () => {
inputRef.current?.blur();
},
}), []);
useEffect(() => {
if (!loaded && currentUser?.id) {
loadFollowing();
@@ -361,7 +381,7 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
)}
</View>
);
};
});
const styles = StyleSheet.create({
container: {

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,12 +18,14 @@ 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';
export { default as VotePreview } from './VotePreview';
export { default as PostContentRenderer } from './PostContentRenderer';
export { default as PostMentionInput } from './PostMentionInput';
export type { PostMentionInputHandle } from './PostMentionInput';
export { default as BlockEditor } from './BlockEditor';
export type { BlockEditorHandle } from './BlockEditor';
export { default as ReportDialog } from './ReportDialog';

View File

@@ -213,6 +213,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
}
setSaving(true);
let downloaded: InstanceType<typeof File> | null = null;
try {
const urlPath = currentImage.url.split('?')[0];
const ext = urlPath.split('.').pop()?.toLowerCase() ?? 'jpg';
@@ -222,13 +223,14 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
const fileName = `withyou_${Date.now()}.${fileExt}`;
const destination = new File(Paths.cache, fileName);
// File.downloadFileAsync 是新版 expo-file-system/next 的静态方法
const downloaded = await File.downloadFileAsync(currentImage.url, destination);
// File.downloadFileAsync 是新版 expo-file-system 的静态方法
downloaded = await File.downloadFileAsync(currentImage.url, destination, {
idempotent: true,
});
await MediaLibrary.saveToLibraryAsync(downloaded.uri);
// 清理缓存文件
downloaded.delete();
// expo-media-library v17+ 已移除顶层 saveToLibraryAsync,改用 Asset.create()
// 在 Android 上 filePath 必须以 file:/// 开头
await MediaLibrary.Asset.create(downloaded.uri);
onSave?.(currentImage.url);
showToast('success');
@@ -236,6 +238,14 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
console.error('[ImageGallery] 保存图片失败:', err);
showToast('error');
} finally {
// 清理缓存文件(无论成功失败都清理,避免残留)
if (downloaded) {
try {
downloaded.delete();
} catch (cleanupErr) {
console.warn('[ImageGallery] 清理缓存文件失败:', cleanupErr);
}
}
setSaving(false);
}
}, [currentImage, saving, onSave, showToast]);

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

@@ -236,6 +236,14 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
const currentState = usePostListStore.getState().getPostsState(listKey);
syncFromStore(currentState);
// listKey 切换时,如果目标列表在 store 中还没有有效数据(首次访问),
// 立即进入 loading 态,避免先渲染空列表再切到 loading 造成闪烁。
// syncFromStore 会用 store 的 isLoading 覆盖,因此在其之后补设。
// 如果已有数据(曾访问过),则保留旧数据,由后续 fetch 静默刷新。
if (!currentState.posts || currentState.posts.length === 0) {
setLoading(true);
}
const unsubscribe = usePostListStore.subscribe(state => {
const postsState = state.postsStateMap.get(listKey);
if (postsState) syncFromStore(postsState);
@@ -277,6 +285,18 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
const reset = useCallback(() => {
calculatorRef.current?.reset();
batcherRef.current?.clearPending();
// 同步清空 store 中对应 listKey 的数据,避免 subscribe 回调
// 在切换 listKey 时把上一个 key 的残留数据重新同步回本地造成闪烁
usePostListStore.getState().updatePostsState(listKey, {
posts: [],
cursor: null,
currentPage: 1,
hasMore: true,
isLoading: false,
isRefreshing: false,
error: null,
lastParams: undefined,
});
setPosts([]);
setLoading(false);
setRefreshing(false);
@@ -284,7 +304,7 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
setHasMore(true);
previousPostsRef.current = [];
setDiffUpdates({ addedCount: 0, updatedCount: 0, deletedCount: 0, lastUpdateTime: 0 });
}, []);
}, [listKey]);
const forceUpdate = useCallback((newPosts: T[]) => {
setPosts(newPosts);

View File

@@ -9,6 +9,12 @@ export function useRegisterPushDevice(isAuthenticated: boolean, userID?: string)
useEffect(() => {
if (Platform.OS === 'web' || !isAuthenticated || !userID) return;
// JPush 初始化只取决于登录态——这是基础推送通道(获取 RegistrationID、
// 向服务器注册设备 token与"是否允许后台自启动/保活"无关。
// 后台保活由 backgroundService 控制;退后台是否保持 JPush 长连接由
// jpushService._setBackgroundKeepLongConn() 根据 autoStart 同意状态自行处理。
// 若在此处用 consent 拦截 init未同意用户将永远拿不到 RegistrationID
// 即便在前台/已授予通知权限也无法收到推送。
if (!deviceRegistered.current) {
deviceRegistered.current = true;

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');
@@ -437,7 +491,6 @@ export const HomeScreen: React.FC = () => {
refreshing: isRefreshing,
hasMore,
error,
reset,
} = useDifferentialPosts<Post>(
[],
{
@@ -497,8 +550,9 @@ export const HomeScreen: React.FC = () => {
useEffect(() => {
let cancelled = false;
const run = async () => {
reset();
await new Promise(resolve => requestAnimationFrame(resolve));
// 注意:不再在此处调用 reset()。
// fetchPosts 内部会在参数变化时立即清空 store 中的旧数据并设置 loading
// 这样可以避免本地清空后、请求发起前的间隙渲染到上一个分区的帖子造成闪烁。
if (!cancelled) {
await refresh();
}
@@ -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,11 +37,13 @@ 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';
import { useCursorPagination } from '../../hooks/useCursorPagination';
import { CommentItem, VoteCard, ReportDialog, ShareSheet, PostContentRenderer, PostMentionInput } from '../../components/business';
import type { PostMentionInputHandle } from '../../components/business';
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks';
import {
@@ -261,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); // 是否已记录浏览量
@@ -276,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,
@@ -384,35 +416,45 @@ export const PostDetailScreen: React.FC = () => {
router.replace(hrefs.hrefHome());
}, [router]);
const renderCustomHeader = () => (
<View style={styles.customHeader}>
<AppBackButton onPress={handleBackPress} style={styles.headerBackButton} />
{post?.author ? (
<View style={styles.headerContainer}>
<TouchableOpacity
onPress={() => handleUserPress(post.author!.id)}
style={styles.headerAvatarWrapper}
>
<Avatar source={post.author.avatar} size={32} name={post.author.nickname} />
</TouchableOpacity>
<View style={styles.headerUserInfo}>
<TouchableOpacity onPress={() => handleUserPress(post.author!.id)}>
<Text style={styles.headerNickname} numberOfLines={1}>
{post.author.nickname && post.author.nickname.length > 10
? `${post.author.nickname.slice(0, 10)}...`
: post.author.nickname}
</Text>
const renderCustomHeader = () => {
const followersCount = authorFollowersCount ?? post?.author?.followers_count ?? 0;
return (
<View style={styles.customHeader}>
<AppBackButton onPress={handleBackPress} style={styles.headerBackButton} />
{post?.author ? (
<View style={styles.headerContainer}>
<TouchableOpacity
onPress={() => handleUserPress(post.author!.id)}
style={styles.headerAvatarWrapper}
activeOpacity={0.75}
>
<Avatar source={post.author.avatar} size={42} name={post.author.nickname} />
</TouchableOpacity>
<View style={styles.headerUserInfo}>
<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>
) : (
<View style={styles.headerPlaceholder} />
)}
<View style={styles.headerRightContainer}>
{renderFollowButton()}
</View>
) : (
<View style={styles.headerPlaceholder} />
)}
<View style={styles.headerRightContainer}>
{renderFollowButton()}
</View>
</View>
);
);
};
// 监听键盘事件
useEffect(() => {
@@ -1007,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) {
@@ -1271,10 +1315,22 @@ export const PostDetailScreen: React.FC = () => {
// 回复评论
const [replyingTo, setReplyingTo] = useState<Comment | null>(null);
const [isComposerVisible, setIsComposerVisible] = useState(false);
const commentInputRef = useRef<PostMentionInputHandle>(null);
// 展开编辑器后自动聚焦输入框、唤起键盘,提升回复体验
const focusCommentInput = useCallback(() => {
// 展开态需要先渲染,延迟一帧再 focus避免 ref 尚未挂载到真实 TextInput
requestAnimationFrame(() => {
setTimeout(() => {
commentInputRef.current?.focus();
}, 60);
});
}, []);
const handleReply = (comment: Comment) => {
setReplyingTo(comment);
setIsComposerVisible(true);
focusCommentInput();
};
const handleCancelReply = () => {
@@ -1345,31 +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 + '@');
@@ -1471,6 +1538,7 @@ export const PostDetailScreen: React.FC = () => {
{/* Text Input —— 小红书风格:无边框、自动扩展 */}
<PostMentionInput
ref={commentInputRef}
value={commentText}
onChangeText={setCommentText}
onSegmentsChange={setCommentSegments}
@@ -1507,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}
@@ -1867,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,
@@ -1879,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: {
@@ -1907,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: {
@@ -1946,7 +2067,7 @@ function createPostDetailStyles(colors: AppColors) {
fontWeight: '600',
},
followButtonTextPrimary: {
color: colors.text.inverse,
color: colors.primary.main,
},
followButtonTextOutline: {
color: colors.text.secondary,
@@ -2279,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

@@ -12,6 +12,7 @@ import {
TouchableOpacity,
ScrollView,
RefreshControl,
Animated,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
@@ -23,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, TabBar, 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';
@@ -94,6 +95,25 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
// 保存当前搜索关键词用于Tab切换时重新搜索
const [currentKeyword, setCurrentKeyword] = useState('');
// 入场动画
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(20)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}),
]).start();
}, []);
const searchExtraParams = useMemo(() => ({ query: currentKeyword }), [currentKeyword]);
const {
@@ -508,10 +528,16 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
if (hasSearched) return null;
return (
<View style={[styles.suggestionsContainer, { paddingHorizontal: responsivePadding }]}>
<Animated.View
style={[
styles.suggestionsContainer,
{ paddingHorizontal: responsivePadding },
{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }
]}
>
{/* 搜索历史 */}
{history.length > 0 && (
<View style={[styles.section, { marginTop: responsiveGap }]}>
<View style={[styles.section, { marginTop: responsiveGap * 1.5 }]}>
<View style={styles.sectionHeader}>
<Text
variant="body"
@@ -522,7 +548,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
>
</Text>
<TouchableOpacity onPress={handleClearHistory}>
<TouchableOpacity onPress={handleClearHistory} activeOpacity={0.7}>
<MaterialCommunityIcons name="delete-outline" size={isDesktop ? 22 : 18} color={colors.text.secondary} />
</TouchableOpacity>
</View>
@@ -540,6 +566,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
}
]}
onPress={() => handleHistoryPress(keyword)}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="history" size={isDesktop ? 16 : 14} color={colors.text.secondary} />
<Text
@@ -557,48 +584,36 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
</View>
</View>
)}
</View>
{/* 空状态提示 */}
{history.length === 0 && (
<View style={styles.emptySuggestions}>
<EmptyState
title="开始搜索"
description="输入关键词搜索帖子、用户或商品"
icon="magnify"
/>
</View>
)}
</Animated.View>
);
};
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
value={searchText}
onChangeText={setSearchText}
onSubmit={handleSearch}
placeholder={isMarket ? "搜索商品、用户" : "搜索帖子、用户"}
autoFocus
/>
</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>
{/* 搜索顶栏(统一组件,内置硬件返回键拦截) */}
<SearchHeader
value={searchText}
onChangeText={setSearchText}
onSubmit={handleSearch}
onCancel={onBack ? onBack : () => router.back()}
placeholder={isMarket ? "搜索商品、用户" : "搜索帖子、用户"}
compact
searchBarMaxWidth={searchBarMaxWidth}
horizontalPadding={responsivePadding}
/>
{/* Tab切换 */}
{/* Tab切换 - 与主页风格一致的下划线样式 */}
<View style={styles.tabWrapper}>
<TabBar
tabs={TABS}
@@ -609,8 +624,8 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
performSearch(currentKeyword);
}
}}
variant="modern"
icons={isMarket ? ['shopping-outline', 'account-outline'] : ['file-document-outline', 'account-outline']}
variant="home"
style={{ paddingHorizontal: responsivePadding }}
/>
</View>
@@ -626,34 +641,19 @@ function createSearchScreenStyles(colors: AppColors) {
flex: 1,
backgroundColor: colors.background.default,
},
searchHeader: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: `${colors.divider}70`,
},
searchShell: {
flex: 1,
},
cancelButton: {
backgroundColor: `${colors.primary.main}12`,
borderRadius: borderRadius.full,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
cancelText: {
fontSize: fontSizes.md,
fontWeight: '600',
},
tabWrapper: {
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: `${colors.divider}50`,
backgroundColor: colors.background.default,
paddingVertical: spacing.xs,
},
suggestionsContainer: {
flex: 1,
},
emptySuggestions: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
marginTop: -40,
},
section: {
marginTop: spacing.md,
},
@@ -674,13 +674,14 @@ function createSearchScreenStyles(colors: AppColors) {
tag: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: `${colors.primary.main}10`,
backgroundColor: colors.background.paper,
borderRadius: borderRadius.full,
borderWidth: 0,
borderWidth: 1,
borderColor: colors.divider,
},
tagText: {
marginLeft: spacing.xs,
color: colors.primary.main,
color: colors.text.secondary,
fontWeight: '500',
},
userCard: {

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';
@@ -159,7 +160,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
longPressMenuVisible,
selectedMessage,
selectedMessageId,
setSelectedMessageId,
menuPosition,
isGroupChat,
groupInfo,
@@ -174,6 +174,10 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
effectiveGroupName,
otherUserLastReadSeq,
messageMap,
// 引用消息回填(会话级缓存:内存 → 本地 SQLite → 服务端)
getCachedReply,
ensureReplyMessage,
jumpToMessageSeq,
loadingMore,
hasMoreHistory,
conversationId,
@@ -201,13 +205,13 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
handleDeleteMessage,
handleReplyMessage,
handleCancelReply,
handleRetrySend,
handleAvatarPress,
handleAvatarLongPress,
handleSelectMention,
handleMentionAll,
getSenderInfo,
getTypingHint,
getInputBottom,
handleDismiss,
navigateToInfo,
navigateToChatSettings,
@@ -216,6 +220,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
handleReachLatestEdge,
jumpToLatestMessages,
setBrowsingHistory,
clearHistoryLock,
} = useChatScreen(props);
const stableOnBack = useCallback(() => router.back(), [router]);
@@ -281,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);
// 1. 内存命中:直接滚动定位(与原逻辑一致,最快路径)
const targetIndex = displayMessages.findIndex(msg => String(msg.id) === targetId);
if (targetIndex < 0) return;
if (targetIndex >= 0) {
replyTargetMessageIdRef.current = targetId;
setBrowsingHistory(true);
try {
flatListRef.current?.scrollToIndex({
index: targetIndex,
animated: true,
viewPosition: 0.5,
});
} 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;
setBrowsingHistory(true);
flatListRef.current?.scrollToIndex({
index: targetIndex,
animated: true,
viewPosition: 0.5,
});
}, [displayMessages, flatListRef, setBrowsingHistory]);
const accepted = jumpToMessageSeq ? jumpToMessageSeq(filled.seq) : false;
if (!accepted) {
Alert.alert('无法定位该引用消息');
}
}, [displayMessages, flatListRef, setBrowsingHistory, ensureReplyMessage, jumpToMessageSeq]);
// 监听返回事件:面板/键盘打开时先关闭它们,否则刷新会话列表后返回
useEffect(() => {
@@ -359,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}
@@ -367,6 +397,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
onImagePress={handleImagePress}
onReply={handleReplyMessage}
onReplyPress={handleReplyPreviewPress}
onRetrySend={handleRetrySend}
/>
);
}, [
@@ -379,6 +410,8 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
otherUserLastReadSeq,
selectedMessageId,
messageMap,
getCachedReply,
ensureReplyMessage,
handleLongPressMessage,
handleAvatarPress,
handleAvatarLongPress,
@@ -387,6 +420,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
handleImagePress,
handleReplyMessage,
handleReplyPreviewPress,
handleRetrySend,
]);
const keyExtractor = useCallback((item: GroupMessage) => String(item.id), []);
@@ -414,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 增大且距离尾部变小
@@ -462,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>
{value && (
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
{value}
{subtitle && (
<Text variant="caption" color={colors.text.secondary} numberOfLines={2} style={styles.settingItemSubtitle}>
{subtitle}
</Text>
)}
</View>
<View style={styles.settingValueRow}>
{value && (
<Text variant="body" color={colors.text.secondary} numberOfLines={1} style={styles.settingValueText}>
{value}
</Text>
)}
{onPress && (
<Text variant="body" color={colors.text.hint} style={styles.settingChevron}>
</Text>
)}
</View>
{onPress && (
<MaterialCommunityIcons
name="chevron-right"
size={22}
color={colors.text.hint}
/>
)}
</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} />
<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>
<Text variant="body" color={colors.text.hint} style={styles.settingChevron}></Text>
</View>
<Text variant="label" style={styles.cardTitle}></Text>
<Text variant="caption" color={colors.text.secondary} style={styles.memberCount}>
{group.member_count}
</Text>
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
</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>
<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
value={searchText}
onChangeText={setSearchText}
onSubmit={() => performSearch(searchText)}
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
autoFocus
/>
</View>
<TouchableOpacity
style={styles.cancelButton}
onPress={handleCloseSearch}
activeOpacity={0.7}
>
<Text style={styles.cancelText}></Text>
</TouchableOpacity>
</View>
{/* 搜索顶栏(统一组件,内置硬件返回键拦截) */}
<SearchHeader
value={searchText}
onChangeText={setSearchText}
onSubmit={() => performSearch(searchText)}
onCancel={handleCloseSearch}
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
compact
/>
{/* 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
value={keyword}
onChangeText={handleTextChanged}
onSubmit={handleSubmit}
placeholder="搜索聊天记录"
autoFocus
/>
</View>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* 搜索顶栏(与 HomeScreen 搜索页一致的统一组件) */}
<SearchHeader
value={keyword}
onChangeText={handleTextChanged}
onSubmit={handleSubmit}
onCancel={() => router.back()}
placeholder={`搜索 ${conversationName} 的聊天记录`}
compact
/>
<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.card}>
<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.card}>
<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.card}>
<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) {
const replyData = replySegment.data as { id: string; seq?: number };
const replyId = String(replyData.id);
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,17 +469,45 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
{renderMessageContent()}
{/* 私聊模式:显示已读标记 */}
{isLastReadMessage && (
<View style={styles.readStatusContainer}>
<MaterialCommunityIcons
name="check-all"
size={14}
color="#34C759"
/>
<Text style={[styles.readStatusText, styles.readStatusTextRead]}>
</Text>
{/* 发送状态指示器 */}
{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}
color="#34C759"
/>
<Text style={[styles.readStatusText, styles.readStatusTextRead]}>
</Text>
</View>
)}
</View>
)}
</View>
@@ -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;
@@ -476,23 +479,102 @@ const renderVideoSegment = (data: VideoSegmentData, isMe: boolean): React.ReactN
return <VideoSegment key={`video-${data.url}`} data={data} isMe={isMe} />;
};
/**
* 根据 MIME 类型返回文件图标名与主题色
*/
function getFileVisual(mime?: string): { icon: string; color: string } {
if (!mime) return { icon: 'file-document-outline', color: '#5C6BC0' };
const m = mime.toLowerCase();
if (m === 'application/pdf') return { icon: 'file-pdf-box', color: '#E53935' };
if (m.includes('word') || m === 'application/msword') return { icon: 'file-word-box', color: '#1E88E5' };
if (m.includes('excel') || m === 'application/vnd.ms-excel' || m === 'text/csv') return { icon: 'file-excel-box', color: '#43A047' };
if (m.includes('powerpoint') || m === 'application/vnd.ms-powerpoint') return { icon: 'file-powerpoint-box', color: '#FB8C00' };
if (m === 'application/zip' || m.includes('compressed') || m.includes('rar') || m.includes('7z') || m.includes('gzip') || m.includes('tar')) {
return { icon: 'folder-zip-outline', color: '#8D6E63' };
}
if (m.startsWith('audio/')) return { icon: 'file-music-outline', color: '#8E24AA' };
if (m.startsWith('video/')) return { icon: 'file-video-outline', color: '#00897B' };
if (m.startsWith('image/')) return { icon: 'file-image-outline', color: '#FF6B35' };
if (m === 'application/json' || m === 'text/xml' || m === 'application/xml') return { icon: 'code-json', color: '#455A64' };
if (m === 'text/plain' || m === 'text/markdown') return { icon: 'file-document-outline', color: '#607D8B' };
return { icon: 'file-document-outline', color: '#5C6BC0' };
}
/**
* 打开/下载文件:移动端用浏览器在新页面打开 URL系统会提示下载/预览),
* 此处不再依赖 expo-file-systemSDK 56 已废弃 downloadAsync
*/
async function openRemoteFile(url: string, name: string) {
if (!url) return;
try {
await Linking.openURL(url);
} catch (error) {
console.warn('打开文件失败:', error);
}
}
/**
* 渲染文件 Segment
* 当 data.expired 为 true 时显示"文件已过期"失效态,禁用点击。
*/
const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ data, isMe }) => {
const styles = useSegmentStyles();
const themeColors = useAppColors();
const [downloading, setDownloading] = useState(false);
const fileSize = data.size ? formatFileSize(data.size) : '';
const visual = getFileVisual(data.mime_type);
const isExpired = !!data.expired;
const handlePress = async () => {
if (isExpired || !data.url || downloading) return;
setDownloading(true);
try {
await openRemoteFile(data.url, data.name || 'file');
} finally {
setDownloading(false);
}
};
// 失效态:灰态卡片 + 时钟图标 + "文件已过期",禁用点击
if (isExpired) {
return (
<View
style={[styles.fileContainer, styles.fileExpired, isMe ? styles.fileMe : styles.fileOther]}
pointerEvents="none"
>
<View style={[styles.fileIcon, { backgroundColor: 'rgba(150,150,150,0.15)' }]}>
<MaterialCommunityIcons
name="clock-alert-outline"
size={26}
color={themeColors.chat.textTertiary}
/>
</View>
<View style={styles.fileInfo}>
<Text
style={[styles.fileName, styles.fileExpiredText]}
numberOfLines={1}
>
{data.name}
</Text>
<Text style={[styles.fileSize, styles.fileExpiredText]}>
</Text>
</View>
</View>
);
}
return (
<TouchableOpacity
style={[styles.fileContainer, isMe ? styles.fileMe : styles.fileOther]}
onPress={handlePress}
activeOpacity={0.7}
>
<View style={styles.fileIcon}>
<View style={[styles.fileIcon, { backgroundColor: `${visual.color}22` }]}>
<MaterialCommunityIcons
name="file-document"
size={28}
color={isMe ? themeColors.primary.contrast : themeColors.primary.main}
name={visual.icon as any}
size={26}
color={isMe ? themeColors.primary.contrast : visual.color}
/>
</View>
<View style={styles.fileInfo}>
@@ -502,8 +584,15 @@ const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ d
>
{data.name}
</Text>
{fileSize ? <Text style={styles.fileSize}>{fileSize}</Text> : null}
<Text style={styles.fileSize}>
{downloading ? '下载中…' : (fileSize || (data.mime_type || '文件'))}
</Text>
</View>
<MaterialCommunityIcons
name={downloading ? 'progress-download' : 'download-outline'}
size={20}
color={isMe ? themeColors.primary.contrast : themeColors.chat.textTertiary}
/>
</TouchableOpacity>
);
};
@@ -582,6 +671,8 @@ const renderLinkSegment = (
export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
replyData,
replyMessage,
replyLoading,
replyNotFound,
isMe,
onPress,
getSenderInfo,
@@ -591,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]}
@@ -601,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>
@@ -680,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;
@@ -692,6 +790,8 @@ const MessageSegmentsRendererInner: React.FC<{
currentUserId,
memberMap,
replyMessage,
replyLoading,
replyNotFound,
onAtPress,
onReplyPress,
onImagePress,
@@ -725,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}
@@ -977,7 +1079,10 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
overflow: 'hidden',
},
// 文件 - QQ风格卡片式设计
// 文件 - 卡片式设计
// 注意:文件卡片渲染在聊天气泡内部,因此卡片本身不再绘制背景与阴影。
// 自带的底色会与气泡背景叠加出「白框」,阴影会被气泡的 overflow:'hidden'
// 裁切成「黑框」,二者都会遮挡文件内容。这里改为透明,让内容直接落在气泡上。
fileContainer: {
flexDirection: 'row',
alignItems: 'center',
@@ -985,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,
@@ -1021,6 +1121,14 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
fontWeight: '500',
},
// 文件过期失效态
fileExpired: {
opacity: 0.65,
},
fileExpiredText: {
color: colors.chat.textTertiary,
},
// 链接 - QQ风格卡片式设计
linkContainer: {
borderRadius: 16,
@@ -1136,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

@@ -178,13 +178,6 @@ export const MORE_ACTIONS: MoreAction[] = [
color: '#5C6BC0',
gradientColors: ['#7986CB', '#3F51B5'],
},
{
id: 'location',
icon: 'map-marker',
name: '位置',
color: '#EC407A',
gradientColors: ['#F06292', '#D81B60'],
},
];
// 消息撤回时间限制(毫秒)

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

@@ -2,7 +2,7 @@
* ChatScreen 类型定义
*/
import { MessageResponse, UserDTO, GroupMemberResponse, GroupResponse } from '../../../../types/dto';
import { MessageResponse, UserDTO, GroupMemberResponse } from '../../../../types/dto';
// 面板类型
export type PanelType = 'none' | 'emoji' | 'more' | 'mention';
@@ -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;
}
/** 输入框中待发送的本地图片(未上传) */
@@ -215,39 +221,3 @@ export interface SwipeableMessageBubbleProps {
onReply: () => void;
enabled?: boolean;
}
// ChatScreen 状态接口
export interface ChatScreenState {
// 基础状态
messages: GroupMessage[];
conversationId: string | null;
inputText: string;
otherUser: UserDTO | null;
currentUser: UserDTO | null;
keyboardHeight: number;
loading: boolean;
sending: boolean;
currentUserId: string;
lastSeq: number;
otherUserLastReadSeq: number;
activePanel: PanelType;
sendingImage: boolean;
// 回复消息状态
replyingTo: GroupMessage | null;
// 长按菜单状态
longPressMenuVisible: boolean;
selectedMessage: GroupMessage | null;
// 群聊相关状态
groupInfo: GroupResponse | null;
groupMembers: GroupMemberResponse[];
typingUsers: string[];
currentUserRole: UserRole;
mentionQuery: string;
selectedMentions: string[];
mentionAll: boolean;
isMuted: boolean;
muteAll: boolean;
}

View File

@@ -20,7 +20,9 @@ import {
import { useLocalSearchParams, router } from 'expo-router';
import { formatChatTime } from '@/utils/formatTime';
import * as ImagePicker from 'expo-image-picker';
import { GroupMemberResponse, MessageSegment, TextSegmentData, ImageSegmentData, AtSegmentData, ReplySegmentData, MessageStatus } from '../../../../types/dto';
import * as DocumentPicker from 'expo-document-picker';
import { Linking } from 'react-native';
import { GroupMemberResponse, MessageSegment, TextSegmentData, ImageSegmentData, FileSegmentData, AtSegmentData, ReplySegmentData, MessageStatus } from '../../../../types/dto';
import { messageService } from '@/services/message';
import { uploadService } from '@/services/upload';
import { ApiError } from '@/services/core';
@@ -44,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;
@@ -151,6 +154,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
loadMoreMessages,
refreshMessages,
sendMessage: sendMessageViaManager,
retrySendMessage: retrySendMessageViaManager,
isSending: isSendingViaManager,
markAsRead,
conversation,
@@ -181,8 +185,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
const [keyboardHeight, setKeyboardHeight] = useState(0);
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [lastSeq, setLastSeq] = useState<number>(0);
const [firstSeq, setFirstSeq] = useState<number>(0);
const [otherUserLastReadSeq, setOtherUserLastReadSeq] = useState<number>(0);
const [activePanel, setActivePanel] = useState<PanelType>('none');
const [sendingImage, setSendingImage] = useState(false);
@@ -196,11 +198,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
// 滚动状态机 refsTelegram/Element 风格:底部粘附 + 阅读锚点)
const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0, viewportHeight: 0 });
const hasInitialAnchorDoneRef = useRef(false);
const prevMessageCountRef = useRef(0);
const prevLatestSeqRef = useRef(0);
const prevMarkedReadSeqRef = useRef(0);
const enterMarkedKeyRef = useRef<string>('');
const isProgrammaticScrollRef = useRef(false);
const suppressAutoFollowRef = useRef(false);
const isBrowsingHistoryRef = useRef(false);
const hasShownMessageListRef = useRef(false);
@@ -229,6 +229,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
setGroupInfo(prev => prev ? prev : { name: name || undefined, avatar: avatar || null });
}
}, [isGroupChat, effectiveGroupName, effectiveGroupAvatar]);
const [currentUserRole, setCurrentUserRole] = useState<UserRole>('member');
const [mentionQuery, setMentionQuery] = useState<string>('');
const [selectedMentions, setSelectedMentions] = useState<string[]>([]);
@@ -369,10 +370,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
};
}, [isGroupChat, otherUserId]);
// 进入新会话时重置滚动状态
// 进入新会话时重置滚动/草稿状态
useEffect(() => {
hasInitialAnchorDoneRef.current = false;
prevMessageCountRef.current = 0;
prevLatestSeqRef.current = 0;
prevMarkedReadSeqRef.current = 0;
enterMarkedKeyRef.current = '';
@@ -381,9 +381,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
hasShownMessageListRef.current = false;
historyLoadingLockUntilRef.current = 0;
scrollToSeqRef.current = routeScrollToSeq ?? null;
}, [conversationId]);
useEffect(() => {
setPendingAttachments([]);
}, [conversationId]);
@@ -403,14 +400,10 @@ export const useChatScreen = (props?: ChatScreenProps) => {
const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => {
if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked())) return;
// inverted 列表下,最新消息端对应 offset=0
isProgrammaticScrollRef.current = true;
flatListRef.current?.scrollToOffset({
offset: 0,
animated,
});
setTimeout(() => {
isProgrammaticScrollRef.current = false;
}, animated ? 220 : 32);
}, [flatListRef, isHistoryLoadingLocked]);
const isNearBottom = useCallback(() => {
@@ -420,18 +413,23 @@ export const useChatScreen = (props?: ChatScreenProps) => {
}, [scrollPositionRef]);
// 首屏加载完成后仅锚定一次到最新消息端(有 scrollToSeq 时跳过)
// 注意:必须在消息首次出现时立刻把 hasInitialAnchorDoneRef 置为 true
// 不能再依赖 viewportHeight 等通过 ref 同步的值——否则后续首次 loadMoreHistory
// 让 messages.length 变化时此 effect 会被再度命中,并以 force=true 调用
// scrollToLatest 强行把列表拉回最底端,造成"上滑首次加载更多就回底"的问题。
useEffect(() => {
if (
loading ||
loadingMore ||
messages.length === 0 ||
hasInitialAnchorDoneRef.current ||
scrollPositionRef.current.viewportHeight <= 0
hasInitialAnchorDoneRef.current
) {
return;
}
hasInitialAnchorDoneRef.current = true;
if (scrollToSeqRef.current != null) return; // 由下方 scrollToSeq effect 处理
// inverted FlashList 天然从 offset=0最新端开始
// 这里仅做一次保险锚定,即使布局尚未就绪也不会有副作用。
const timer = setTimeout(() => {
scrollToLatest(false, true, 'initial-anchor');
}, 0);
@@ -442,10 +440,8 @@ export const useChatScreen = (props?: ChatScreenProps) => {
// 仅当"最新端消息 seq 增长"且用户在底部附近时才跟随;
// 历史加载只会增加旧消息,不会提升 latest seq因此不会触发回底。
useEffect(() => {
const currentCount = messages.length;
const latestSeq = currentCount > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
const latestSeq = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
const prevLatestSeq = prevLatestSeqRef.current;
prevMessageCountRef.current = currentCount;
prevLatestSeqRef.current = latestSeq;
if (loading || loadingMore) return;
@@ -575,33 +571,20 @@ export const useChatScreen = (props?: ChatScreenProps) => {
};
}, [routeUserId, conversationId, currentUserId, isGroupChat]);
// 加载更多历史消息inverted 下保持阅读锚点
// 加载更多历史消息inverted + maintainVisibleContentPosition 由列表原生保持位置
const loadMoreHistory = useCallback(async () => {
if (!conversationId || !hasMoreHistory || loadingMore) {
return;
}
// 历史加载期间禁止新消息自动跟随到底
// 历史加载期间禁止"新消息自动跟随到底"
suppressAutoFollowRef.current = true;
isBrowsingHistoryRef.current = true;
historyLoadingLockUntilRef.current = Date.now() + 3000;
// 保存加载前的滚动位置和内容高度
const scrollYBefore = scrollPositionRef.current.scrollY;
const contentHeightBefore = scrollPositionRef.current.contentHeight;
setLoadingMore(true);
try {
await loadMoreMessages();
// 更新 firstSeq
if (messages.length > 0) {
const minSeq = Math.min(...messages.map(m => m.seq));
setFirstSeq(minSeq);
}
// inverted + maintainVisibleContentPosition 下由列表原生保持位置
// 不做手动 scrollToOffset避免与原生锚点冲突导致回到底部
} catch (error) {
console.error('加载历史消息失败:', error);
} finally {
@@ -609,7 +592,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
// 加载结束后继续保留短暂锁窗,避免布局结算阶段误触自动回底
historyLoadingLockUntilRef.current = Date.now() + 800;
}
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]);
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages]);
// 从搜索结果跳转:滚动到目标 seq
useEffect(() => {
@@ -625,11 +608,16 @@ export const useChatScreen = (props?: ChatScreenProps) => {
const ascendingIndex = messages.findIndex(m => m.seq === targetSeq);
const displayIndex = messages.length - 1 - ascendingIndex;
setTimeout(() => {
flatListRef.current?.scrollToIndex({
index: displayIndex,
animated: false,
viewPosition: 0.5,
});
try {
flatListRef.current?.scrollToIndex({
index: displayIndex,
animated: false,
viewPosition: 0.5,
});
} catch {
// FlashList 远距离跳转可能失败,降级到 offset 滚动
flatListRef.current?.scrollToOffset?.({ offset: 0, animated: false });
}
}, 50);
return;
}
@@ -641,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;
@@ -658,13 +685,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
isBrowsingHistoryRef.current = false;
// FlashList 在 inverted 模式下 scrollToOffset({ offset: 0 }) 可能无法真正滚到最底部,
// 因为列表内容高度变化后最小 offset 可能不是 0。先尝试滚到负值确保到底再补偿回 0。
isProgrammaticScrollRef.current = true;
flatListRef.current?.scrollToOffset({ offset: -99999, animated: false });
requestAnimationFrame(() => {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
setTimeout(() => {
isProgrammaticScrollRef.current = false;
}, 220);
});
}, [flatListRef]);
@@ -672,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;
@@ -698,6 +732,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
// 自动标记已读QQ/Telegram 风格):
// 仅当出现更大的 latest seq且用户在最新端附近时才上报。
// 历史加载/浏览历史不会触发 read。
// 注意:不能复用 prevLatestSeqRef 做判定——它被新消息跟随 effect 无条件推进,
// 会导致这里的去重判断永远成立,标记已读逻辑事实上不会执行。
// 这里只依赖专属的 prevMarkedReadSeqRef 做去重。
useEffect(() => {
if (!conversationId || messages.length === 0) return;
if (loading || loadingMore) return;
@@ -707,10 +744,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
const latestSeq = Math.max(...messages.map(m => m.seq), 0);
if (latestSeq <= 0) return;
// 没有新增最新消息,不重复上报
if (latestSeq <= prevLatestSeqRef.current) return;
prevLatestSeqRef.current = latestSeq;
// 避免对同一 seq 重复标记
if (latestSeq <= prevMarkedReadSeqRef.current) return;
prevMarkedReadSeqRef.current = latestSeq;
@@ -720,18 +753,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
});
}, [messages, conversationId, markAsRead, loading, loadingMore, isNearBottom]);
// 使用 ref 存储 groupMembers
const groupMembersRef = useRef(groupMembers);
useEffect(() => {
groupMembersRef.current = groupMembers;
}, [groupMembers]);
// 使用 ref 存储 currentUserId
const currentUserIdRef = useRef(currentUserId);
useEffect(() => {
currentUserIdRef.current = currentUserId;
}, [currentUserId]);
// 监听键盘事件
useEffect(() => {
const keyboardWillShow = (e: KeyboardEvent) => {
@@ -910,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;
@@ -1049,7 +1073,39 @@ export const useChatScreen = (props?: ChatScreenProps) => {
return segments;
}, []);
// 【新架构】发送消息(支持纯文字、纯多图、图文同条)
/**
* 构建文件消息的 segments 数组
*/
const buildFileSegments = useCallback((
fileUrl: string,
name: string,
size?: number,
mimeType?: string,
replyToMessage?: GroupMessage | null,
): MessageSegment[] => {
const segments: MessageSegment[] = [];
if (replyToMessage) {
segments.push({
type: 'reply',
data: { id: replyToMessage.id, seq: replyToMessage.seq } as ReplySegmentData
});
}
segments.push({
type: 'file',
data: {
url: fileUrl,
name,
size,
mime_type: mimeType,
} as FileSegmentData
});
return segments;
}, []);
// 【新架构】发送消息(支持纯文字、纯多图、图文同条)- 乐观更新模式
const handleSend = useCallback(async () => {
const trimmedText = inputText.trim();
const hasPending = pendingAttachments.length > 0;
@@ -1075,6 +1131,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
}
}
// 图片需要先上传这个无法乐观因为需要URL
const uploadedUrls: string[] = [];
if (hasPending) {
setUploadingAttachments(true);
@@ -1101,60 +1158,60 @@ export const useChatScreen = (props?: ChatScreenProps) => {
}
}
setSending(true);
// 构建消息段
const segments: MessageSegment[] = (trimmedText || replyingTo)
? [...buildTextSegments(trimmedText, replyingTo)]
: [];
for (const url of uploadedUrls) {
segments.push({
type: 'image',
data: {
url,
thumbnail_url: url,
} as ImageSegmentData,
});
}
if (segments.length === 0) {
Alert.alert('无法发送', '消息内容为空');
return;
}
// 乐观更新:立即清空输入框并滚动到底部
const savedReplyTo = replyingTo;
setInputText('');
setSelectedMentions([]);
setMentionAll(false);
setReplyingTo(null);
setPendingAttachments([]);
// 立即滚动到底部显示新消息
setTimeout(() => {
scrollToLatest(false, false, hasPending ? 'send-mixed' : 'send-text');
}, 50);
// 后台发送消息(私聊与群聊统一走 MessageSendService 的乐观更新):
// 1. 立即在 store 中插入 pending 临时消息
// 2. 成功 → 替换为正式消息、更新会话 last_message、写入本地数据库
// 3. 失败 → 标记为 failed供用户点击重试
try {
// 有文本或回复时才构建文本段,纯图片时不塞空 text
const segments: MessageSegment[] = (trimmedText || replyingTo)
? [...buildTextSegments(trimmedText, replyingTo)]
: [];
for (const url of uploadedUrls) {
segments.push({
type: 'image',
data: {
url,
thumbnail_url: url,
} as ImageSegmentData,
});
}
if (segments.length === 0) {
Alert.alert('无法发送', '消息内容为空');
return;
}
if (isGroupChat && effectiveGroupId) {
await messageService.sendMessageByAction('group', conversationId, segments);
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);
await sendMessageViaManager(segments, {
replyToId: savedReplyTo?.id,
detailType: isGroupChat ? 'group' : 'private',
});
} catch (error) {
console.error('发送消息失败:', error);
Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试'));
} finally {
setSending(false);
// MessageSendService 已将消息置为 failed 并在 UI 上显示状态,
// 这里仅弹窗提示用户(与原行为保持一致)。
if (isGroupChat) {
Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试'));
}
}
}, [
inputText,
pendingAttachments,
conversationId,
isGroupChat,
effectiveGroupId,
sendMessageViaManager,
isMuted,
muteAll,
@@ -1166,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) {
@@ -1253,6 +1325,91 @@ export const useChatScreen = (props?: ChatScreenProps) => {
setActivePanel('none');
}, [pendingAttachments.length]);
// 选择并发送文件(单选,立即上传后发送)
const handlePickFile = useCallback(async () => {
if (!conversationId) {
setActivePanel('none');
return;
}
if (isGroupChat && isMuted) {
Alert.alert('无法发送', muteAll ? '当前群组已开启全员禁言' : '你已被管理员禁言');
setActivePanel('none');
return;
}
setActivePanel('none');
try {
const result = await DocumentPicker.getDocumentAsync({
multiple: false,
copyToCacheDirectory: true,
});
if (result.canceled || !result.assets || result.assets.length === 0) {
return;
}
const asset = result.assets[0];
setUploadingAttachments(true);
const uploaded = await uploadService.uploadFile(
{
uri: asset.uri,
name: asset.name,
type: asset.mimeType || undefined,
},
'chat'
);
if (!uploaded?.url) {
Alert.alert('上传失败', getSendErrorMessage(null, '文件上传失败,请重试'));
return;
}
const segments = buildFileSegments(
uploaded.url,
// asset.name 是 DocumentPicker 返回的用户原始文件名(真实名),
// 优先使用它uploaded.name 为后端回传的展示名(已与 asset.name 一致)。
asset.name || uploaded.name || 'file',
uploaded.size ?? asset.size,
uploaded.mime_type ?? asset.mimeType,
replyingTo,
);
setSending(true);
try {
if (isGroupChat && effectiveGroupId) {
await messageService.sendMessageByAction('group', conversationId, segments);
} else {
await sendMessageViaManager(segments);
}
setReplyingTo(null);
setTimeout(() => scrollToLatest(false, false, 'send-file'), 100);
} catch (error) {
console.error('发送文件消息失败:', error);
Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试'));
} finally {
setSending(false);
}
} catch (error) {
console.error('选择文件失败:', error);
Alert.alert('错误', getSendErrorMessage(error, '选择文件失败'));
} finally {
setUploadingAttachments(false);
}
}, [
conversationId,
isGroupChat,
isMuted,
muteAll,
effectiveGroupId,
replyingTo,
buildFileSegments,
sendMessageViaManager,
scrollToLatest,
getSendErrorMessage,
]);
// 处理更多功能
const handleMoreAction = useCallback((actionId: string) => {
switch (actionId) {
@@ -1281,17 +1438,12 @@ export const useChatScreen = (props?: ChatScreenProps) => {
handleTakePhoto();
break;
case 'file':
Alert.alert('提示', '文件功能即将上线');
setActivePanel('none');
break;
case 'location':
Alert.alert('提示', '位置功能即将上线');
setActivePanel('none');
handlePickFile();
break;
default:
setActivePanel('none');
}
}, [handlePickImage, handleTakePhoto, isGroupChat, otherUser, conversationId]);
}, [handlePickImage, handleTakePhoto, handlePickFile, isGroupChat, otherUser, conversationId]);
// 插入表情
const handleInsertEmoji = useCallback((emoji: string) => {
@@ -1364,20 +1516,15 @@ export const useChatScreen = (props?: ChatScreenProps) => {
setActivePanel('none');
}, []);
// 撤回消息 - 现在通过 MessageManager 处理
// 撤回消息 - 现在通过 MessageManager 处理(撤回事件由 MessageManager 同步状态)
const handleRecall = useCallback(async (messageId: string) => {
try {
if (isGroupChat && effectiveGroupId) {
await messageService.recallMessage(messageId);
} else {
await messageService.recallMessage(messageId);
}
// 不需要手动更新状态MessageManager 会处理撤回事件
await messageService.recallMessage(messageId);
} catch (error) {
console.error('撤回消息失败:', error);
Alert.alert('撤回失败', '无法撤回消息');
}
}, [isGroupChat, effectiveGroupId, conversationId]);
}, []);
// 长按消息显示操作菜单
const handleLongPressMessage = useCallback((message: GroupMessage, position?: MenuPosition) => {
@@ -1416,19 +1563,18 @@ export const useChatScreen = (props?: ChatScreenProps) => {
if (!conversationId) return;
try {
setLastSeq(0);
setFirstSeq(0);
setHasMoreHistory(true);
await messageRepository.clearConversation(conversationId);
// 刷新消息列表
// 同步清空内存消息 + hydrated 标志,保证内存/DB/hydrated 三者一致。
// 否则 hydrated 残留会让 refreshMessages 走轻量增量同步,清空后仍显示旧消息。
useMessageStore.getState().clearMessages(conversationId);
// 刷新消息列表hydrated 已清,会重新走完整 hydration 管线)
await refreshMessages();
} catch (error) {
console.error('清空会话失败:', error);
Alert.alert('清空失败', '无法清空聊天记录');
}
}, [conversationId, refreshMessages]);
// 回复消息
}, [conversationId, refreshMessages]); // 回复消息
const handleReplyMessage = useCallback((message: GroupMessage) => {
setReplyingTo(message);
textInputRef.current?.focus();
@@ -1481,26 +1627,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
}
}, [groupTypingUsers, groupMembers]);
// 计算底部面板高度
const getPanelHeight = useCallback(() => {
if (activePanel === 'none') return 0;
if (activePanel === 'mention') return 250;
return 350;
}, [activePanel]);
// 计算输入框的bottom值
const getInputBottom = useCallback(() => {
if (keyboardHeight > 0) return keyboardHeight;
if (activePanel !== 'none') return getPanelHeight();
return 0;
}, [keyboardHeight, activePanel, getPanelHeight]);
// 计算消息列表的底部padding
const getListPaddingBottom = useCallback(() => {
if (activePanel !== 'none' && keyboardHeight === 0) return getPanelHeight();
return 0;
}, [keyboardHeight, activePanel, getPanelHeight]);
// 关闭所有面板
const handleDismiss = useCallback(() => {
Keyboard.dismiss();
@@ -1544,7 +1670,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
currentUserId,
keyboardHeight,
loading,
sending,
activePanel,
sendingImage,
uploadingAttachments,
@@ -1554,16 +1679,12 @@ export const useChatScreen = (props?: ChatScreenProps) => {
longPressMenuVisible,
selectedMessage,
selectedMessageId,
setSelectedMessageId,
menuPosition,
isGroupChat,
groupInfo,
groupMembers,
// 【改造】使用 MessageManager 的输入状态
typingUsers: groupTypingUsers,
currentUserRole,
mentionQuery,
selectedMentions,
isMuted,
muteAll,
followRestrictionHint,
@@ -1572,6 +1693,10 @@ export const useChatScreen = (props?: ChatScreenProps) => {
effectiveGroupName,
otherUserLastReadSeq,
messageMap,
// 引用消息回填(会话级缓存:内存 → 本地 SQLite → 服务端)
getCachedReply,
ensureReplyMessage,
jumpToMessageSeq,
loadingMore,
hasMoreHistory,
@@ -1585,9 +1710,8 @@ export const useChatScreen = (props?: ChatScreenProps) => {
shouldShowTime,
handleInputChange,
handleSend,
handlePickImage,
handleRetrySend,
removePendingAttachment,
handleTakePhoto,
handleMoreAction,
handleInsertEmoji,
handleSendSticker,
@@ -1606,9 +1730,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
handleMentionAll,
getSenderInfo,
getTypingHint,
getPanelHeight,
getInputBottom,
getListPaddingBottom,
handleDismiss,
navigateToInfo,
navigateToChatSettings,
@@ -1618,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

@@ -1,8 +1,10 @@
import { StatusBar } from 'expo-status-bar';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
TextInput,
@@ -13,7 +15,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { authService } from '../../services';
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
import { spacing, fontSizes, useAppColors, type AppColors } from '../../theme';
import { Text, SimpleHeader } from '../../components/common';
import { useResponsive, useResponsiveSpacing } from '../../hooks';
import { useAuthStore } from '../../stores';
@@ -33,6 +35,9 @@ function createAccountDeletionStyles(colors: AppColors) {
alignItems: 'center',
justifyContent: 'center',
},
keyboardView: {
flex: 1,
},
scrollContent: {
paddingVertical: spacing.lg,
},
@@ -41,53 +46,153 @@ function createAccountDeletionStyles(colors: AppColors) {
alignSelf: 'center',
width: '100%',
},
section: {
marginBottom: spacing['2xl'],
// 顶部叙述区:左对齐、有呼吸感
heroSection: {
paddingHorizontal: spacing['2xl'],
paddingTop: spacing.md,
paddingBottom: spacing.xl,
},
// 警告卡片 - 扁平化风格
warningCard: {
backgroundColor: colors.error.light + '20',
borderRadius: 14,
padding: spacing.lg,
marginHorizontal: spacing['2xl'],
marginBottom: spacing.xl,
},
warningTitle: {
fontWeight: '700',
fontSize: fontSizes.md,
heroEyebrow: {
fontSize: 13,
color: colors.text.hint,
letterSpacing: 1,
marginBottom: spacing.sm,
color: colors.error.main,
},
warningText: {
color: colors.error.dark,
marginBottom: spacing.xs,
fontSize: fontSizes.sm,
},
// 状态卡片 - 扁平化风格
statusCard: {
backgroundColor: colors.warning.light + '30',
borderRadius: 14,
padding: spacing.lg,
marginHorizontal: spacing['2xl'],
marginBottom: spacing.xl,
},
statusTitle: {
heroTitle: {
fontSize: 24,
fontWeight: '700',
fontSize: fontSizes.md,
color: colors.text.primary,
lineHeight: 32,
marginBottom: spacing.sm,
color: colors.warning.dark,
},
daysText: {
heroDesc: {
fontSize: 15,
color: colors.text.secondary,
lineHeight: 22,
},
// 倒计时区:圆环 + 数字 + 文案,替代"大数字 + 标签"卡片
countdownSection: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing['2xl'],
paddingVertical: spacing.lg,
},
countdownRing: {
width: 88,
height: 88,
borderRadius: 44,
borderWidth: 4,
borderColor: colors.warning.main,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.lg,
},
countdownNumber: {
fontSize: 32,
fontWeight: '700',
color: colors.warning.dark,
textAlign: 'center',
marginVertical: spacing.md,
lineHeight: 36,
},
// 内容区域
sectionContent: {
marginHorizontal: spacing['2xl'],
marginBottom: spacing.xl,
countdownUnit: {
fontSize: 11,
color: colors.text.secondary,
marginTop: -2,
},
countdownTextWrap: {
flex: 1,
},
countdownTitle: {
fontSize: 16,
fontWeight: '600',
color: colors.text.primary,
marginBottom: 4,
},
countdownDesc: {
fontSize: 13,
color: colors.text.secondary,
lineHeight: 19,
},
// 引导文 + 列表(无背景、无圆角,自然排版)
guideSection: {
paddingHorizontal: spacing['2xl'],
paddingTop: spacing.lg,
},
guideLabel: {
fontSize: 13,
color: colors.text.secondary,
fontWeight: '500',
marginBottom: spacing.md,
},
guideItem: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingVertical: 10,
},
guideIcon: {
marginRight: spacing.md,
marginTop: 2,
},
guideTextWrap: {
flex: 1,
},
guideTitle: {
fontSize: 15,
fontWeight: '500',
color: colors.text.primary,
marginBottom: 2,
},
guideDesc: {
fontSize: 13,
color: colors.text.secondary,
lineHeight: 19,
},
guideDivider: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.divider,
marginLeft: spacing['2xl'] + 22 + spacing.md,
},
// 注意事项(无填色,仅左侧色条)
noticeSection: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingHorizontal: spacing['2xl'],
paddingTop: spacing.lg,
paddingBottom: spacing.md,
},
noticeBar: {
width: 3,
alignSelf: 'stretch',
backgroundColor: colors.error.main,
borderRadius: 2,
marginRight: spacing.md,
},
noticeTextWrap: {
flex: 1,
},
noticeTitle: {
fontSize: 14,
fontWeight: '600',
color: colors.error.main,
marginBottom: 4,
},
noticeDesc: {
fontSize: 13,
color: colors.text.secondary,
lineHeight: 20,
},
// 表单区(与 AccountSecurity 风格一致:分节标题 + 输入框)
formSection: {
marginTop: spacing.lg,
},
sectionHeader: {
paddingHorizontal: spacing['2xl'],
marginBottom: spacing.sm,
marginTop: spacing.sm,
},
sectionTitle: {
fontWeight: '600',
@@ -95,47 +200,40 @@ function createAccountDeletionStyles(colors: AppColors) {
color: colors.text.secondary,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: spacing.md,
},
listItem: {
// 输入框
inputWrapper: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: spacing.sm,
},
listBullet: {
marginRight: spacing.sm,
marginTop: 2,
},
// 输入框 - 扁平化风格
input: {
alignItems: 'center',
backgroundColor: colors.background.default,
borderRadius: 14,
padding: spacing.md,
fontSize: 16,
paddingHorizontal: spacing.lg,
height: 56,
marginHorizontal: spacing['2xl'],
marginBottom: spacing.md,
},
inputIcon: {
marginRight: spacing.sm,
},
input: {
flex: 1,
color: colors.text.primary,
fontSize: fontSizes.md,
height: 56,
},
// 按钮行
eyeButton: {
padding: 4,
marginLeft: 4,
},
// 按钮行:次按钮在左、危险按钮在右,与全站保持一致
buttonRow: {
flexDirection: 'row',
gap: spacing.md,
marginTop: spacing.lg,
marginHorizontal: spacing['2xl'],
},
// 扁平化按钮
primaryButton: {
flex: 1,
height: 56,
borderRadius: 14,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
},
primaryButtonText: {
color: colors.text.inverse,
fontSize: fontSizes.md,
fontWeight: '600',
},
secondaryButton: {
flex: 1,
height: 56,
@@ -165,11 +263,38 @@ function createAccountDeletionStyles(colors: AppColors) {
fontWeight: '600',
},
buttonDisabled: {
opacity: 0.6,
opacity: 0.5,
},
cancelButton: {
marginTop: spacing.lg,
primaryButton: {
height: 56,
borderRadius: 14,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginHorizontal: spacing['2xl'],
marginTop: spacing.md,
},
primaryButtonText: {
color: colors.text.inverse,
fontSize: fontSizes.md,
fontWeight: '600',
},
// 页脚:联系客服
footer: {
alignItems: 'center',
marginTop: spacing.xl,
paddingHorizontal: spacing['2xl'],
},
footerText: {
fontSize: 13,
color: colors.text.hint,
lineHeight: 20,
textAlign: 'center',
},
footerLink: {
color: colors.primary.main,
fontWeight: '500',
},
});
}
@@ -185,9 +310,9 @@ export const AccountDeletionScreen: React.FC = () => {
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md;
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
const loadStatus = useCallback(async () => {
@@ -284,121 +409,244 @@ export const AccountDeletionScreen: React.FC = () => {
);
}
// 待注销状态
if (status?.is_pending_deletion) {
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}>
{/* 顶部叙述 */}
<View style={styles.heroSection}>
<Text style={styles.heroEyebrow}>ACCOUNT · </Text>
<Text style={styles.heroTitle}></Text>
<Text style={styles.heroDesc}>
使
</Text>
</View>
{/* 倒计时 */}
<View style={styles.countdownSection}>
<View style={styles.countdownRing}>
<Text style={styles.countdownNumber}>{status.cool_down_days || 90}</Text>
<Text style={styles.countdownUnit}></Text>
</View>
<View style={styles.countdownTextWrap}>
<Text style={styles.countdownTitle}></Text>
<Text style={styles.countdownDesc}>
</Text>
</View>
</View>
{/* 取消按钮 */}
<TouchableOpacity
style={[
styles.primaryButton,
submitting && styles.buttonDisabled,
]}
onPress={handleCancelDeletion}
disabled={submitting}
activeOpacity={0.9}
>
{submitting ? (
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.primaryButtonText}></Text>
)}
</TouchableOpacity>
{/* 页脚 */}
<View style={styles.footer}>
<Text style={styles.footerText}>
<Text style={styles.footerLink}> </Text>
</Text>
</View>
</View>
</ScrollView>
</SafeAreaView>
);
}
// 正常状态:申请注销
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<StatusBar style="auto" />
<SimpleHeader title="注销账号" onBack={() => router.back()} />
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
<View style={styles.content}>
{status?.is_pending_deletion ? (
<View style={styles.section}>
<View style={styles.statusCard}>
<Text variant="body" style={styles.statusTitle}>
</Text>
<Text variant="body" color={colors.text.secondary}>
</Text>
<Text variant="body" style={styles.daysText}>
{status.cool_down_days || 90}
</Text>
<Text variant="caption" color={colors.text.secondary}>
</Text>
<TouchableOpacity
style={[styles.secondaryButton, styles.cancelButton, submitting && styles.buttonDisabled]}
onPress={handleCancelDeletion}
disabled={submitting}
>
{submitting ? (
<ActivityIndicator size="small" color={colors.text.primary} />
) : (
<Text style={styles.secondaryButtonText}></Text>
)}
</TouchableOpacity>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<View style={styles.content}>
{/* 顶部叙述 */}
<View style={styles.heroSection}>
<Text style={styles.heroEyebrow}>ACCOUNT · </Text>
<Text style={styles.heroTitle}></Text>
<Text style={styles.heroDesc}>
90
</Text>
</View>
{/* 注销影响 - 列表式(无背景) */}
<View style={styles.guideSection}>
<Text style={styles.guideLabel}></Text>
<View style={styles.guideItem}>
<MaterialCommunityIcons
name="account-remove-outline"
size={22}
color={colors.error.main}
style={styles.guideIcon}
/>
<View style={styles.guideTextWrap}>
<Text style={styles.guideTitle}></Text>
<Text style={styles.guideDesc}></Text>
</View>
</View>
<View style={styles.guideDivider} />
<View style={styles.guideItem}>
<MaterialCommunityIcons
name="message-text-outline"
size={22}
color={colors.warning.main}
style={styles.guideIcon}
/>
<View style={styles.guideTextWrap}>
<Text style={styles.guideTitle}></Text>
<Text style={styles.guideDesc}></Text>
</View>
</View>
<View style={styles.guideDivider} />
<View style={styles.guideItem}>
<MaterialCommunityIcons
name="heart-broken-outline"
size={22}
color={colors.error.main}
style={styles.guideIcon}
/>
<View style={styles.guideTextWrap}>
<Text style={styles.guideTitle}></Text>
<Text style={styles.guideDesc}></Text>
</View>
</View>
<View style={styles.guideDivider} />
<View style={styles.guideItem}>
<MaterialCommunityIcons
name="clock-time-four-outline"
size={22}
color={colors.text.secondary}
style={styles.guideIcon}
/>
<View style={styles.guideTextWrap}>
<Text style={styles.guideTitle}>90 </Text>
<Text style={styles.guideDesc}></Text>
</View>
</View>
</View>
) : (
<View style={styles.section}>
{/* 警告卡片 */}
<View style={styles.warningCard}>
<Text variant="body" style={styles.warningTitle}>
{/* 红色提示 - 左侧细线代替大色块 */}
<View style={styles.noticeSection}>
<View style={styles.noticeBar} />
<View style={styles.noticeTextWrap}>
<Text style={styles.noticeTitle}></Text>
<Text style={styles.noticeDesc}>
90
</Text>
<Text variant="body" style={styles.warningText}>
90
</Text>
<Text variant="body" style={styles.warningText}>
</View>
</View>
{/* 密码确认 */}
<View style={styles.formSection}>
<View style={styles.sectionHeader}>
<Text variant="caption" style={styles.sectionTitle}>
</Text>
</View>
{/* 注销说明 */}
<View style={styles.sectionContent}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.listItem}>
<MaterialCommunityIcons name="close-circle" size={16} color={colors.error.main} style={styles.listBullet} />
<Text variant="body" color={colors.text.secondary}>
</Text>
</View>
<View style={styles.listItem}>
<MaterialCommunityIcons name="information" size={16} color={colors.warning.main} style={styles.listBullet} />
<Text variant="body" color={colors.text.secondary}>
</Text>
</View>
<View style={styles.listItem}>
<MaterialCommunityIcons name="close-circle" size={16} color={colors.error.main} style={styles.listBullet} />
<Text variant="body" color={colors.text.secondary}>
</Text>
</View>
<View style={styles.listItem}>
<MaterialCommunityIcons name="close-circle" size={16} color={colors.error.main} style={styles.listBullet} />
<Text variant="body" color={colors.text.secondary}>
</Text>
</View>
</View>
{/* 密码确认 */}
<View style={styles.sectionContent}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons
name="lock-outline"
size={20}
color={colors.text.secondary}
style={styles.inputIcon}
/>
<TextInput
style={styles.input}
placeholder="请输入密码"
placeholder="请输入登录密码以确认"
placeholderTextColor={colors.text.hint}
secureTextEntry
secureTextEntry={!showPassword}
value={password}
onChangeText={setPassword}
autoCapitalize="none"
returnKeyType="done"
/>
</View>
{/* 按钮行 */}
<View style={styles.buttonRow}>
<TouchableOpacity
style={styles.secondaryButton}
onPress={() => router.back()}
onPress={() => setShowPassword(!showPassword)}
style={styles.eyeButton}
>
<Text style={styles.secondaryButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.dangerButton, submitting && styles.buttonDisabled]}
onPress={handleRequestDeletion}
disabled={submitting || !password.trim()}
>
{submitting ? (
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.dangerButtonText}></Text>
)}
<MaterialCommunityIcons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={colors.text.hint}
/>
</TouchableOpacity>
</View>
</View>
)}
</View>
</ScrollView>
{/* 按钮行 */}
<View style={styles.buttonRow}>
<TouchableOpacity
style={styles.secondaryButton}
onPress={() => router.back()}
activeOpacity={0.8}
>
<Text style={styles.secondaryButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.dangerButton,
(submitting || !password.trim()) && styles.buttonDisabled,
]}
onPress={handleRequestDeletion}
disabled={submitting || !password.trim()}
activeOpacity={0.9}
>
{submitting ? (
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.dangerButtonText}></Text>
)}
</TouchableOpacity>
</View>
{/* 页脚 */}
<View style={styles.footer}>
<Text style={styles.footerText}>
<Text style={styles.footerLink}> </Text>
</Text>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
};

View File

@@ -63,13 +63,19 @@ export const DataStorageScreen: React.FC = () => {
if (Platform.OS === 'web') return 0;
try {
const cacheDir = Paths.cache;
const sdImageCacheDir = new Directory(cacheDir, 'defaultDiskCache');
const expoImageCacheDir = new Directory(cacheDir, 'expo-image');
// expo-image 在底层使用原生图片库,磁盘缓存目录名与平台相关:
// - iOSSDWebImage: {cache}/com.hackemist.SDImageCache/
// - AndroidGlide: {cache}/image_manager_disk_cache/
// 同时保留旧的猜测路径作为兜底,避免未来库升级改路径时彻底失效。
const candidateDirNames =
Platform.OS === 'ios'
? ['com.hackemist.SDImageCache', 'defaultDiskCache', 'expo-image']
: ['image_manager_disk_cache', 'expo-image', 'defaultDiskCache'];
const scanDirectorySize = async (dir: Directory): Promise<number> => {
let size = 0;
try {
if (!(await dir.exists)) return 0;
if (!dir.exists) return 0;
const items = dir.list();
for (const item of items) {
try {
@@ -87,7 +93,10 @@ export const DataStorageScreen: React.FC = () => {
return size;
};
const totalSize = await scanDirectorySize(sdImageCacheDir) + await scanDirectorySize(expoImageCacheDir);
let totalSize = 0;
for (const name of candidateDirNames) {
totalSize += await scanDirectorySize(new Directory(cacheDir, name));
}
return totalSize;
} catch (error) {
console.warn('读取 expo-image 缓存大小失败:', error);
@@ -144,6 +153,9 @@ export const DataStorageScreen: React.FC = () => {
await mediaCacheManager.clearAll();
await Image.clearDiskCache();
await Image.clearMemoryCache();
// expo-image 的磁盘缓存清理由原生库SDWebImage / Glide在后台线程执行
// 立即读取目录大小可能仍是清理前的值,这里短暂等待以确保文件落盘删除完成。
await new Promise((resolve) => setTimeout(resolve, 300));
await loadStats();
Alert.alert('完成', '缓存已清除');
} catch (error) {

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>
<View style={styles.headerCountPill}>
<Text variant="caption" color={colors.text.secondary}>
{users.length}
</Text>
</View>
{!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

@@ -29,6 +29,12 @@ import {
} from '@/services/notification';
import { useResponsive, useResponsiveSpacing } from '../../hooks';
import { backgroundSyncManager, BackgroundSyncMode } from '@/services/background';
import {
loadAutoStartConsent,
consentToAutoStart,
rejectAutoStart,
getAutoStartDescription,
} from '@/services/consent';
// 内容最大宽度
const CONTENT_MAX_WIDTH = 720;
@@ -49,8 +55,9 @@ export const NotificationSettingsScreen: React.FC = () => {
const [vibrationEnabled, setVibrationEnabledState] = useState(true);
const [pushEnabled, setPushEnabled] = useState(true);
const [soundEnabled, setSoundEnabled] = useState(true);
const [syncMode, setSyncMode] = useState<BackgroundSyncMode>(BackgroundSyncMode.BATTERY_SAVER);
const [syncMode, setSyncMode] = useState<BackgroundSyncMode>(BackgroundSyncMode.DISABLED);
const [systemPushEnabled, setSystemPushEnabled] = useState<boolean | null>(null);
const [autoStartConsented, setAutoStartConsented] = useState(false);
const { isWideScreen, isMobile } = useResponsive();
const insets = useSafeAreaInsets();
@@ -67,6 +74,10 @@ export const NotificationSettingsScreen: React.FC = () => {
setSoundEnabled(prefs.soundEnabled);
setSyncMode(backgroundSyncManager.getMode());
// 加载自启动同意状态
const consent = await loadAutoStartConsent();
setAutoStartConsented(consent.consented);
if (Platform.OS !== 'web') {
const enabled = await jpushService.checkNotificationPermission();
setSystemPushEnabled(enabled);
@@ -106,6 +117,41 @@ export const NotificationSettingsScreen: React.FC = () => {
};
const handleSyncModeChange = async (mode: BackgroundSyncMode) => {
// 如果要切换到需要自启动的模式,先请求用户同意
if (mode !== BackgroundSyncMode.DISABLED && !autoStartConsented) {
Alert.alert(
'后台消息接收',
getAutoStartDescription() + '\n\n开启后应用可在后台接收消息推送。',
[
{ text: '取消', style: 'cancel' },
{
text: '同意并开启',
onPress: async () => {
try {
await consentToAutoStart('接收实时消息推送');
setAutoStartConsented(true);
// 用户同意后,继续切换模式
await doSetSyncMode(mode);
} catch (error) {
console.error('同意自启动失败:', error);
}
},
},
]
);
return;
}
// 如果切换到禁用模式,撤销自启动同意
if (mode === BackgroundSyncMode.DISABLED && autoStartConsented) {
await rejectAutoStart();
setAutoStartConsented(false);
}
await doSetSyncMode(mode);
};
const doSetSyncMode = async (mode: BackgroundSyncMode) => {
if (mode === BackgroundSyncMode.REALTIME) {
Alert.alert(
'实时模式',
@@ -130,24 +176,24 @@ export const NotificationSettingsScreen: React.FC = () => {
};
const syncModeOptions: { mode: BackgroundSyncMode; title: string; subtitle: string; icon: string }[] = [
{
mode: BackgroundSyncMode.DISABLED,
title: '静默模式',
subtitle: '不自启动,仅在使用时接收消息',
icon: 'bell-off-outline',
},
{
mode: BackgroundSyncMode.BATTERY_SAVER,
title: '省电模式',
subtitle: '系统后台任务,每 15 分钟检查一次',
title: '后台模式',
subtitle: '允许自启动,每 15 分钟检查一次',
icon: 'leaf',
},
{
mode: BackgroundSyncMode.REALTIME,
title: '实时模式',
subtitle: '通知栏常驻保活,即时同步消息',
subtitle: '允许自启动,通知栏常驻保活',
icon: 'lightning-bolt',
},
{
mode: BackgroundSyncMode.DISABLED,
title: '禁用',
subtitle: '仅在应用打开时接收消息',
icon: 'close-circle-outline',
},
];
const handleRequestSystemPermission = async () => {
@@ -304,12 +350,30 @@ export const NotificationSettingsScreen: React.FC = () => {
))}
</View>
{/* 实时模式说明 */}
{/* 模式说明 */}
{syncMode === BackgroundSyncMode.DISABLED && (
<View style={styles.tipContainer}>
<MaterialCommunityIcons name="information-outline" size={16} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.tipText}>
</Text>
</View>
)}
{syncMode === BackgroundSyncMode.BATTERY_SAVER && (
<View style={styles.tipContainer}>
<MaterialCommunityIcons name="information-outline" size={16} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.tipText}>
15
</Text>
</View>
)}
{syncMode === BackgroundSyncMode.REALTIME && (
<View style={styles.tipContainer}>
<MaterialCommunityIcons name="shield-check-outline" size={16} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.tipText}>
</Text>
</View>
)}

View File

@@ -30,7 +30,7 @@ const THEME_COLORS = {
};
// 政策最后更新日期
const LAST_UPDATED = '2026年4月28日';
const LAST_UPDATED = '2026年6月15日';
// 隐私政策内容
const PRIVACY_SECTIONS = [
@@ -159,29 +159,60 @@ const PRIVACY_SECTIONS = [
title: '七、第三方SDK目录',
content: `为了保障App的稳定运行或实现特定功能我们可能接入第三方SDK。截至本隐私政策更新之日我们主要使用以下服务
1. 极光推送JPush
• 提供方:深圳市和讯华谷信息技术有限公司
• 使用目的:实现消息推送功能,向您的设备发送通知提醒
收集的个人信息:设备标识符Registration ID、Android ID、IMEI、OAID、设备型号、操作系统版本、IP地址、应用列表信息
• 隐私政策https://www.jiguang.cn/privacy
2. Expo推送服务
• 提供方Expo650 Industries, Inc.
• 使用目的:辅助消息推送通道管理
• 收集的个人信息设备推送令牌Push Token、设备标识符
1. 极光推送 - JPush SDK - Android
所属系统安卓、iOS
第三方公司名称:深圳市和讯华谷信息技术有限公司
第三方收集的个人信息类型
1. 设备标识符Registration ID、Android ID、IMEI、OAID2. 设备型号、操作系统版本3. IP地址4. 应用列表信息
我们从第三方获取的个人信息设备标识符Registration ID
第三方可能调用的权限:无
实现功能及场景描述:消息推送
处理目的:实现消息推送功能,向您的设备发送通知提醒
处理方式SDK采集
联系方式:官网 https://www.jiguang.cn/
第三方隐私政策链接https://www.jiguang.cn/license/privacy
如您后续接入其他第三方SDK如微信登录、分享等功能我们将在本章节更新相关说明并告知您对应SDK收集的信息类型和用途。更新后的SDK目录将在应用内公布请以最新版本为准。`,
},
{
title: '八、隐私政策的更新',
content: `8.1 我们可能会不时更新本隐私政策。更新后的隐私政策将在应用内公布,并标注更新日期。
title: '八、应用自启动与关联启动说明',
content: `8.1 自启动/关联启动的目的与场景
为了及时向您推送消息通知,本应用可能需要在以下场景进行自启动或关联启动:
8.2 对于重大变更,我们会在变更生效前通过应用内通知、弹窗等方式告知您。
• 设备开机完成后:恢复后台消息推送服务,确保您能及时收到新消息提醒
• 应用更新后:恢复后台任务调度,保证消息同步功能正常运作
• 系统重启后:恢复通知服务,确保推送通道可用
• 关联启动场景:当系统或其他应用触发相关事件时,为保证推送服务连续性而进行关联启动
8.3 如您不同意变更后的内容,应立即停止使用本服务。如您继续使用本服务,即视为您已阅读并同意受变更后的隐私政策约束。`,
8.2 用户同意机制
• 自启动/关联启动功能仅在您明确同意后才启用
• 首次使用时,我们会在"通知设置"中向您说明自启动的目的、场景、规则及必要性,并征得您的同意
• 您可随时在"设置-通知设置-后台同步模式"中更改选择:
- 静默模式:不自启动,仅在使用应用时接收消息
- 后台模式同意自启动系统每15分钟检查一次新消息
- 实时模式:同意自启动,通知栏常驻保活,即时同步消息
8.3 关闭自启动的影响
• 选择静默模式后,应用不会自启动
• 您仅在打开应用时才能接收消息
• 此模式最省电,但可能错过实时消息
• 关闭自启动不会影响应用内的其他功能使用
8.4 我们承诺
• 自启动行为仅用于消息推送服务,不会用于收集额外个人信息
• 自启动行为不会用于广告推送或其他商业目的
• 我们仅在用户同意的范围内使用自启动功能`,
},
{
title: '九、联系我们',
title: '九、隐私政策的更新',
content: `9.1 我们可能会不时更新本隐私政策。更新后的隐私政策将在应用内公布,并标注更新日期。
9.2 对于重大变更,我们会在变更生效前通过应用内通知、弹窗等方式告知您。
9.3 如您不同意变更后的内容,应立即停止使用本服务。如您继续使用本服务,即视为您已阅读并同意受变更后的隐私政策约束。`,
},
{
title: '十、联系我们',
content: `如果您对本隐私政策有任何疑问、意见或建议,或者您希望行使您的权利,请通过以下方式与我们联系:
• 邮箱system@qczlit.cn

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

@@ -103,7 +103,7 @@ const TERMS_SECTIONS = [
6.3 我们将采取合理的技术和管理措施保护您的个人信息安全,但不对因不可抗力或第三方原因导致的信息泄露承担责任。
6.4 本应用集成了极光推送JPush等第三方SDK用于实现消息推送等功能。第三方SDK可能会收集和处理您的设备标识符、设备信息等必要数据。具体的第三方SDK信息请参见《隐私政策》中的"第三方SDK目录"章节。`,
6.4 本应用集成了极光推送 - JPush SDK - Android等第三方SDK用于实现消息推送等功能。第三方SDK可能会收集和处理您的设备标识符、设备信息等必要数据。具体的第三方SDK信息请参见《隐私政策》中的"第三方SDK目录"章节。`,
},
{
title: '七、免责声明',

View File

@@ -16,7 +16,8 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { useAppColors } from '../../theme';
import { Post } from '../../types';
import { PostCard, TabBar, UserProfileHeader } from '../../components/business';
import { Loading, EmptyState, ResponsiveContainer } from '../../components/common';
import type { PostCardAction } from '../../components/business/PostCard';
import { Loading, EmptyState, ResponsiveContainer, ImageGallery, ImageGridItem } from '../../components/common';
import { useResponsive } from '../../hooks';
import { useUserProfile, ProfileMode, TABS, TAB_ICONS, createSharedProfileStyles } from './useUserProfile';
@@ -116,6 +117,39 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
currentUser,
} = useUserProfile({ mode, userId, isDesktop, isTablet });
// 图片查看器状态(与 HomeScreen / PostDetailScreen 一致)
const [showImageViewer, setShowImageViewer] = useState(false);
const [postImages, setPostImages] = useState<ImageGridItem[]>([]);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const closeImageViewer = useCallback(() => setShowImageViewer(false), []);
const stableGalleryImages = useMemo(
() =>
postImages.map((img, i) => ({
id: img.id || img.url || `img-${i}`,
url: img.url || img.uri || '',
})),
[postImages]
);
// 包装 handlePostAction在 hook 通用逻辑之上处理 imagePress
const onPostAction = useCallback(
(post: Post, action: PostCardAction) => {
if (action.type === 'imagePress') {
const images = action.payload?.images;
const imageIndex = action.payload?.imageIndex;
if (images && imageIndex !== undefined) {
setPostImages(images);
setSelectedImageIndex(imageIndex);
setShowImageViewer(true);
}
return;
}
handlePostAction(post, action);
},
[handlePostAction]
);
// 当前显示的帖子列表
const currentPosts = activeTab === 0 ? posts : favorites;
@@ -130,12 +164,12 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
]}>
<PostCard
post={item}
onAction={(action) => handlePostAction(item, action)}
onAction={(action) => onPostAction(item, action)}
isPostAuthor={isPostAuthor}
/>
</View>
);
}, [currentUser?.id, handlePostAction, currentPosts.length]);
}, [currentUser?.id, onPostAction, currentPosts.length]);
const postKeyExtractor = useCallback((item: Post) => item.id, []);
@@ -269,6 +303,15 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
</View>
</View>
</ResponsiveContainer>
{/* 图片查看器 */}
<ImageGallery
visible={showImageViewer}
images={stableGalleryImages}
initialIndex={selectedImageIndex}
onClose={closeImageViewer}
enableSave
/>
</SafeAreaView>
);
}
@@ -310,6 +353,15 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
}
drawDistance={250}
/>
{/* 图片查看器 */}
<ImageGallery
visible={showImageViewer}
images={stableGalleryImages}
initialIndex={selectedImageIndex}
onClose={closeImageViewer}
enableSave
/>
</SafeAreaView>
);
};

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) {
console.error('[AuthService] 登出 API 失败:', 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,9 +327,15 @@ class AuthService {
return response.data;
} catch (error) {
console.error('刷新Token失败:', error);
await api.clearToken();
return null;
// 网络故障:保留 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

@@ -12,25 +12,34 @@
import { AppState, AppStateStatus, Platform } from 'react-native';
import { ForegroundServiceModule } from './ForegroundServiceModule';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
loadAutoStartConsent,
saveAutoStartConsent,
AutoStartMode,
} from '../consent/autoStartConsent';
/**
* 后台同步模式
* 整合自启动同意机制:
* - DISABLED静默模式不自启动不注册后台任务
* - BATTERY_SAVER后台模式用户同意自启动后使用 WorkManager 后台任务15分钟间隔
* - REALTIME实时模式用户同意自启动后使用前台服务保活
*/
export enum BackgroundSyncMode {
/** 省电模式:仅依赖 JPush 推送 */
/** 静默模式:不自启动,仅在使用时接收消息 */
DISABLED = 'disabled',
/** 后台模式:用户同意自启动,使用 WorkManager 后台任务 */
BATTERY_SAVER = 'battery_saver',
/** 实时模式:前台服务保活(仅用于通话) */
/** 实时模式:用户同意自启动,前台服务保活 */
REALTIME = 'realtime',
/** 禁用后台同步 */
DISABLED = 'disabled',
}
const STORAGE_KEY = 'background_sync_mode';
class BackgroundSyncManager {
private mode: BackgroundSyncMode = BackgroundSyncMode.BATTERY_SAVER;
private mode: BackgroundSyncMode = BackgroundSyncMode.DISABLED;
private appStateSubscription: ReturnType<typeof AppState.addEventListener> | null = null;
private isInitialized: boolean = false;
private lastSyncAt: number = 0;
@@ -64,8 +73,18 @@ class BackgroundSyncManager {
/**
* 切换后台同步模式
* 需要用户同意自启动才能切换到 BATTERY_SAVER 或 REALTIME 模式
*/
async setMode(mode: BackgroundSyncMode): Promise<void> {
// 如果要切换到需要自启动的模式,先检查用户是否同意
if (mode !== BackgroundSyncMode.DISABLED) {
const consent = await loadAutoStartConsent();
if (!consent.consented || consent.mode !== AutoStartMode.BACKGROUND) {
console.log('[BackgroundSyncManager] 用户未同意自启动,无法切换到模式:', mode);
throw new Error('用户未同意自启动');
}
}
if (AppState.currentState !== 'active' && this.mode === BackgroundSyncMode.REALTIME) {
await ForegroundServiceModule.stop();
}
@@ -161,9 +180,9 @@ class BackgroundSyncManager {
private async loadMode(): Promise<BackgroundSyncMode> {
try {
const saved = await AsyncStorage.getItem(STORAGE_KEY);
return (saved as BackgroundSyncMode) || BackgroundSyncMode.BATTERY_SAVER;
return (saved as BackgroundSyncMode) || BackgroundSyncMode.DISABLED;
} catch (error) {
return BackgroundSyncMode.BATTERY_SAVER;
return BackgroundSyncMode.DISABLED;
}
}

View File

@@ -17,6 +17,11 @@ import {
} from './BackgroundSyncManager';
import { messageService } from '../message/messageService';
import { api } from '../core/api';
import {
loadAutoStartConsent,
isAutoStartAllowed,
AutoStartMode,
} from '../consent/autoStartConsent';
// 后台任务名称
const BACKGROUND_SYNC_TASK = 'background-sync-task';
@@ -36,6 +41,13 @@ TaskManager.defineTask(BACKGROUND_SYNC_TASK, async () => {
return BackgroundTask.BackgroundTaskResult.Success;
}
// 检查用户是否同意自启动
const consent = await loadAutoStartConsent();
if (!consent.consented || consent.mode !== AutoStartMode.BACKGROUND) {
console.log('[BackgroundService] 用户未同意自启动,跳过后台同步');
return BackgroundTask.BackgroundTaskResult.Success;
}
// 执行同步
await syncMessages();
@@ -82,6 +94,7 @@ async function syncMessages(): Promise<void> {
/**
* 注册后台任务expo-background-task
* 仅在用户同意自启动且模式为 BATTERY_SAVER 时注册
*/
async function registerBackgroundTask(): Promise<void> {
if (Platform.OS === 'web') {
@@ -89,6 +102,13 @@ async function registerBackgroundTask(): Promise<void> {
}
try {
// 检查用户是否同意自启动
const consent = await loadAutoStartConsent();
if (!consent.consented || consent.mode !== AutoStartMode.BACKGROUND) {
console.log('[BackgroundService] 用户未同意自启动,跳过注册后台任务');
return;
}
// 检查后台任务状态
const status = await BackgroundTask.getStatusAsync();
if (status !== BackgroundTask.BackgroundTaskStatus.Available) {
@@ -129,7 +149,7 @@ async function unregisterBackgroundTask(): Promise<void> {
}
/**
* 初始化后台保活服务
* 根据用户同意的自启动模式初始化后台服务
*/
export async function initBackgroundService(): Promise<boolean> {
if (isInitialized) {
@@ -142,14 +162,21 @@ export async function initBackgroundService(): Promise<boolean> {
}
try {
// 加载用户同意状态
await loadAutoStartConsent();
// 设置同步回调
backgroundSyncManager.setSyncMessagesCallback(syncMessages);
// 初始化后台同步管理器
await backgroundSyncManager.initialize();
// 注册 expo-background-task 任务(用于 BATTERY_SAVER 模式)
await registerBackgroundTask();
// 仅在用户同意自启动时注册后台任务
if (isAutoStartAllowed()) {
await registerBackgroundTask();
} else {
console.log('[BackgroundService] 用户选择静默模式,不注册后台自启动任务');
}
isInitialized = true;
console.log('[BackgroundService] 初始化完成');
@@ -160,6 +187,39 @@ export async function initBackgroundService(): Promise<boolean> {
}
}
/**
* 重新初始化后台服务(在用户更改自启动同意后调用)
*/
export async function reinitBackgroundService(): Promise<boolean> {
if (!isInitialized) {
return initBackgroundService();
}
try {
const consent = await loadAutoStartConsent();
if (consent.consented && consent.mode === AutoStartMode.BACKGROUND) {
// 用户同意自启动,注册后台任务
await registerBackgroundTask();
// 如果模式是实时模式,启动前台服务
if (backgroundSyncManager.getMode() === BackgroundSyncMode.REALTIME) {
await backgroundSyncManager.setMode(BackgroundSyncMode.REALTIME);
}
} else {
// 用户拒绝自启动,取消后台任务
await unregisterBackgroundTask();
// 停止前台服务
await backgroundSyncManager.setMode(BackgroundSyncMode.DISABLED);
}
console.log('[BackgroundService] 根据用户同意状态重新初始化完成');
return true;
} catch (error) {
console.error('[BackgroundService] 重新初始化失败:', error);
return false;
}
}
/**
* 停止后台保活服务
*/
@@ -205,6 +265,7 @@ export async function checkBackgroundStatus(): Promise<{
/**
* 设置后台同步模式
* 同时更新自启动同意状态
*/
export async function setBackgroundSyncMode(mode: BackgroundSyncMode): Promise<void> {
await backgroundSyncManager.setMode(mode);
@@ -239,6 +300,7 @@ export { BackgroundSyncMode };
// 后台服务实例
export const backgroundService = {
init: initBackgroundService,
reinit: reinitBackgroundService,
stop: stopBackgroundService,
setMode: setBackgroundSyncMode,
getMode: getBackgroundSyncMode,

View File

@@ -1,6 +1,7 @@
export {
backgroundService,
initBackgroundService,
reinitBackgroundService,
stopBackgroundService,
triggerVibration,
vibrateOnMessage,

View File

@@ -0,0 +1,201 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';
/**
* 自启动权限同意管理
*
* 管理用户对应用自启动/关联启动行为的同意状态
* 符合隐私合规要求:未经用户同意不得进行自启动
*/
const STORAGE_KEY = 'auto_start_consent';
const STORAGE_KEY_FIRST_LAUNCH = 'auto_start_first_launch';
export enum AutoStartMode {
/** 静默模式:不自启动,仅在使用时接收消息 */
SILENT = 'silent',
/** 后台模式:允许自启动以接收实时推送 */
BACKGROUND = 'background',
}
export interface AutoStartConsent {
/** 用户是否同意自启动 */
consented: boolean;
/** 当前模式 */
mode: AutoStartMode;
/** 同意时间 */
consentedAt?: string;
/** 用户同意的目的描述 */
purpose?: string;
}
const DEFAULT_CONSENT: AutoStartConsent = {
consented: false,
mode: AutoStartMode.SILENT,
};
let cachedConsent: AutoStartConsent = { ...DEFAULT_CONSENT };
/**
* 加载自启动同意状态
*/
export async function loadAutoStartConsent(): Promise<AutoStartConsent> {
if (Platform.OS === 'web') {
return { ...DEFAULT_CONSENT };
}
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) {
cachedConsent = JSON.parse(raw) as AutoStartConsent;
return { ...cachedConsent };
}
} catch (error) {
console.error('[AutoStartConsent] 加载同意状态失败:', error);
}
return { ...DEFAULT_CONSENT };
}
/**
* 保存自启动同意状态
*/
export async function saveAutoStartConsent(consent: AutoStartConsent): Promise<void> {
if (Platform.OS === 'web') {
return;
}
try {
cachedConsent = { ...consent };
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(consent));
} catch (error) {
console.error('[AutoStartConsent] 保存同意状态失败:', error);
}
}
/**
* 获取同步的自启动同意状态
*/
export function getAutoStartConsentSync(): AutoStartConsent {
return { ...cachedConsent };
}
/**
* 检查是否是首次启动(用于显示首次同意弹窗)
*/
export async function isFirstLaunch(): Promise<boolean> {
if (Platform.OS === 'web') {
return false;
}
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY_FIRST_LAUNCH);
if (raw === null) {
await AsyncStorage.setItem(STORAGE_KEY_FIRST_LAUNCH, 'false');
return true;
}
return false;
} catch {
return false;
}
}
/**
* 用户同意自启动(后台模式)
* @param purpose 用户同意的目的描述
*/
export async function consentToAutoStart(purpose: string = '接收实时消息推送'): Promise<void> {
await saveAutoStartConsent({
consented: true,
mode: AutoStartMode.BACKGROUND,
consentedAt: new Date().toISOString(),
purpose,
});
}
/**
* 用户拒绝自启动(静默模式)
*/
export async function rejectAutoStart(): Promise<void> {
await saveAutoStartConsent({
consented: false,
mode: AutoStartMode.SILENT,
consentedAt: new Date().toISOString(),
purpose: '用户拒绝自启动',
});
}
/**
* 切换自启动模式
*/
export async function setAutoStartMode(mode: AutoStartMode): Promise<void> {
const current = getAutoStartConsentSync();
if (mode === AutoStartMode.BACKGROUND) {
await saveAutoStartConsent({
consented: true,
mode: AutoStartMode.BACKGROUND,
consentedAt: new Date().toISOString(),
purpose: current.purpose || '接收实时消息推送',
});
} else {
await saveAutoStartConsent({
consented: false,
mode: AutoStartMode.SILENT,
consentedAt: new Date().toISOString(),
purpose: '用户选择静默模式',
});
}
}
/**
* 检查是否允许自启动
*/
export function isAutoStartAllowed(): boolean {
return cachedConsent.consented && cachedConsent.mode === AutoStartMode.BACKGROUND;
}
/**
* 获取当前模式
*/
export function getCurrentAutoStartMode(): AutoStartMode {
return cachedConsent.mode;
}
/**
* 重置同意状态(用于测试或用户撤销同意)
*/
export async function resetAutoStartConsent(): Promise<void> {
cachedConsent = { ...DEFAULT_CONSENT };
await AsyncStorage.removeItem(STORAGE_KEY);
await AsyncStorage.removeItem(STORAGE_KEY_FIRST_LAUNCH);
}
/**
* 获取自启动说明文本(用于隐私政策和弹窗)
*/
export function getAutoStartDescription(): string {
return `为了及时接收消息推送,应用需要在以下场景自启动:
1. 设备开机完成后:恢复后台消息推送服务
2. 应用更新后:恢复后台任务调度
3. 系统重启后:恢复通知服务
自启动行为仅用于消息推送,不会收集额外个人信息。
您可以在"设置-通知设置"中随时更改此选项。`;
}
/**
* 获取自启动目的说明(用于隐私政策)
*/
export function getAutoStartPurposeForPrivacy(): string {
return `应用自启动/关联启动的目的与规则:
• 目的:确保用户能够及时接收消息推送通知
• 场景:设备开机、应用更新、系统重启后
• 规则:仅在用户同意后才启用自启动功能
• 必要性:对于需要实时消息通知的用户是必要的
• 用户控制:用户可随时在设置中关闭此功能
• 关闭影响:关闭后需手动打开应用才能接收消息`;
}

View File

@@ -0,0 +1,16 @@
export {
AutoStartMode,
loadAutoStartConsent,
saveAutoStartConsent,
getAutoStartConsentSync,
isFirstLaunch,
consentToAutoStart,
rejectAutoStart,
setAutoStartMode,
isAutoStartAllowed,
getCurrentAutoStartMode,
resetAutoStartConsent,
getAutoStartDescription,
getAutoStartPurposeForPrivacy,
} from './autoStartConsent';
export type { AutoStartConsent } from './autoStartConsent';

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,12 +550,23 @@ class ApiClient {
const headers: Record<string, string> = {};
if (token) {
if (this.isTokenExpiringSoon(token)) {
const refreshed = await this.refreshToken();
if (refreshed) {
const newToken = await this.getToken();
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`;
}
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

@@ -85,9 +85,8 @@ export const addStickerFromUrl = async (
*/
export const deleteSticker = async (stickerId: string): Promise<boolean> => {
try {
await api.delete('/stickers', {
data: { sticker_id: stickerId },
});
// api.delete(path, body) 的第二个参数直接作为请求体,不需要 axios 风格的 { data } 包装
await api.delete('/stickers', { sticker_id: stickerId });
return true;
} catch (error) {
console.error('删除自定义表情失败:', error);
@@ -130,9 +129,7 @@ export const batchDeleteStickers = async (stickerIds: string[]): Promise<{ succe
for (const stickerId of stickerIds) {
try {
await api.delete('/stickers', {
data: { sticker_id: stickerId },
});
await api.delete('/stickers', { sticker_id: stickerId });
success++;
} catch (error) {
console.error(`删除表情 ${stickerId} 失败:`, error);

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,
});
}
});
@@ -112,8 +147,10 @@ class JPushService {
// Register listeners only once (idempotent guard)
this._registerListeners();
// 退后台保持极光长连接,确保推送实时到达
this._setBackgroundKeepLongConn(true);
// 检查用户是否同意自启动,仅在同意后才保持后台长连接
const { isAutoStartAllowed } = await import('../consent/autoStartConsent');
const keepLongConn = isAutoStartAllowed();
this._setBackgroundKeepLongConn(keepLongConn);
// 初始化 SDK
JPush!.init({
@@ -429,6 +466,7 @@ class JPushService {
this.notificationCallback = null;
this.initPromise = null;
this.connectResolved = false;
this.arrivedMessageTimestamps.clear();
}
}

View File

@@ -39,17 +39,27 @@ export interface VersionCheckResult {
hasUpdate: boolean;
currentVersion: string;
latestVersion: string;
runtimeVersion: string;
downloadUrl: string;
size: number;
}
/**
* 比较版本号
* 比较语义版本号SemVer 三段数字)
* 返回: 1 表示 v1 > v2, -1 表示 v1 < v2, 0 表示相等
*
* 注意:仅做 x.y.z 形式的纯数字比较任何一段非数字NaN会被当作 0 处理,
* 避免运行时版本号commit count 等)混入时导致方向相反的误判。
*/
function compareVersions(v1: string, v2: string): number {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
const parts1 = v1.split('.').map((s) => {
const n = Number.parseInt(s, 10);
return Number.isFinite(n) ? n : 0;
});
const parts2 = v2.split('.').map((s) => {
const n = Number.parseInt(s, 10);
return Number.isFinite(n) ? n : 0;
});
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const p1 = parts1[i] || 0;
@@ -86,10 +96,10 @@ async function fetchLatestAPKVersion(): Promise<APKVersionInfo | null> {
}
/**
* 获取当前应用版本
* 获取当前应用语义版本(来自 app.json / app.config.js 的 expo.version
*/
function getCurrentVersion(): string {
return Constants.expoConfig?.version || '1.0.0';
return Constants.expoConfig?.version || '1.0.1';
}
/**
@@ -144,6 +154,7 @@ async function downloadAndInstallAPK(downloadUrl: string, version: string): Prom
}
try {
// 文件名带语义版本用户可读runtimeVersion 已在 downloadUrl 中体现
const downloadPath = `${FileSystem.documentDirectory}with-you-${version}.apk`;
// 显示下载进度
@@ -247,15 +258,18 @@ export async function checkForAPKUpdate(force: boolean = false): Promise<Version
}
const currentVersion = getCurrentVersion();
// 远端必须返回独立的语义版本号;若缺失才回退到 runtimeVersion不推荐但兜底
const latestVersion = latestAPK.versionName || latestAPK.runtimeVersion;
const remoteRuntimeVersion = latestAPK.runtimeVersion;
// 比较版本
// 比较版本(仅基于语义版本,不受 buildNumber / runtimeVersion 影响)
const comparison = compareVersions(currentVersion, latestVersion);
const result: VersionCheckResult = {
hasUpdate: comparison < 0,
currentVersion,
latestVersion,
runtimeVersion: remoteRuntimeVersion,
downloadUrl: latestAPK.downloadUrl,
size: latestAPK.size,
};

View File

@@ -62,23 +62,36 @@ class PostSyncService {
}
async fetchPosts(params?: GetPostsParams, key: string = 'default'): Promise<PostsResult> {
const store = usePostListStore.getState();
store.updatePostsState(key, { isLoading: true, error: null });
const queryParams: GetPostsParams = {
pageSize: params?.pageSize || DEFAULT_PAGE_SIZE,
...params,
cursor: params?.cursor !== undefined ? params.cursor : '',
};
// 判断参数是否变化:参数变化(切换分区/排序/筛选)时直接清空旧数据,
// 避免渲染期间残留上一个分区的帖子造成闪烁
const prevState = usePostListStore.getState().getPostsState(key);
const paramsChanged = JSON.stringify(prevState.lastParams) !== JSON.stringify(queryParams);
// 参数变化时立即写入空数据 + loading 状态,确保 UI 第一时间进入加载态
// 而不是先渲染上一个分区的旧帖子
usePostListStore.getState().updatePostsState(key, {
isLoading: true,
error: null,
...(paramsChanged ? { posts: [], cursor: null, currentPage: 1, hasMore: true } : {}),
});
try {
const queryParams: GetPostsParams = {
pageSize: params?.pageSize || DEFAULT_PAGE_SIZE,
...params,
cursor: params?.cursor !== undefined ? params.cursor : '',
};
const result = await postRepository.getPosts(queryParams);
const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null;
const currentState = usePostListStore.getState().getPostsState(key);
const incomingPosts = Array.isArray(result.posts) ? result.posts : [];
const mergeStart = Date.now();
const mergedPosts = mergeRefreshWindow(currentState.posts, incomingPosts);
// 参数变化时直接替换;同参数刷新则合并以保留滚动位置上的帖子
const mergedPosts = paramsChanged
? incomingPosts
: mergeRefreshWindow(currentState.posts, incomingPosts);
const mergeCost = Date.now() - mergeStart;
if (mergeCost >= MERGE_PERF_WARN_THRESHOLD_MS) {
console.log('[PostPerf] fetchPosts merge cost', { key, costMs: mergeCost, prev: currentState.posts.length, next: incomingPosts.length });
@@ -116,7 +129,8 @@ class PostSyncService {
const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null;
const incomingPosts = Array.isArray(result.posts) ? result.posts : [];
const mergeStart = Date.now();
const mergedPosts = mergeRefreshWindow(state.posts, incomingPosts);
// refreshPosts 是刷新操作,应直接替换当前列表,避免旧数据残留
const mergedPosts = incomingPosts;
const mergeCost = Date.now() - mergeStart;
if (mergeCost >= MERGE_PERF_WARN_THRESHOLD_MS) {
console.log('[PostPerf] refreshPosts merge cost', { key, costMs: mergeCost, prev: state.posts.length, next: incomingPosts.length });

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

@@ -41,6 +41,9 @@ export interface UploadResponse {
height?: number;
size?: number;
type?: string;
// 文件上传专用字段POST /uploads/files 返回)
name?: string;
mime_type?: string;
}
// 上传服务类
@@ -130,21 +133,27 @@ 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;
name?: string;
type?: string;
},
folder: string = 'general'
folder: string = 'chat'
): Promise<UploadResponse | null> {
try {
const response = await api.upload<UploadResponse>('/upload/file', {
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 {};

Some files were not shown because too many files have changed in this diff Show More