68 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
lafay
97477c3471 Merge branch 'dev' of https://code.littlelan.cn/carrot_bbs/frontend into dev
Some checks failed
Frontend CI / ota-android (push) Successful in 2m33s
Frontend CI / ota-ios (push) Successful in 8m2s
Frontend CI / build-and-push-web (push) Failing after 15m26s
Frontend CI / build-android-apk (push) Successful in 1h45m49s
2026-06-12 23:42:11 +08:00
lafay
1e05d2bd54 refactor(navigation): consolidate navigation with href helpers and extract push device hook
- Migrate all navigation calls from magic strings to centralized href helpers
- Extract inline device registration logic into useRegisterPushDevice hook
- Remove unused terms and privacy policy routes from profile stack
- Add hrefTradeDetail helper for trade detail navigation
- Restore routePayloadCache.stashSystemMessage for group request/invite handling
- Change desktop shell tab navigation from replace to push
2026-06-12 23:42:09 +08:00
lan
afbbee337d feat(ui): improve navigation, component props, and chat logic
Some checks failed
Frontend CI / ota-android (push) Successful in 1m31s
Frontend CI / build-and-push-web (push) Failing after 3m56s
Frontend CI / ota-ios (push) Successful in 4m59s
Frontend CI / build-android-apk (push) Successful in 26m18s
- update `app.json` with splash screen configuration
- implement tab navigation redirection in `TabsLayout` for apps and profile tabs
- update `HighlightText` prop usage from `style` to `highlightStyle` in `PostCard` and `PostContentRenderer`
- fix chat message segment construction to avoid empty text segments when sending only images
- refine UI styling by removing unnecessary shadows and adjusting `UserScreen` header visibility
- improve `HomeScreen` scroll behavior by disabling animation on FlashList scroll-to-top
2026-06-07 10:37:19 +08:00
lan
b15e0c0b0b feat(editor): implement deferred image uploading and pending state management
Some checks failed
Frontend CI / ota-ios (push) Successful in 2m3s
Frontend CI / ota-android (push) Successful in 2m4s
Frontend CI / build-and-push-web (push) Failing after 33m21s
Frontend CI / build-android-apk (push) Successful in 59m9s
Refactor the image handling workflow to decouple image selection from the upload process. This improves user experience by allowing users to continue composing posts or trades while images are queued for upload, and increases reliability by using a centralized pending image utility.

- **Deferred Uploads**: Replaced immediate image uploads in `CreatePostScreen`, `PostDetailScreen`, and `CreateTradeScreen` with a "pending" state. Images are now uploaded in bulk during the final submission phase.
- **BlockEditor Enhancements**:
    - Updated `useBlockEditor` to support asynchronous batch uploading of all pending images via a new `uploadPendingImages` method.
    - Removed inline uploading overlays in favor of a more robust state-driven approach.
    - Improved `BlockEditorHandle` to expose block retrieval and batch upload capabilities.
- **API Layer Improvements**:
    - Migrated native image uploads from standard `FormData` to `expo-file-system`'s multipart upload to resolve compatibility issues with recent React Native versions.
    - Maintained `fetch` + `Blob` logic for web platform compatibility.
- **New Utilities**: Introduced `src/utils/pendingImages.ts` to manage the lifecycle of local (pending) vs. remote (uploaded) images across the application.
2026-06-07 00:40:35 +08:00
lan
5c81795d39 fix(message): improve message synchronization and reliability
Some checks failed
Frontend CI / ota-android (push) Successful in 1m47s
Frontend CI / ota-ios (push) Successful in 1m49s
Frontend CI / build-and-push-web (push) Failing after 13m31s
Frontend CI / build-android-apk (push) Successful in 29m15s
Refactor the messaging subsystem to enhance data consistency, prevent race conditions during WebSocket reconnection, and ensure accurate unread counts.

- **WebSocket Reliability**: Implement `client_msg_id` for precise message ACK matching, preventing incorrect pending message resolution in high-frequency scenarios.
- **Sync Logic**: Update `WSMessageHandler` and `MessageManager` to use a bootstrapping state, ensuring buffered SSE events are flushed only after the initial synchronization is complete.
- **State Consistency**:
    - Introduce atomic unread count increments to prevent lost updates.
    - Implement conditional rollback for optimistic read receipts, ensuring that failed API calls do not overwrite newer, valid read states.
- **Resource Management**: Add reference counting to `useMessages` hook to prevent premature clearing of loading states during rapid conversation switching or React StrictMode double-invocations.
- **Data Integrity**: Update `UserCacheService` to re-read the latest message list before applying sender info enrichment, ensuring new messages arriving during the async process are correctly processed.
2026-06-06 13:10:08 +08:00
lafay
1f7e25349f refactor(ios): remove trailing comma from build script in package.json
Some checks failed
Frontend CI / ota-ios (push) Successful in 1m23s
Frontend CI / ota-android (push) Successful in 4m37s
Frontend CI / build-and-push-web (push) Failing after 4m39s
Frontend CI / build-android-apk (push) Successful in 54m21s
2026-06-06 00:08:34 +08:00
lafay
49e94fc056 chore(deps): remove patch-package postinstall script
Some checks failed
Frontend CI / ota-android (push) Failing after 12s
Frontend CI / ota-ios (push) Failing after 16s
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
Remove the postinstall hook that was automatically applying patches after
dependency installation.
2026-06-06 00:07:02 +08:00
lafay
cabac94aae feat(android): add expo-splash-screen support and patch-package integration
Some checks failed
Frontend CI / ota-android (push) Failing after 1m7s
Frontend CI / ota-ios (push) Failing after 1m7s
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
- Add expo-splash-screen dependency and plugin configuration
- Add postinstall script to automatically apply patches
- Update Android icon assets (foreground and monochrome)
- Add marginTop style to PostCard actions container
2026-06-06 00:00:45 +08:00
lafay
c6d65fe545 build(android): integrate Google Services plugin for Firebase support
Some checks failed
Frontend CI / ota-ios (push) Successful in 1m32s
Frontend CI / ota-android (push) Successful in 3m23s
Frontend CI / build-and-push-web (push) Failing after 4m2s
Frontend CI / build-android-apk (push) Successful in 2h7m26s
Add Google Services Gradle plugin integration to withHuaweiPush.js to enable Firebase initialization alongside Huawei Push. This includes:
- Adding google-services classpath dependency for buildscript
- Applying com.google.gms.google-services plugin after React plugin
- Copying google-services.json to android/app/ for runtime initialization

Without these changes, Firebase initialization fails at runtime with "default FirebaseApp is not initialized" when google-services.json is configured in app.json.
2026-06-04 13:54:59 +08:00
lan
f79415591d build(android): add google services and update NDK version
Some checks failed
Frontend CI / ota-android (push) Successful in 1m45s
Frontend CI / ota-ios (push) Successful in 1m49s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Configure Firebase/Google Services support for Android and update the CI
build pipeline to use a newer NDK version.

- Add `google-services.json` and configure `googleServicesFile` in `app.json`
- Add `com.google.gms:google-services:4.4.4` classpath to build workflow
- Update Android NDK version from `27.0.12077973` to `27.1.12297006` in CI cache and installation steps
2026-06-04 13:29:02 +08:00
lan
3cc3695d0e chore(deps): downgrade react-native-reanimated and worklets
Some checks failed
Frontend CI / ota-android (push) Successful in 2m1s
Frontend CI / ota-ios (push) Successful in 2m14s
Frontend CI / build-and-push-web (push) Failing after 34m46s
Frontend CI / build-android-apk (push) Successful in 1h9m44s
Update dependency versions in package.json to ensure compatibility and resolve potential build issues.

- Downgrade `react-native-reanimated` from `^4.4.0` to `~4.3.1`
- Downgrade `react-native-worklets` from `^0.9.1` to `^0.8.3`
2026-06-04 12:14:29 +08:00
lan
765fd8cce9 fix(platform): improve web compatibility and optimize module loading
Some checks failed
Frontend CI / ota-ios (push) Successful in 2m0s
Frontend CI / ota-android (push) Successful in 2m0s
Frontend CI / build-android-apk (push) Successful in 20m41s
Frontend CI / build-and-push-web (push) Failing after 22m49s
Refactor core services and components to prevent runtime errors on web platforms and optimize build environment.

- Update `Dockerfile.web` to use Node 25 for the build stage.
- Implement lazy loading for `expo-media-library` and `expo-file-system` in `ImageGallery` to prevent crashes on web where these native modules are unavailable.
- Refactor `CallKeepServiceImpl` to use dynamic imports for `expo-callkit-telecom`, ensuring the service remains compatible with web environments.
2026-06-04 11:32:05 +08:00
lan
2e2f6e3467 refactor(message): improve message synchronization and hook reliability
Some checks failed
Frontend CI / ota-android (push) Successful in 1m29s
Frontend CI / build-and-push-web (push) Failing after 1m31s
Frontend CI / ota-ios (push) Successful in 4m4s
Frontend CI / build-android-apk (push) Successful in 29m5s
Refactor the message management system to address synchronization issues and improve performance through request deduplication and enhanced lifecycle management.

- Implement in-flight promise tracking in `MessageSyncService` to prevent redundant network requests for conversations and messages.
- Enhance `useMessages` hook with `useFocusEffect` to trigger automatic synchronization when a chat screen regains focus.
- Add `forceSync` capability to `MessageManager` and `MessageSyncService` to allow manual overrides of loading states during re-entry.
- Consolidate WebSocket synchronization logic in `WSMessageHandler` using a throttled and deduplicated trigger mechanism.
- Clean up unused styles and deprecated hooks (`baseHooks.ts`, `bubbleStyles.ts`, `inputStyles.ts`).
- Update `metro.config.js` and `package.json` to include `@expo/ui` and optimize resolver settings.
2026-06-03 10:31:46 +08:00
lan
2e6912dddf feat(call): implement CallKeep integration and enhance group call support
Some checks failed
Frontend CI / ota-android (push) Successful in 1m27s
Frontend CI / build-and-push-web (push) Failing after 1m28s
Frontend CI / ota-ios (push) Successful in 2m58s
Frontend CI / build-android-apk (push) Successful in 51m46s
Integrate `expo-callkit-telecom` to support native system call handling (answering, ending, and muting via system UI). This includes a new `callKeepService` and a `CallKeepBootstrap` component to manage the lifecycle of system call events.

Additionally, improves the calling experience by:
- Adding support for group calls with participant tracking and UI indicators.
- Enhancing LiveKit integration by replacing polling with event-driven video track synchronization.
- Updating `WebSocketService` to prevent disconnection when the app enters the background during an active call.
- Adding new WebSocket message types for participant join/leave events and group invites.
- Refining call UI components (`CallScreen`, `FloatingCallWindow`, `IncomingCallModal`) for better visual feedback and safe area handling.

Refactor LiveKit service to use event-driven updates for local and remote tracks, improving performance and reliability.
2026-06-03 01:56:02 +08:00
lan
d6a94c7b4d feat(livekit): improve native audio/video handling and stability
All checks were successful
Frontend CI / ota-ios (push) Successful in 1m55s
Frontend CI / ota-android (push) Successful in 1m57s
Frontend CI / build-and-push-web (push) Successful in 4m8s
Frontend CI / build-android-apk (push) Successful in 12m20s
Implement robust WebRTC polyfills and native audio session management for
LiveKit on mobile platforms. This includes configuring iOS audio sessions
to prevent issues during calls and implementing a delayed video
activation strategy to avoid main thread deadlocks on iOS.

- Add WebRTC and environment polyfills in `src/polyfills.ts`
- Implement iOS-specific `AudioSession` configuration in `LiveKitService`
- Add support for toggling speaker output on iOS
- Update `callStore` to handle video enablement after connection settles
- Fix `useUnreadCountQuery` to correctly handle async fetching
2026-06-02 13:28:16 +08:00
lafay
8ee6e77cb4 chore: update styles, add splash config, and improve search functionality
All checks were successful
Frontend CI / ota-android (push) Successful in 1m50s
Frontend CI / ota-ios (push) Successful in 2m38s
Frontend CI / build-and-push-web (push) Successful in 10m12s
Frontend CI / build-android-apk (push) Successful in 17m46s
- Replace deprecated StyleSheet.absoluteFillObject with StyleSheet.absoluteFill (React Native 0.76+)
- Add splash screen configuration in app.json
- Fix duplicate UIBackgroundModes and add audio mode for iOS
- Disable Android ripple effect on tab bar buttons
- Improve SmartImage loading state to prevent unnecessary re-renders
- Refactor ImageGrid to use mainUri with separate previewUrl for better preview handling
- Add market search support with trade items tab in SearchScreen
- Pass homeTab prop to SearchScreen for context-aware search behavior
- Simplify fetchUnreadCount return type to void in MessageSyncService
- Fix StatusBar import from expo to react-native in ChatScreen
2026-06-02 08:14:04 +08:00
lan
52d2581dda refactor(plugins): update JPush config and improve SmartImage loading
All checks were successful
Frontend CI / ota-ios (push) Successful in 1m52s
Frontend CI / ota-android (push) Successful in 3m54s
Frontend CI / build-and-push-web (push) Successful in 5m14s
Frontend CI / build-android-apk (push) Successful in 24m39s
- Update `withJPush` plugin to support Expo SDK 56+ by using `JPUSHService.setup` instead of `JCoreModule.setup` and adding `UNUserNotificationCenterDelegate` conformance.
- Improve `SmartImage` component by adding a ref to prevent loading state flickering when transitioning from preview to original image.
- Upgrade `@shopify/flash-list` dependency.
- Add transition prop to `SmartImage` for smoother image loading.
2026-06-02 01:02:41 +08:00
lafay
e7eae716be fix(ci): add Huawei AGConnect plugin to CI Gradle 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) Successful in 45m47s
The CI workflow overwrites build.gradle/settings.gradle with China Maven
mirrors but was missing the Huawei AGConnect classpath dependency and
Maven repo, causing "Plugin with id 'com.huawei.agconnect' not found".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:29:33 +08:00
lafay
ad06881b85 chore(deps): upgrade Expo SDK from 55 to 56
Some checks failed
Frontend CI / ota-android (push) Successful in 3m39s
Frontend CI / ota-ios (push) Successful in 3m40s
Frontend CI / build-and-push-web (push) Successful in 11m53s
Frontend CI / build-android-apk (push) Failing after 13m6s
Migrate from @react-navigation/native to expo-router navigation hooks across all screens.
Add polyfills module and apply LiveKit VideoView optional loading for Expo Go compatibility.
Improve background sync to skip when user is not logged in.
Enhance fetchUnreadCount to return typed totalUnread and systemUnread values.
2026-06-01 22:56:37 +08:00
lan
f39288f401 chore(config): remove @livekit/react-native from expo plugins
Some checks failed
Frontend CI / ota-ios (push) Successful in 2m16s
Frontend CI / ota-android (push) Successful in 2m18s
Frontend CI / build-and-push-web (push) Successful in 4m28s
Frontend CI / build-android-apk (push) Failing after 1m3s
2026-06-01 13:47:34 +08:00
lan
70ab00795a feat(call): migrate from WebRTC to LiveKit
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) Has been cancelled
Replace the custom WebRTC implementation with LiveKit for improved
stability and feature support.

- Remove `react-native-webrtc` and custom `WebRTCManager`
- Implement `LiveKitService` for room and track management
- Update `callStore` to handle LiveKit events and connection states
- Refactor `CallScreen` (mobile and web) to use `@livekit/react-native`
  and `VideoView`
- Update WebSocket protocol to use `call_ready` instead of manual SDP/ICE
  exchanges
- Add necessary camera and microphone permissions to `app.json`
- Update Metro and Babel configurations for LiveKit compatibility
2026-06-01 13:45:45 +08:00
lan
72c30ed156 feat(huawei-push): implement settings.gradle configuration via withSettingsGradle
Some checks failed
Frontend CI / ota-android (push) Successful in 1m41s
Frontend CI / ota-ios (push) Successful in 1m40s
Frontend CI / build-and-push-web (push) Successful in 3m5s
Frontend CI / build-android-apk (push) Failing after 8m43s
Integrate Huawei Maven repository and AGConnect plugin into settings.gradle using the withSettingsGradle helper. This ensures the necessary repositories and plugin management are correctly configured for Huawei Push services.
2026-05-31 21:31:54 +08:00
lan
b86fe324f5 chore(config): add withSettingsGradle to Huawei push plugin
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 53s
Frontend CI / ota-ios (push) Successful in 1m40s
Frontend CI / ota-android (push) Successful in 1m49s
Frontend CI / build-android-apk (push) Failing after 14s
Import `withSettingsGradle` from `@expo/config-plugins` to support necessary Gradle configuration within the Huawei push plugin.
2026-05-25 14:08:03 +08:00
lan
313820ed20 feat(message): enhance message store management and UI stability
Some checks failed
Frontend CI / ota-android (push) Successful in 1m40s
Frontend CI / ota-ios (push) Successful in 1m38s
Frontend CI / build-and-push-web (push) Successful in 4m32s
Frontend CI / build-android-apk (push) Failing after 8m53s
Implement `removeMessage` in `useMessageStore` to allow for atomic message deletion from the state. Improve `useChatScreen` loading logic to prevent layout jumps and ensure consistent UI state during initial message loading. Additionally, add type safety for WebSocket sequence numbers and optimize message selector stability.

- Add `removeMessage` action to `useMessageStore` for state-consistent deletions
- Refine `useChatScreen` loading state logic to prevent FlashList layout issues
- Ensure `useMessages` hook returns a stable `EMPTY_MESSAGES` constant
- Cast WebSocket `seq` to integer to ensure consistent numeric comparison
- Update `useChatScreen` to use `useMessageStore` for immediate local deletion feedback
2026-05-25 02:30:59 +08:00
lan
f4db4eb1ed refactor(message): implement atomic message merging and patching to prevent race conditions
Some checks failed
Frontend CI / ota-ios (push) Successful in 1m11s
Frontend CI / ota-android (push) Successful in 2m20s
Frontend CI / build-and-push-web (push) Successful in 3m55s
Frontend CI / build-android-apk (push) Failing after 9m29s
Introduce `mergeMessages` and `patchMessages` to the message store to handle
updates atomically within Zustand's `set` callback. This replaces manual
read-modify-write patterns in services, preventing message loss during
concurrent WebSocket updates and synchronization processes.

- Add `mergeMessages` for atomic merging of new messages into existing lists
- Add `patchMessages` for efficient field updates (e.g., sender info, status)
- Update `MessageSendService`, `MessageSyncService`, and `WSMessageHandler`
  to use these new atomic operations
- Remove reliance on external `mergeMessagesById` in service layers to
  ensure state consistency
2026-05-25 01:44:23 +08:00
lan
7a17323e8d perf(profile): improve rendering performance and selector efficiency
Some checks failed
Frontend CI / ota-android (push) Successful in 1m35s
Frontend CI / ota-ios (push) Successful in 1m34s
Frontend CI / build-and-push-web (push) Successful in 2m48s
Frontend CI / build-android-apk (push) Failing after 12m16s
Optimize profile-related screens and components by implementing memoization and granular Zustand selectors to reduce unnecessary re-renders.

- **Performance Optimization**:
  - Wrap `TabBar` in `React.memo` to prevent re-renders when parent components update.
  - Refactor `UserProfileScreen` to use a memoized `ProfileListHeader` component, decoupling the user header from tab state changes.
- **State Management**:
  - Replace object destructuring from `useAuthStore` with granular selectors (e.g., `useAuthStore((s) => s.logout)`) in `AccountDeletionScreen`, `EditProfileScreen`, `FollowListScreen`, and `SettingsScreen` to prevent re-renders on unrelated store changes.
- **Code Cleanup**:
  - Remove redundant case logic in `useUserProfile` hook.
2026-05-18 01:06:46 +08:00
lan
4fde3e403a refactor(core): optimize state management and component rendering performance
Some checks failed
Frontend CI / ota-ios (push) Successful in 1m39s
Frontend CI / ota-android (push) Successful in 1m39s
Frontend CI / build-android-apk (push) Failing after 1m52s
Frontend CI / build-and-push-web (push) Successful in 22m5s
Improve application stability and performance by optimizing Zustand store usage, implementing memoization patterns, and introducing persistent authentication.

- **State Management**:
  - Refactor Zustand selectors to use `useShallow` and `getState()` to prevent unnecessary re-renders and infinite loops in hooks.
  - Implement `persist` middleware for `authStore` to maintain user sessions across restarts.
  - Introduce `buildStateCached` in `themeStore` to reduce redundant theme object computations.
- **Performance & Rendering**:
  - Implement `useMemo` for stable object/array references in `ImageGallery` and list components to prevent expensive re-renders.
  - Replace inline arrow functions with `useCallback` in complex screens like `ChatScreen` and `MessageListScreen`.
  - Optimize `FlashList` usage by providing stable `key` and `extraData` props.
- **Architecture**:
  - Decouple unread count fetching by introducing `useUnreadCountQuery` (React Query) for better caching and synchronization.
  - Add `useChannels` hook to centralize channel data fetching.
  - Refine `SessionGate` logic to allow immediate rendering of authenticated users while verifying in the background.
2026-05-18 00:39:25 +08:00
lan
fb67fb6d5b feat(message): implement version-based incremental sync and WS compression
Some checks failed
Frontend CI / ota-android (push) Successful in 1m58s
Frontend CI / ota-ios (push) Successful in 2m2s
Frontend CI / build-android-apk (push) Failing after 3m22s
Frontend CI / build-and-push-web (push) Successful in 4m19s
Implement a more efficient conversation synchronization mechanism using
version numbers instead of sequence numbers to reduce bandwidth usage.
This includes adding support for Gzip decompression on WebSocket
messages to optimize data transfer.

- Add `pako` for WebSocket message decompression
- Implement `getSyncByVersion` in `MessageService`
- Implement `syncByVersion` in `MessageSyncService` to handle incremental
  updates and conversation state changes
- Update `WebSocketService` to support binary frames and Gzip inflation
- Add `syncVersion` to `MessageStore` for tracking synchronization state
2026-05-17 23:37:38 +08:00
lan
404b3fabe7 style(message): remove shadow from message bubble outer container
Some checks failed
Frontend CI / ota-ios (push) Successful in 1m17s
Frontend CI / ota-android (push) Successful in 1m24s
Frontend CI / build-and-push-web (push) Successful in 3m3s
Frontend CI / build-android-apk (push) Failing after 1h47m14s
2026-05-15 14:50:00 +08:00
lan
d8d2b03f94 feat(message): implement message search functionality
Some checks failed
Frontend CI / ota-android (push) Successful in 1m19s
Frontend CI / ota-ios (push) Successful in 1m50s
Frontend CI / build-and-push-web (push) Successful in 3m28s
Frontend CI / build-android-apk (push) Failing after 8m56s
Add ability to search messages within a specific conversation. This includes:
- New `MessageSearchScreen` for displaying search results.
- `MessageRepository.searchByConversation` to query messages by keyword and conversation ID.
- Integration of search entry points in `GroupInfoScreen` and `PrivateChatInfoScreen`.
- Support for scrolling to a specific message sequence (`scrollToSeq`) when navigating from search results.
- Enhanced `HighlightText` component to support custom highlight styles.

feat(message): implement message search functionality
2026-05-14 02:26:32 +08:00
199 changed files with 18987 additions and 9386 deletions

View File

@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); deps={**d.get\\('dependencies',{}\\), **d.get\\('devDependencies',{}\\)}; [print\\(f'{k}: {v}'\\) for k,v in deps.items\\(\\) if 'navigation' in k or 'tab' in k.lower\\(\\)]\")",
"Bash(npx tsc *)",
"Bash(echo \"EXIT: $?\")",
"Bash(npm view *)",
"Bash(npx patch-package *)",
"Bash(rm -rf node_modules/expo-router)",
"Bash(npm install *)",
"Bash(python -c \"import struct, os; paths=['D:/codes/carrot_bbs/frontend/assets/splash-icon.png','D:/codes/carrot_bbs/frontend/assets/icon.png','D:/codes/carrot_bbs/frontend/assets/android-icon-foreground.png','D:/codes/carrot_bbs/frontend/assets/android-icon-background.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png'];\\\\nfor p in paths:\\\\n try:\\\\n with open\\(p,'rb'\\) as f: data=f.read\\(33\\)\\\\n sig=data[:8]; w=h=None\\\\n if sig==b'\\\\\\\\x89PNG\\\\\\\\r\\\\\\\\n\\\\\\\\x1a\\\\\\\\n': w,h=struct.unpack\\('>II', data[16:24]\\)\\\\n print\\(f'{p} | exists={os.path.exists\\(p\\)} | bytes={os.path.getsize\\(p\\)} | png={sig==b\"\\\\\\\\x89PNG\\\\\\\\r\\\\\\\\n\\\\\\\\x1a\\\\\\\\n\"} | size={w}x{h}'\\)\\\\n except Exception as e:\\\\n print\\(f'{p} | ERROR {e}'\\)\")",
"Bash(python -c 'import struct, os; paths=[\"D:/codes/carrot_bbs/frontend/assets/splash-icon.png\",\"D:/codes/carrot_bbs/frontend/assets/icon.png\",\"D:/codes/carrot_bbs/frontend/assets/android-icon-foreground.png\",\"D:/codes/carrot_bbs/frontend/assets/android-icon-background.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png\"]; sig_png=b\"\\\\x89PNG\\\\r\\\\n\\\\x1a\\\\n\"; [print\\(f\"{p} | bytes={os.path.getsize\\(p\\)} | png={open\\(p,\"rb\"\\).read\\(8\\)==sig_png} | size={struct.unpack\\(\">II\", open\\(p,\"rb\"\\).read\\(24\\)[16:24]\\) if open\\(p,\"rb\"\\).read\\(8\\)==sig_png else None}\"\\) for p in paths]')"
]
}
}

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
android
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,201 +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.0.12077973
key: ndk-27.0.12077973-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.0.12077973..."
yes | sdkmanager --install "ndk;27.0.12077973"
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' }
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)
def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
}
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
}
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
}
expoAutolinking.useExpoModules()
rootProject.name = '威友'
expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(expoAutolinking.reactNativeGradlePlugin)
SETTINGS_EOF
# Update build.gradle 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' }
google()
mavenCentral()
}
dependencies {
classpath('com.android.tools.build:gradle')
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' }
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
# Decode Android signing keystore
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > app/withyou-release-key.keystore
# 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
# CI build tuning (low-memory container): force single-worker, reduce JVM heap,
# plus restore properties that prebuild does NOT generate but the build needs.
# printf '\n' below is defensive — even though the signing block above already
# ensured a trailing newline, both blocks should be independently safe.
{
printf '\n'
echo "org.gradle.daemon=false"
echo "org.gradle.parallel=false"
echo "org.gradle.configureondemand=false"
echo "org.gradle.workers.max=1"
echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError"
echo "kotlin.daemon.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m"
echo "ndkVersion=27.1.12297006"
echo "systemProp.org.gradle.internal.http.connectionTimeout=30000"
echo "systemProp.org.gradle.internal.http.socketTimeout=30000"
# ExpoAutolinkingPlugin requires this; without it Gradle calls
# `node ... mirror-kotlin-inline-modules --watched-directories-serialized ''`
# and node JSON.parse('') throws SyntaxError, exiting 1.
# See ExpoAutolinkingPlugin.kt:51 (findProperty fallback to emptyList)
# and ExpoAutolinkingPlugin.kt:67 (passes value to node as-is).
echo "expo.inlineModules.watchedDirectories=[]"
} >> gradle.properties
# Verify signing config in app/build.gradle
echo "=== app/build.gradle signing section ==="
grep -A 20 'signingConfigs' app/build.gradle || echo "signingConfigs not found"
# Diagnostic: dump full gradle.properties + verify all 4 signing keys are at column 0.
# Each grep -c MUST print 1; 0 means the key was concatenated onto a previous line
# (typically because prebuild's gradle.properties had no trailing newline — see fix above).
echo "=== gradle.properties (final) ==="
cat gradle.properties
echo "=== signing keys column-0 check (each must be 1) ==="
for k in MYAPP_UPLOAD_STORE_FILE MYAPP_UPLOAD_STORE_PASSWORD MYAPP_UPLOAD_KEY_ALIAS MYAPP_UPLOAD_KEY_PASSWORD; do
count=$(grep -c "^${k}=" gradle.properties || true)
echo "${k}=${count}"
if [ "${count}" != "1" ]; then
echo "FATAL: ${k} not found at column 0 — release signing will fail."
exit 1
fi
done
- name: Build Android release APK (arm64 only)
run: |
cd android
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
@@ -406,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}"
@@ -481,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

@@ -1,4 +1,4 @@
FROM node:20-alpine AS builder
FROM node:25-alpine AS builder
WORKDIR /app
@@ -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,
@@ -95,7 +102,8 @@ module.exports = {
// 配置 Web 端别名,避免加载原生模块
babel: {
dangerouslyAddModulePathsToTranspile: [
'react-native-webrtc',
'livekit-client',
'@livekit',
],
},
},

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",
@@ -16,9 +16,8 @@
"supportsTablet": true,
"infoPlist": {
"NSMicrophoneUsageDescription": "允许威友访问您的麦克风以进行语音通话",
"NSCameraUsageDescription": "允许威友访问您的相机以进行视频通话",
"UIBackgroundModes": [
"fetch",
"remote-notification",
"fetch",
"remote-notification",
"audio"
@@ -28,6 +27,7 @@
"bundleIdentifier": "cn.qczlit.weiyou"
},
"android": {
"googleServicesFile": "./google-services.json",
"adaptiveIcon": {
"backgroundColor": "#0570F9",
"foregroundImage": "./assets/android-icon-foreground.png",
@@ -35,12 +35,11 @@
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false,
"jsEngine": "hermes",
"package": "cn.qczlit.withyou",
"versionCode": 7,
"permissions": [
"VIBRATE",
"RECORD_AUDIO",
"CAMERA",
"RECEIVE_BOOT_COMPLETED",
"WAKE_LOCK",
"READ_EXTERNAL_STORAGE",
@@ -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
}
],
[
@@ -153,7 +154,21 @@
"channel": "developer-default"
}
],
"./plugins/withHuaweiPush"
"./plugins/withHuaweiPush",
"./plugins/withXiaomiPush",
"./plugins/withHonorPush",
"./plugins/withVivoPush",
"./plugins/withOppoPush",
"expo-callkit-telecom",
[
"expo-splash-screen",
{
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
}
],
"expo-status-bar"
],
"extra": {
"eas": {

View File

@@ -1,22 +1,35 @@
import { useMemo, useCallback } from 'react';
import { Platform, useWindowDimensions } from 'react-native';
import { Tabs, usePathname } from 'expo-router';
import { Platform, Pressable, useWindowDimensions } from 'react-native';
import { Tabs, usePathname, useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { PressableProps } from 'react-native';
import { useAppColors, shadows } from '../../../src/theme';
import { BREAKPOINTS } from '../../../src/hooks';
import { useHomeTabBarVisibilityStore, useTotalUnreadCount, useHomeTabPressStore } from '../../../src/stores';
import { hrefHome } from '../../../src/navigation/hrefs';
const TAB_BAR_HEIGHT = 56;
const TAB_BAR_FLOATING_MARGIN = 12;
const TAB_BAR_MARGIN = 20;
const TabBarButton = ({ children, style, ...rest }: PressableProps) => (
<Pressable
{...rest}
style={style}
android_ripple={null}
>
{children}
</Pressable>
);
export default function TabsLayout() {
const colors = useAppColors();
const { width } = useWindowDimensions();
const insets = useSafeAreaInsets();
const pathname = usePathname();
const router = useRouter();
const unreadCount = useTotalUnreadCount();
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
const triggerHomeTabPress = useHomeTabPressStore((s) => s.triggerPress);
@@ -24,11 +37,30 @@ export default function TabsLayout() {
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
const handleHomeTabPress = useCallback(() => {
if (pathname === '/home') {
triggerHomeTabPress();
const handleHomeTabPress = useCallback(
(e: { preventDefault: () => void }) => {
if (!isHomeStackRoute) return;
e.preventDefault();
if (pathname === '/home') {
triggerHomeTabPress();
} else {
router.navigate(hrefHome());
}
},
[isHomeStackRoute, pathname, triggerHomeTabPress, router]
);
const handleAppsTabPress = useCallback(() => {
if (pathname.startsWith('/apps/')) {
router.replace('/apps');
}
}, [pathname, triggerHomeTabPress]);
}, [pathname, router]);
const handleProfileTabPress = useCallback(() => {
if (pathname.startsWith('/profile/')) {
router.replace('/profile');
}
}, [pathname, router]);
const tabBarStyle = useMemo(() => {
if (hideTabBar) {
@@ -72,6 +104,7 @@ export default function TabsLayout() {
marginHorizontal: 2,
paddingVertical: 1,
},
tabBarButton: (props) => <TabBarButton {...props} />,
tabBarShowLabel: true,
tabBarLabelStyle: { fontSize: 11, fontWeight: '600', marginTop: -1 },
}}
@@ -104,6 +137,9 @@ export default function TabsLayout() {
/>
),
}}
listeners={{
tabPress: handleAppsTabPress,
}}
/>
<Tabs.Screen
name="messages"
@@ -131,6 +167,9 @@ export default function TabsLayout() {
/>
),
}}
listeners={{
tabPress: handleProfileTabPress,
}}
/>
</Tabs>
);

View File

@@ -17,8 +17,7 @@ export default function ProfileStackLayout() {
<Stack.Screen name="blocked-users" />
<Stack.Screen name="chat-settings" />
<Stack.Screen name="about" />
<Stack.Screen name="terms" />
<Stack.Screen name="privacy" />
<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,5 +0,0 @@
import { PrivacyPolicyScreen } from '../../../../src/screens/profile/PrivacyPolicyScreen';
export default function PrivacyPolicyRoute() {
return <PrivacyPolicyScreen />;
}

View File

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

View File

@@ -1,83 +1,54 @@
import { useEffect, useRef } from 'react';
import { Platform } from 'react-native';
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 { jpushService } from '../../src/services/notification/jpushService';
function getDeviceType(): 'ios' | 'android' | 'web' {
switch (Platform.OS) {
case 'ios': return 'ios';
case 'android': return 'android';
default: return 'web';
}
}
async function getOrCreateDeviceID(): Promise<string> {
try {
const AsyncStorage = require('@react-native-async-storage/async-storage').default;
const DEVICE_ID_KEY = 'withyou_device_id';
let deviceId = await AsyncStorage.getItem(DEVICE_ID_KEY);
if (deviceId) return deviceId;
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 8);
deviceId = `${Platform.OS}_${timestamp}_${random}`;
await AsyncStorage.setItem(DEVICE_ID_KEY, deviceId);
return deviceId;
} catch {
return `${Platform.OS}_${Date.now()}`;
}
}
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 deviceRegistered = useRef(false);
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();
}, []);
useEffect(() => {
if (!isAuthenticated || !userID) return;
if (!deviceRegistered.current) {
deviceRegistered.current = true;
if (Platform.OS === 'web') {
// Web: register device without push_token (JPush not available on web)
getOrCreateDeviceID().then((deviceId) => {
const { pushService } = require('../../src/services/notification/pushService');
pushService.registerDevice({
device_id: deviceId,
device_type: 'web',
device_name: 'Web Browser',
}).catch((err: any) => {
console.warn('[Push] web device register failed:', err?.message);
});
});
} else {
// Mobile: JPush handles registration internally in initialize()
jpushService.initialize().then((ok) => {
if (ok && userID) {
jpushService.setAliasForUser(userID);
}
});
}
}
return () => {
if (!isAuthenticated) {
deviceRegistered.current = false;
jpushService.cleanup();
}
};
}, [isAuthenticated, userID]);
if (!isAuthenticated) {
return <Redirect href="/login" />;
// 持久化状态显示已登录则立即渲染(后台静默校验)
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,28 +1,56 @@
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';
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="/login" />;
// 持久化状态显示已登录则立即渲染(后台静默校验)
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

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

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import '../src/polyfills';
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';
@@ -10,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,
@@ -23,10 +22,12 @@ 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';
import { callKeepService } from '@/services/callkeep';
import { callStore } from '@/stores/call';
import * as hrefs from '../src/navigation/hrefs';
registerNotificationPresentationHandler();
@@ -100,32 +101,6 @@ function SystemChrome() {
return null;
}
function SessionGate({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
const colors = useAppColors();
useEffect(() => {
fetchCurrentUser().finally(() => setReady(true));
}, [fetchCurrentUser]);
if (!ready) {
return (
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.default,
}}
>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
);
}
return <>{children}</>;
}
function NotificationBootstrap() {
const appState = useRef<AppStateStatus>(AppState.currentState);
const permissionRequested = useRef(false);
@@ -138,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) => {
@@ -152,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;
@@ -165,22 +148,24 @@ function NotificationBootstrap() {
if (conversationId) {
const isGroup = conversationType === 'group';
const q = new URLSearchParams();
if (isGroup) {
q.set('isGroupChat', '1');
if (groupId) q.set('groupId', groupId);
if (groupName) q.set('groupName', groupName);
if (groupAvatar) q.set('groupAvatar', groupAvatar);
} else if (senderId) {
q.set('userId', senderId);
}
const qs = q.toString();
router.push(`/chat/${encodeURIComponent(conversationId)}${qs ? `?${qs}` : ''}`);
// 使用 navigate 而非 push避免
// 1. 在已有聊天页上创建重复实例(用户需要退出两次)
// 2. 从帖子等页面打开聊天通知后返回到帖子(而非消息列表)
router.navigate(
hrefs.hrefChat({
conversationId,
userId: isGroup ? undefined : senderId,
isGroupChat: isGroup,
groupId: isGroup ? groupId : undefined,
groupName: isGroup ? groupName : undefined,
groupAvatar: isGroup ? groupAvatar : undefined,
})
);
} else {
router.push('/messages/notifications');
router.navigate(hrefs.hrefNotifications());
}
} else {
router.push('/messages/notifications');
router.navigate(hrefs.hrefNotifications());
}
});
@@ -232,6 +217,37 @@ function APKUpdateBootstrap() {
return null;
}
function CallKeepBootstrap() {
useEffect(() => {
if (Platform.OS === 'web') return;
callKeepService.initialize({
onSystemAnswered: () => {
callStore.getState().handleSystemAnswer();
},
onSystemEnded: () => {
callStore.getState().endCall('ended');
},
onSystemMuted: (muted: boolean) => {
callStore.getState().handleSystemMuted(muted);
},
onIncomingCallFromPush: (session) => {
callStore.getState().handleIncomingFromPush(session);
},
onOutgoingStarted: () => {
// Outgoing call accepted by system — no action needed,
// the WS call_invited / call_accepted flow handles everything
},
});
return () => {
callKeepService.dispose();
};
}, []);
return null;
}
function ThemedStack() {
const router = useRouter();
const resolved = useResolvedColorScheme();
@@ -241,39 +257,16 @@ function ThemedStack() {
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
<SystemChrome />
<EventSubscriber />
<SessionGate>
<NotificationBootstrap />
<APKUpdateBootstrap />
<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="terms"
options={{
headerShown: false,
}}
/>
<Stack.Screen
name="privacy"
options={{
headerShown: false,
}}
/>
<Stack.Screen
name="post/[postId]"
options={{
headerShown: false,
}}
/>
<Stack.Screen
name="user/[userId]"
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>
</>
);
}

View File

@@ -1,11 +1,12 @@
import { Redirect } from 'expo-router';
import { useAuthStore } from '../src/stores';
import { hrefAuthWelcome, hrefHome } from '../src/navigation/hrefs';
export default function Index() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
if (isAuthenticated) {
return <Redirect href="/home" />;
return <Redirect href={hrefHome()} />;
}
return <Redirect href="/welcome" />;
return <Redirect href={hrefAuthWelcome()} />;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 20 KiB

23
google-services.json Normal file
View File

@@ -0,0 +1,23 @@
{
"project_info": {
"project_number": "000000000000",
"project_id": "withyou-dummy",
"storage_bucket": "withyou-dummy.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
"android_client_info": {
"package_name": "cn.qczlit.withyou"
}
},
"api_key": [
{
"current_key": "AIzaSyDummyKeyForFirebaseInitOnly"
}
]
}
],
"configuration_version": "1"
}

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

View File

@@ -7,10 +7,6 @@ const config = getDefaultConfig(__dirname);
// Add wasm asset support
config.resolver.assetExts.push('wasm');
// Fix for react-native-webrtc event-target-shim import warning
// The package doesn't properly export "./index" in its exports field
config.resolver.unstable_enablePackageExports = false;
// Support TypeScript path aliases (@/ -> ./src/)
config.resolver.alias = {
'@': path.resolve(__dirname, 'src'),
@@ -36,7 +32,6 @@ const webShimPaths = [
// These packages import requireNativeComponent directly from 'react-native'
// (which resolves to react-native-web on web) and don't have web implementations.
const webPackageShims = {
'react-native-webrtc': path.resolve(__dirname, 'web-shims/react-native-webrtc/index.js'),
};
const originalResolveRequest = config.resolver.resolveRequest;

7462
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,68 +11,91 @@
"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.18",
"@expo/vector-icons": "^15.1.1",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.15.2",
"@react-navigation/material-top-tabs": "^7.4.16",
"@react-navigation/native": "^7.1.31",
"@react-navigation/native-stack": "^7.14.2",
"@livekit/react-native": "^2.11.0",
"@livekit/react-native-webrtc": "^144.1.0",
"@react-native-async-storage/async-storage": "^3.1.1",
"@shopify/flash-list": "^2.3.1",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6",
"date-fns": "^4.1.0",
"expo": "~55.0.4",
"expo-background-task": "~55.0.10",
"expo-camera": "^55.0.10",
"expo-constants": "~55.0.7",
"expo-dev-client": "~55.0.10",
"expo-file-system": "~55.0.10",
"expo-font": "~55.0.4",
"expo-haptics": "~55.0.8",
"expo-image": "^55.0.5",
"expo-image-picker": "^55.0.10",
"expo-intent-launcher": "~55.0.9",
"expo-linear-gradient": "^55.0.8",
"expo-media-library": "~55.0.9",
"expo-notifications": "~55.0.0",
"expo-router": "^55.0.4",
"expo-sqlite": "~55.0.10",
"expo-status-bar": "~55.0.4",
"expo-system-ui": "^55.0.9",
"expo-task-manager": "~55.0.9",
"expo-updates": "^55.0.12",
"expo-video": "^55.0.10",
"jcore-react-native": "^2.3.5",
"jpush-react-native": "^3.2.6",
"katex": "^0.16.42",
"markdown-it": "^14.1.1",
"@tanstack/react-query": "^5.100.14",
"@types/react": "~19.2.14",
"axios": "^1.16.1",
"date-fns": "^4.4.0",
"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.7",
"expo-haptics": "~56.0.3",
"expo-image": "~56.0.6",
"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.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.19",
"expo-updates": "~56.0.19",
"expo-video": "~56.1.4",
"jcore-react-native": "^2.3.6",
"jpush-react-native": "^3.2.7",
"katex": "^0.17.0",
"livekit-client": "^2.19.2",
"markdown-it": "^14.2.0",
"pako": "^2.1.0",
"prismjs": "^1.30.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.2",
"react-native-gesture-handler": "^2.30.0",
"react-native-pager-view": "^8.0.0",
"react-native-paper": "^5.15.0",
"react-native-reanimated": "^4.2.1",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0",
"react-native-share": "^12.2.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.85.3",
"react-native-gesture-handler": "^3.0.0",
"react-native-pager-view": "^8.0.2",
"react-native-paper": "^5.15.3",
"react-native-reanimated": "~4.3.1",
"react-native-safe-area-context": "~5.8.0",
"react-native-screens": "4.25.2",
"react-native-share": "^12.3.1",
"react-native-sse": "^1.2.1",
"react-native-web": "^0.21.0",
"react-native-webrtc": "^124.0.7",
"react-native-webview": "13.16.0",
"react-native-worklets": "0.7.2",
"zod": "^4.3.6",
"zustand": "^5.0.11"
"react-native-webview": "13.16.1",
"react-native-worklets": "^0.8.3",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
"devDependencies": {
"@react-native-community/cli": "^20.1.2",
"@types/react": "~19.2.2",
"@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",
"typescript": "~5.9.2"
"jest": "^29.7.0",
"ts-jest": "^29.1.2",
"typescript": "~6.0.3"
},
"private": true
"private": true,
"expo": {
"install": {
"exclude": [
"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,4 @@
plugins {
kotlin("jvm") version "2.1.20" apply false
id("java-gradle-plugin")
}

View File

@@ -0,0 +1,22 @@
[versions]
agp = "8.12.0"
gson = "2.8.9"
guava = "31.0.1-jre"
javapoet = "1.13.0"
junit = "4.13.2"
kotlin = "2.1.20"
assertj = "3.25.1"
ktfmt = "0.22.0"
[libraries]
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
guava = { module = "com.google.guava:guava", version.ref = "guava" }
javapoet = { module = "com.squareup:javapoet", version.ref = "javapoet" }
junit = {module = "junit:junit", version.ref = "junit" }
assertj = { module = "org.assertj:assertj-core", version.ref = "assertj" }
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" }

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -0,0 +1,83 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.ktfmt)
id("java-gradle-plugin")
}
repositories {
google()
mavenCentral()
}
gradlePlugin {
plugins {
create("react") {
id = "com.facebook.react"
implementationClass = "com.facebook.react.ReactPlugin"
}
create("reactrootproject") {
id = "com.facebook.react.rootproject"
implementationClass = "com.facebook.react.ReactRootProjectPlugin"
}
}
}
group = "com.facebook.react"
dependencies {
implementation(project(":shared"))
implementation(gradleApi())
// The KGP/AGP version is defined by React Native Gradle plugin.
// Therefore we specify an implementation dep rather than a compileOnly.
implementation(libs.kotlin.gradle.plugin)
implementation(libs.android.gradle.plugin)
implementation(libs.gson)
implementation(libs.guava)
implementation(libs.javapoet)
testImplementation(libs.junit)
testImplementation(libs.assertj)
testImplementation(project(":shared-testutil"))
}
// We intentionally don't build for Java 17 as users will see a cryptic bytecode version
// error first. Instead we produce a Java 11-compatible Gradle Plugin, so that AGP can print their
// nice message showing that JDK 11 (or 17) is required first
java { targetCompatibility = JavaVersion.VERSION_11 }
kotlin { jvmToolchain(17) }
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
apiVersion.set(KotlinVersion.KOTLIN_1_8)
// See comment above on JDK 11 support
jvmTarget.set(JvmTarget.JVM_11)
allWarningsAsErrors.set(
project.properties["enableWarningsAsErrors"]?.toString()?.toBoolean() ?: false
)
}
}
tasks.withType<Test>().configureEach {
testLogging {
exceptionFormat = TestExceptionFormat.FULL
showExceptions = true
showCauses = true
showStackTraces = true
}
}

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

@@ -1,6 +1,7 @@
const {
withAppBuildGradle,
withProjectBuildGradle,
withSettingsGradle,
withDangerousMod,
withAndroidManifest,
} = require('@expo/config-plugins');
@@ -8,7 +9,71 @@ 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;
// Add Huawei Maven repo to pluginManagement.repositories for buildscript resolution.
// NOTE: We intentionally do NOT add `plugins { id 'com.huawei.agconnect' ... }` here.
// The Huawei agcp plugin transitively depends on agconnect-apms-plugin:1.6.2.300,
// which depends on AGP 4.0.1. That old AGP references BuildCompletionListener (removed
// from Gradle 8.x+), causing NoClassDefFoundError at build time. Instead, the plugin
// is applied via buildscript classpath in build.gradle with the apms-plugin excluded.
if (!contents.includes('developer.huawei.com/repo')) {
const repoLine = ` maven { url 'https://developer.huawei.com/repo/' }`;
const hasPluginManagement = contents.includes('pluginManagement');
const hasRepositoriesInPM = /pluginManagement\s*\{[\s\S]*?repositories\s*\{/.test(contents);
if (hasRepositoriesInPM) {
config.modResults.contents = contents.replace(
/(pluginManagement\s*\{[\s\S]*?repositories\s*\{)/,
`$1\n ${repoLine}`
);
} else if (hasPluginManagement) {
config.modResults.contents = contents.replace(
/(pluginManagement\s*\{)/,
`$1\n repositories {\n gradlePluginPortal()\n google()\n mavenCentral()\n ${repoLine}\n }`
);
} else {
config.modResults.contents =
`pluginManagement {\n repositories {\n ${repoLine}\n }\n}\n\n` +
config.modResults.contents;
}
}
// 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;
}
}
}
return config;
});
config = withProjectBuildGradle(config, (config) => {
const contents = config.modResults.contents;
@@ -28,24 +93,72 @@ const withHuaweiPush = (config, options = {}) => {
config = withProjectBuildGradle(config, (config) => {
const contents = config.modResults.contents;
// Add Huawei AGConnect classpath with apms-plugin excluded.
// The apms-plugin transitively depends on AGP 4.0.1 which is incompatible
// with Gradle 8.14+ (references removed BuildCompletionListener class).
if (!contents.includes('com.huawei.agconnect:agcp')) {
if (contents.includes('buildscript')) {
const depsPattern = /(buildscript\s*\{[\s\S]*?dependencies\s*\{)/;
if (depsPattern.test(contents)) {
config.modResults.contents = contents.replace(
depsPattern,
`$1\n classpath 'com.huawei.agconnect:agcp:1.9.1.301'`
`$1\n classpath('com.huawei.agconnect:agcp:1.9.1.301') {\n exclude group: 'com.huawei.agconnect', module: 'agconnect-apms-plugin'\n }`
);
}
}
}
// Set explicit AGP version (needed by Huawei plugin validation)
if (!contents.includes('com.android.tools.build:gradle:8.12.0')) {
config.modResults.contents = config.modResults.contents.replace(
/classpath\(['"]com\.android\.tools\.build:gradle['"]\)/,
"classpath('com.android.tools.build:gradle:8.12.0')"
);
}
// Google Services plugin classpath — required for Firebase init via google-services.json.
if (!contents.includes('com.google.gms:google-services')) {
const depsPattern = /(buildscript\s*\{[\s\S]*?dependencies\s*\{[\s\S]*?classpath\(['"]org\.jetbrains\.kotlin:kotlin-gradle-plugin['"]\))/;
if (depsPattern.test(config.modResults.contents)) {
config.modResults.contents = config.modResults.contents.replace(
depsPattern,
`$1\n classpath('com.google.gms:google-services:4.4.4')`
);
}
}
if (!config.modResults.contents.includes('developer.huawei.com/repo')) {
const bsRepoPattern = /(buildscript\s*\{[\s\S]*?repositories\s*\{)/;
if (bsRepoPattern.test(config.modResults.contents)) {
config.modResults.contents = config.modResults.contents.replace(
bsRepoPattern,
`$1\n maven { url 'https://developer.huawei.com/repo/' }`
);
}
}
return config;
});
config = withAppBuildGradle(config, (config) => {
const contents = config.modResults.contents;
// Apply Google Services plugin (Firebase) before the Huawei plugin.
// The googleServicesFile in app.json only sets up the file copy + plugin
// when run through `expo prebuild`, but it does not coexist with this
// Huawei plugin reliably, so we apply it explicitly here.
if (!contents.includes('com.google.gms.google-services')) {
const reactApply = /apply plugin:\s*['"]com\.facebook\.react['"]\s*\n/;
if (reactApply.test(contents)) {
config.modResults.contents = contents.replace(
reactApply,
`apply plugin: "com.facebook.react"\napply plugin: 'com.google.gms.google-services'\n`
);
} else {
config.modResults.contents = `apply plugin: 'com.google.gms.google-services'\n` + contents;
}
}
if (!contents.includes('com.huawei.agconnect')) {
const applyPluginPattern = /apply plugin:\s*['"]com\.google\.gms\.google-services['"]/;
if (applyPluginPattern.test(contents)) {
@@ -85,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');
@@ -96,6 +246,41 @@ const withHuaweiPush = (config, options = {}) => {
console.warn('[withHuaweiPush] agconnect-services.json not found at project root');
}
// Copy google-services.json to android/app/ so the Google Services
// Gradle plugin can find it. Without this, FCM / Firebase init fails
// at runtime with "default FirebaseApp is not initialized".
const gmsSrc = path.join(config.modRequest.projectRoot, 'google-services.json');
const gmsDest = path.join(appDir, 'google-services.json');
if (fs.existsSync(gmsSrc)) {
fs.copyFileSync(gmsSrc, gmsDest);
console.log('[withHuaweiPush] copied google-services.json to android/app/');
} else {
console.warn('[withHuaweiPush] google-services.json not found at project root');
}
// RN 0.85.3 ships Gradle 9.3.1 in its template, but Kotlin 2.1.20 is incompatible
// with Gradle 9.x (CompilerEnvironment ClassNotFoundException). Downgrade to 8.14.5.
const wrapperFile = path.join(config.modRequest.platformProjectRoot, 'gradle', 'wrapper', 'gradle-wrapper.properties');
if (fs.existsSync(wrapperFile)) {
let wrapper = fs.readFileSync(wrapperFile, 'utf8');
if (wrapper.includes('gradle-9.')) {
wrapper = wrapper.replace(/gradle-[\d.]+-bin\.zip/, 'gradle-8.14.5-bin.zip');
fs.writeFileSync(wrapperFile, wrapper);
console.log('[withHuaweiPush] downgraded Gradle to 8.14.5 for Kotlin 2.1.20 compatibility');
}
}
// Ensure local.properties with SDK path exists (gets cleared on prebuild --clean)
const localPropsFile = path.join(config.modRequest.platformProjectRoot, 'local.properties');
if (!fs.existsSync(localPropsFile)) {
const homeDir = process.env.USERPROFILE || process.env.HOME || '';
const sdkPath = path.join(homeDir, 'AppData', 'Local', 'Android', 'Sdk');
if (fs.existsSync(sdkPath)) {
fs.writeFileSync(localPropsFile, `sdk.dir=${sdkPath.replace(/\\/g, '\\\\')}\n`);
console.log('[withHuaweiPush] created local.properties with SDK path');
}
}
return config;
},
]);

View File

@@ -95,7 +95,7 @@ const withJPush = (config, options = {}) => {
config = withAppDelegate(config, (config) => {
const { contents } = config.modResults;
if (contents.includes('JPush') || contents.includes('JCoreModule')) {
if (contents.includes('JPush initialization') && contents.includes('JPUSHService.registerDeviceToken')) {
return config;
}
@@ -104,7 +104,8 @@ const withJPush = (config, options = {}) => {
if (isSwift) {
let newContents = contents;
if (!newContents.includes('import JPushRN')) {
// Add UNUserNotifications import if needed
if (!newContents.includes('import UserNotifications')) {
if (newContents.includes('import UIKit')) {
newContents = newContents.replace(
/(import UIKit\n)/,
@@ -118,56 +119,62 @@ const withJPush = (config, options = {}) => {
}
}
// Swift: Add JPush init in application(_:didFinishLaunchingWithOptions:)
// Match various Swift signatures
const swiftDidFinishPatterns = [
/public override func application\(\s*_ application: UIApplication,\s*didFinishLaunchingWithOptions launchOptions: \[UIApplication\.LaunchOptionsKey: Any\]\? = nil\s*\) -> Bool \{/,
/func application\(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: \[UIApplication\.LaunchOptionsKey: Any\]\?\?\) -> Bool \{/,
];
let didInsertInit = false;
for (const pattern of swiftDidFinishPatterns) {
if (pattern.test(newContents)) {
newContents = newContents.replace(
pattern,
`$&\n // JPush initialization\n #if DEBUG\n JCoreModule.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)\n #else\n JCoreModule.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)\n #endif`
);
didInsertInit = true;
break;
}
// Add UNUserNotificationCenterDelegate conformance if needed
if (!newContents.includes('UNUserNotificationCenterDelegate')) {
newContents = newContents.replace(
/class AppDelegate: ExpoAppDelegate \{/,
'class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate {'
);
}
// Fallback: find the didFinishLaunchingWithOptions method start and insert after the opening brace
if (!didInsertInit && newContents.includes('didFinishLaunchingWithOptions')) {
const lines = newContents.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('didFinishLaunchingWithOptions') && lines[i].includes('{')) {
lines.splice(i + 1, 0,
' // JPush initialization',
' #if DEBUG',
` JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)`,
' #else',
` JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)`,
' #endif',
' UNUserNotificationCenter.current().delegate = self'
);
newContents = lines.join('\n');
didInsertInit = true;
break;
// Swift: Add JPush init in application(_:didFinishLaunchingWithOptions:)
// Expo SDK 56+ uses JPUSHService.setup (not JCoreModule.setup)
if (!newContents.includes('JPUSHService.setup') && !newContents.includes('JCoreModule.setup')) {
const jpushInitBlock = `
// JPush initialization
#if DEBUG
JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)
#else
JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)
#endif
UNUserNotificationCenter.current().delegate = self`;
// Insert after the opening brace of didFinishLaunchingWithOptions
// The { may be on the same line as didFinishLaunchingWithOptions or on a subsequent line
if (newContents.includes('didFinishLaunchingWithOptions')) {
const lines = newContents.split('\n');
let foundDidFinish = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('didFinishLaunchingWithOptions')) {
foundDidFinish = true;
}
// Insert after the line that contains the opening brace of this method
if (foundDidFinish && lines[i].includes('{')) {
const indent = lines[i].match(/^(\s*)/)[1];
const initLines = jpushInitBlock.split('\n').map(l => l ? indent + l.trimStart() : l);
lines.splice(i + 1, 0, ...initLines);
newContents = lines.join('\n');
break;
}
}
}
} else if (newContents.includes('JCoreModule.setup') && !newContents.includes('JPUSHService.setup')) {
// Migrate from JCoreModule.setup to JPUSHService.setup
newContents = newContents.replace(/JCoreModule\.setup/g, 'JPUSHService.setup');
}
// Also add delegate = self after regex-based insertion if not already present
if (didInsertInit && !newContents.includes('UNUserNotificationCenter.current().delegate')) {
// Add delegate = self after init insertion if not present
if (!newContents.includes('UNUserNotificationCenter.current().delegate')) {
newContents = newContents.replace(
/(#endif\n)(\s+let delegate)/,
`$1 UNUserNotificationCenter.current().delegate = self\n$2`
);
}
// Swift: Add delegate methods before the closing brace of the class
// Swift: Add JPush notification delegate methods to AppDelegate class (before closing brace)
// These go in AppDelegate, NOT ReactNativeDelegate
const jpushSwiftMethods = `
// JPush: Register for remote notifications
override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
JPUSHService.registerDeviceToken(deviceToken)
@@ -195,10 +202,20 @@ const withJPush = (config, options = {}) => {
`;
if (!newContents.includes('JPUSHService.registerDeviceToken')) {
// Find the closing brace of the AppDelegate class
const lastBraceIndex = newContents.lastIndexOf('}');
if (lastBraceIndex !== -1) {
newContents = newContents.slice(0, lastBraceIndex) + jpushSwiftMethods + '\n' + newContents.slice(lastBraceIndex);
// Insert before the closing brace of the AppDelegate class
// Find the last '}' that closes the AppDelegate class (before ReactNativeDelegate)
const classEndPattern = /\n\}\n\nclass ReactNativeDelegate/;
if (classEndPattern.test(newContents)) {
newContents = newContents.replace(
classEndPattern,
`${jpushSwiftMethods}}\n\nclass ReactNativeDelegate`
);
} else {
// Fallback: insert before the last closing brace
const lastBraceIndex = newContents.lastIndexOf('}');
if (lastBraceIndex !== -1) {
newContents = newContents.slice(0, lastBraceIndex) + jpushSwiftMethods + '\n' + newContents.slice(lastBraceIndex);
}
}
}

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,7 +35,95 @@ 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;
};
module.exports = withSigning;
module.exports = withSigning;

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

@@ -172,7 +172,7 @@ export function AppDesktopShell() {
const handleTabChange = useCallback(
(href: string) => {
router.replace(href);
router.push(href);
},
[router]
);

View File

@@ -14,7 +14,8 @@ export interface BlockEditorHandle {
insertTextAtCursor: (text: string) => void;
getSegments: () => MessageSegment[];
getContent: () => string;
hasUploadingImages: () => boolean;
getBlocks: () => EditorBlock[];
uploadPendingImages: () => Promise<boolean>;
focus: () => void;
}
@@ -38,7 +39,8 @@ const BlockEditor = React.forwardRef<BlockEditorHandle, BlockEditorProps>(
insertTextAtCursor: editor.insertTextAtCursor,
getSegments: editor.getSegments,
getContent: editor.getContent,
hasUploadingImages: editor.hasUploadingImages,
getBlocks: editor.getBlocks,
uploadPendingImages: editor.uploadPendingImages,
focus: () => {
const firstTextBlock = editor.blocks.find(b => b.type === 'text');
if (firstTextBlock) {

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { View, TouchableOpacity, StyleSheet, Dimensions, ActivityIndicator } from 'react-native';
import { View, TouchableOpacity, StyleSheet, Dimensions } from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { MaterialCommunityIcons } from '@expo/vector-icons';
@@ -41,11 +41,6 @@ const ImageBlockView: React.FC<ImageBlockViewProps> = ({ block, onRemove, style
cachePolicy="memory-disk"
transition={200}
/>
{block.uploading && (
<View style={styles.uploadingOverlay}>
<ActivityIndicator size="small" color={colors.text.inverse} />
</View>
)}
<TouchableOpacity
style={styles.removeButton}
onPress={() => onRemove(block.id)}
@@ -66,13 +61,6 @@ const styles = StyleSheet.create({
position: 'relative',
alignItems: 'center',
},
uploadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
justifyContent: 'center',
alignItems: 'center',
borderRadius: borderRadius.md,
},
removeButton: {
position: 'absolute',
top: 8,

View File

@@ -12,7 +12,7 @@ export interface ImageBlock {
type: 'image';
localUri: string;
remoteUrl?: string;
uploading: boolean;
mimeType?: string;
width?: number;
height?: number;
}

View File

@@ -13,7 +13,6 @@ export function segmentsToBlocks(segments: MessageSegment[]): EditorBlock[] {
type: 'image',
localUri: seg.data.url,
remoteUrl: seg.data.url,
uploading: false,
width: seg.data.width,
height: seg.data.height,
};

View File

@@ -24,6 +24,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
const [activeBlockId, setActiveBlockId] = useState<string | null>(null);
const inputRefs = useRef<Map<string, TextInput>>(new Map());
const blockCursors = useRef<Map<string, { start: number; end: number }>>(new Map());
const blocksRef = useRef<EditorBlock[]>(blocks);
blocksRef.current = blocks;
const updateTextBlock = useCallback(
(blockId: string, text: string, mentions: Map<number, MentionData>) => {
@@ -87,51 +89,47 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
});
}, [focusBlock]);
const uploadImageByLocalUri = useCallback(
async (asset: ImagePicker.ImagePickerAsset) => {
try {
const uploadResult = await uploadService.uploadImage({
uri: asset.uri,
type: asset.mimeType || 'image/jpeg',
});
const uploadPendingImages = useCallback(async (): Promise<boolean> => {
let allSuccess = true;
if (uploadResult) {
setBlocks(prev =>
prev.map(b =>
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
? {
...b,
remoteUrl: uploadResult.url,
uploading: false,
width: uploadResult.width ?? b.width,
height: uploadResult.height ?? b.height,
}
: b
)
);
} else {
// Upload failed
setBlocks(prev =>
prev.map(b =>
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
? { ...b, uploading: false }
: b
)
);
Alert.alert('上传失败', '图片上传失败,请重试');
const uploadBlock = async () => {
const currentBlocks = blocksRef.current;
const pendingBlocks = currentBlocks.filter(
(b): b is ImageBlock => b.type === 'image' && !b.remoteUrl,
);
for (const block of pendingBlocks) {
try {
const uploadResult = await uploadService.uploadImage({
uri: block.localUri,
type: block.mimeType || 'image/jpeg',
});
if (uploadResult) {
setBlocks(prev =>
prev.map(b =>
b.id === block.id && b.type === 'image'
? {
...b,
remoteUrl: uploadResult.url,
width: uploadResult.width ?? b.width,
height: uploadResult.height ?? b.height,
}
: b,
),
);
} else {
allSuccess = false;
}
} catch {
allSuccess = false;
}
} catch {
setBlocks(prev =>
prev.map(b =>
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
? { ...b, uploading: false }
: b
)
);
}
},
[],
);
};
await uploadBlock();
return allSuccess;
}, []);
const insertImagesFromAssets = useCallback(
(assets: ImagePicker.ImagePickerAsset[]) => {
@@ -152,13 +150,12 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
? (blockCursors.current.get(activeBlock.id)?.start ?? activeBlock.text.length)
: 0;
// Create image block
const imageBlockId = generateBlockId();
const imageBlock: ImageBlock = {
id: imageBlockId,
type: 'image',
localUri: asset.uri,
uploading: true,
mimeType: asset.mimeType,
width: asset.width,
height: asset.height,
};
@@ -169,7 +166,6 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
const mentionsBefore = remapMentionsBefore(activeBlock.activeMentions, cursorPos);
const mentionsAfter = remapMentionsAfter(activeBlock.activeMentions, cursorPos, activeBlock.text.length);
// Create new text block for text after cursor
const newTextBlockId = generateBlockId();
const newTextBlock: TextBlock = {
id: newTextBlockId,
@@ -178,24 +174,19 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
activeMentions: mentionsAfter,
};
// Update current block to text before cursor
updated[insertAfterIndex] = {
...activeBlock,
text: textBefore,
activeMentions: mentionsBefore,
};
// Insert image block and new text block
updated.splice(insertAfterIndex + 1, 0, imageBlock, newTextBlock);
// Update index for next image insertion
insertAfterIndex = insertAfterIndex + 2;
// Focus the new text block after the image
setActiveBlockId(newTextBlockId);
requestAnimationFrame(() => focusBlock(newTextBlockId));
} else {
// Insert after the current block (which is an image)
updated.splice(insertAfterIndex + 1, 0, imageBlock);
insertAfterIndex = insertAfterIndex + 1;
}
@@ -203,13 +194,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
return updated;
});
// Start async uploads
for (const asset of assets) {
uploadImageByLocalUri(asset);
}
},
[activeBlockId, focusBlock, uploadImageByLocalUri],
[activeBlockId, focusBlock],
);
const insertImage = useCallback(async () => {
@@ -282,7 +268,7 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
const getSegments = useCallback(() => blocksToSegments(blocks), [blocks]);
const getContent = useCallback(() => blocksToContent(blocks), [blocks]);
const hasUploadingImages = useCallback(() => blocks.some(b => b.type === 'image' && b.uploading), [blocks]);
const getBlocks = useCallback(() => blocks, [blocks]);
const registerInputRef = useCallback((blockId: string, ref: TextInput | null) => {
if (ref) {
@@ -308,7 +294,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
focusBlock,
getSegments,
getContent,
hasUploadingImages,
getBlocks,
uploadPendingImages,
registerInputRef,
updateCursor,
};

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

@@ -255,6 +255,7 @@ function createPostCardStyles(colors: AppColors) {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: spacing.sm,
},
actionsLeading: {
flexDirection: 'row',
@@ -661,7 +662,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
) : (
<View style={styles.gridNoImagePreview}>
<Text style={styles.gridNoImageText} numberOfLines={6}>
{highlightKeyword ? <HighlightText text={contentPreview} keyword={highlightKeyword} style={styles.highlight} /> : contentPreview}
{highlightKeyword ? <HighlightText text={contentPreview} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : contentPreview}
</Text>
</View>
)}
@@ -675,7 +676,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
{!!post.title && (
<Text style={styles.gridTitleMain} numberOfLines={hasImage ? 2 : 3}>
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} style={styles.highlight} /> : post.title}
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : post.title}
</Text>
)}
@@ -747,7 +748,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
{!!post.title && (
<Text style={styles.title} numberOfLines={isCompact ? 2 : undefined}>
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} style={styles.highlight} /> : post.title}
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : post.title}
</Text>
)}
@@ -757,7 +758,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
style={styles.content}
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
>
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} style={styles.highlight} /> : content}
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : content}
</Text>
{shouldTruncate && (
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>

View File

@@ -61,7 +61,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
return (
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
{highlightKeyword && content ? (
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
<HighlightText text={content} keyword={highlightKeyword} highlightStyle={highlightStyle} />
) : (
content || ''
)}
@@ -85,7 +85,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
elements.push(
<Text key={key} selectable style={textStyle}>
{highlightKeyword ? (
<HighlightText text={text} keyword={highlightKeyword} style={highlightStyle} />
<HighlightText text={text} keyword={highlightKeyword} highlightStyle={highlightStyle} />
) : (
text
)}
@@ -125,7 +125,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
return (
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
{highlightKeyword && content ? (
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
<HighlightText text={content} keyword={highlightKeyword} highlightStyle={highlightStyle} />
) : (
content || ''
)}

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

@@ -6,6 +6,7 @@ import { useRouter } from 'expo-router';
import Text from '../common/Text';
import { useAppColors, spacing, borderRadius } from '../../theme';
import type { PostRefSegmentData } from '../../types';
import * as hrefs from '../../navigation/hrefs';
interface PostRefCardProps {
data: PostRefSegmentData;
@@ -21,7 +22,7 @@ const PostRefCard: React.FC<PostRefCardProps> = ({ data, compact = false, onPres
if (onPress) {
onPress(data.post_id);
} else {
router.push(`/post/${data.post_id}` as any);
router.push(hrefs.hrefPostDetail(data.post_id));
}
}, [data.post_id, onPress, router]);

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;
}
@@ -330,4 +385,4 @@ const TabBar: React.FC<TabBarProps> = ({
);
};
export default TabBar;
export default React.memo(TabBar);

View File

@@ -551,7 +551,6 @@ function createUserProfileHeaderStyles(colors: AppColors) {
minWidth: 160,
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
...shadows.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.divider,
overflow: 'hidden',

View File

@@ -159,7 +159,7 @@ function createVoteCardStyles(colors: AppColors) {
fontSize: fontSizes.sm,
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
backgroundColor: colors.background.paper + 'CC',
justifyContent: 'center',
alignItems: 'center',

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

@@ -1,15 +1,25 @@
import React, { useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
StatusBar,
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
StatusBar,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Track, LocalVideoTrack, RemoteVideoTrack } from 'livekit-client';
let VideoView: React.ComponentType<any> | null = null;
try {
VideoView = require('@livekit/react-native').VideoView;
} catch {
// WebRTC native module not available (e.g. Expo Go)
}
type VideoTrack = LocalVideoTrack | RemoteVideoTrack;
import { callStore } from '../../stores/call';
import { RTCView } from 'react-native-webrtc';
import { liveKitService } from '@/services/livekit';
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
@@ -17,12 +27,9 @@ const formatDuration = (seconds: number): string => {
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const CallScreen: React.FC = () => {
const currentCall = callStore((s) => s.currentCall);
const callDuration = callStore((s) => s.callDuration);
const peerStream = callStore((s) => s.peerStream);
const localStream = callStore((s) => s.localStream);
const endCall = callStore((s) => s.endCall);
const toggleMute = callStore((s) => s.toggleMute);
const toggleSpeaker = callStore((s) => s.toggleSpeaker);
@@ -30,33 +37,87 @@ const CallScreen: React.FC = () => {
const isMinimized = callStore((s) => s.isMinimized);
const toggleMinimize = callStore((s) => s.toggleMinimize);
// Track whether peer has video
const [hasPeerVideo, setHasPeerVideo] = useState(false);
// Track whether local has video
const [hasLocalVideo, setHasLocalVideo] = useState(false);
const [remoteVideoTrack, setRemoteVideoTrack] = useState<VideoTrack | null>(null);
const [localVideoTrack, setLocalVideoTrack] = useState<VideoTrack | null>(null);
// Check peer stream for video tracks
// Event-driven video track subscription (no polling)
useEffect(() => {
if (peerStream) {
const videoTracks = peerStream.getVideoTracks();
const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
setHasPeerVideo(hasVideo);
} else {
setHasPeerVideo(false);
}
}, [peerStream]);
// Check local stream for video tracks
useEffect(() => {
if (localStream) {
const videoTracks = localStream.getVideoTracks();
const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
setHasLocalVideo(hasVideo);
} else {
setHasLocalVideo(false);
}
}, [localStream]);
const unsubs: (() => void)[] = [];
// Sync current tracks immediately
const syncTracks = () => {
const lp = liveKitService.localParticipant;
if (lp) {
const videoPub = lp.getTrackPublication(Track.Source.Camera);
setLocalVideoTrack((videoPub?.track as VideoTrack | undefined) ?? null);
}
liveKitService.remoteParticipants.forEach((participant) => {
const videoPub = participant.getTrackPublication(Track.Source.Camera);
if (videoPub?.track) {
setRemoteVideoTrack(videoPub.track as VideoTrack);
}
});
};
syncTracks();
// Remote video track events
unsubs.push(
liveKitService.on('trackSubscribed', ({ track }) => {
if (track.kind === Track.Kind.Video) {
setRemoteVideoTrack(track as VideoTrack);
}
})
);
unsubs.push(
liveKitService.on('trackUnsubscribed', ({ track }) => {
if (track.kind === Track.Kind.Video) {
setRemoteVideoTrack(null);
}
})
);
unsubs.push(
liveKitService.on('trackMuted', ({ publication }) => {
if (publication.source === Track.Source.Camera) {
setRemoteVideoTrack(null);
}
})
);
unsubs.push(
liveKitService.on('trackUnmuted', ({ publication }) => {
if (publication.source === Track.Source.Camera && publication.track) {
setRemoteVideoTrack(publication.track as VideoTrack);
}
})
);
// Local video track events (replace 500ms polling)
unsubs.push(
liveKitService.on('localTrackPublished', ({ publication }) => {
if (publication.source === Track.Source.Camera && publication.track) {
setLocalVideoTrack(publication.track as VideoTrack);
}
})
);
unsubs.push(
liveKitService.on('localTrackUnpublished', ({ publication }) => {
if (publication.source === Track.Source.Camera) {
setLocalVideoTrack(null);
}
})
);
return () => {
unsubs.forEach((unsub) => unsub());
};
}, []);
// Don't render full screen when minimized or no call
if (!currentCall || isMinimized) return null;
const getStatusText = (): string => {
switch (currentCall.status) {
case 'calling':
@@ -77,63 +138,52 @@ const CallScreen: React.FC = () => {
return '';
}
};
const handleEndCall = () => {
endCall('hangup');
};
const handleToggleMute = () => {
toggleMute();
};
const handleToggleSpeaker = () => {
toggleSpeaker();
};
const handleToggleVideo = () => {
toggleVideo();
};
const handleMinimize = () => {
toggleMinimize();
};
// Determine if we should show video UI
const showRemoteVideo = hasPeerVideo;
// Use currentCall.isVideoEnabled directly for local video
// This is more reliable than checking localStream.getVideoTracks()
// because the stream object reference may not trigger useEffect properly
const showLocalVideo = currentCall?.isVideoEnabled && localStream;
const showRemoteVideo = !!remoteVideoTrack;
const showLocalVideo = currentCall?.isVideoEnabled && !!localVideoTrack;
const isVideoCallActive = showRemoteVideo || showLocalVideo;
return (
<View style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Background */}
<View style={styles.background} />
{/* Remote video - full screen when peer has video */}
{showRemoteVideo && peerStream && (
<RTCView
streamURL={peerStream.toURL()}
{/* Remote video - full screen */}
{showRemoteVideo && remoteVideoTrack && VideoView && (
<VideoView
videoTrack={remoteVideoTrack}
style={styles.fullScreenVideo}
objectFit="cover"
mirror={false}
/>
)}
{/* Local video - picture in picture */}
{showLocalVideo && localStream && (
{showLocalVideo && localVideoTrack && VideoView && (
<View style={styles.localVideoContainer}>
<RTCView
streamURL={localStream.toURL()}
<VideoView
videoTrack={localVideoTrack}
style={styles.localVideo}
objectFit="cover"
mirror={true}
/>
</View>
)}
{/* Top area: minimize button */}
<View style={styles.topBar}>
<TouchableOpacity
style={styles.topButton}
onPress={handleMinimize}
onPress={toggleMinimize}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
</TouchableOpacity>
</View>
{/* Center: Peer info - only show when no video */}
{!isVideoCallActive && (
<View style={styles.centerArea}>
@@ -154,6 +204,7 @@ const CallScreen: React.FC = () => {
<Text style={styles.status}>{getStatusText()}</Text>
</View>
)}
{/* Peer name overlay when video is active */}
{isVideoCallActive && (
<View style={styles.videoOverlayInfo}>
@@ -163,13 +214,14 @@ const CallScreen: React.FC = () => {
<Text style={styles.videoStatus}>{getStatusText()}</Text>
</View>
)}
{/* Bottom controls */}
<View style={styles.controls}>
<View style={styles.controlRow}>
{/* Mute */}
<TouchableOpacity
style={[styles.controlButton, currentCall.isMuted && styles.controlButtonActive]}
onPress={handleToggleMute}
onPress={toggleMute}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, currentCall.isMuted && styles.controlCircleActive]}>
@@ -185,25 +237,25 @@ const CallScreen: React.FC = () => {
</TouchableOpacity>
{/* Video toggle */}
<TouchableOpacity
style={[styles.controlButton, hasLocalVideo && styles.controlButtonActive]}
onPress={handleToggleVideo}
style={[styles.controlButton, currentCall.isVideoEnabled && styles.controlButtonActive]}
onPress={toggleVideo}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, hasLocalVideo && styles.controlCircleActive]}>
<View style={[styles.controlCircle, currentCall.isVideoEnabled && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={hasLocalVideo ? 'video' : 'video-off'}
name={currentCall.isVideoEnabled ? 'video' : 'video-off'}
size={26}
color={hasLocalVideo ? '#333' : '#fff'}
color={currentCall.isVideoEnabled ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, hasLocalVideo && styles.controlLabelActive]}>
{hasLocalVideo ? '关闭摄像头' : '打开摄像头'}
<Text style={[styles.controlLabel, currentCall.isVideoEnabled && styles.controlLabelActive]}>
{currentCall.isVideoEnabled ? '关闭摄像头' : '打开摄像头'}
</Text>
</TouchableOpacity>
{/* Speaker */}
<TouchableOpacity
style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]}
onPress={handleToggleSpeaker}
onPress={toggleSpeaker}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, currentCall.isSpeakerOn && styles.controlCircleActive]}>
@@ -218,7 +270,7 @@ const CallScreen: React.FC = () => {
</Text>
</TouchableOpacity>
</View>
{/* End call - prominent red button */}
{/* End call */}
<TouchableOpacity
style={styles.endCallButton}
onPress={handleEndCall}
@@ -234,17 +286,14 @@ const CallScreen: React.FC = () => {
);
};
const CONTROL_CIRCLE_SIZE = 56;
const END_CALL_SIZE = 64;
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
backgroundColor: '#1A1A2E',
zIndex: 9999,
},
background: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
backgroundColor: '#1A1A2E',
},
topBar: {
@@ -306,7 +355,7 @@ const styles = StyleSheet.create({
letterSpacing: 0.3,
},
fullScreenVideo: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
backgroundColor: '#1A1A2E',
},
localVideoContainer: {
@@ -408,5 +457,4 @@ const styles = StyleSheet.create({
},
});
export default CallScreen;

View File

@@ -1,13 +1,26 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
StatusBar,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Track, LocalVideoTrack, RemoteVideoTrack } from 'livekit-client';
let VideoView: React.ComponentType<any> | null = null;
try {
VideoView = require('@livekit/react-native').VideoView;
} catch {
// @livekit/react-native not available on web
}
import { callStore } from '../../stores/call';
import { liveKitService } from '@/services/livekit';
type VideoTrack = LocalVideoTrack | RemoteVideoTrack;
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
@@ -19,80 +32,253 @@ const CallScreen: React.FC = () => {
const currentCall = callStore((s) => s.currentCall);
const callDuration = callStore((s) => s.callDuration);
const endCall = callStore((s) => s.endCall);
const toggleMute = callStore((s) => s.toggleMute);
const toggleSpeaker = callStore((s) => s.toggleSpeaker);
const toggleVideo = callStore((s) => s.toggleVideo);
const isMinimized = callStore((s) => s.isMinimized);
const toggleMinimize = callStore((s) => s.toggleMinimize);
const [remoteVideoTrack, setRemoteVideoTrack] = useState<VideoTrack | null>(null);
const [localVideoTrack, setLocalVideoTrack] = useState<VideoTrack | null>(null);
// Subscribe to LiveKit track events
useEffect(() => {
const unsubs: (() => void)[] = [];
unsubs.push(
liveKitService.on('trackSubscribed', ({ track }) => {
if (track.kind === Track.Kind.Video) {
setRemoteVideoTrack(track as VideoTrack);
}
})
);
unsubs.push(
liveKitService.on('trackUnsubscribed', ({ track }) => {
if (track.kind === Track.Kind.Video) {
setRemoteVideoTrack(null);
}
})
);
unsubs.push(
liveKitService.on('trackMuted', ({ publication }) => {
if (publication.source === Track.Source.Camera) {
setRemoteVideoTrack(null);
}
})
);
unsubs.push(
liveKitService.on('trackUnmuted', ({ publication }) => {
if (publication.source === Track.Source.Camera && publication.track) {
setRemoteVideoTrack(publication.track as VideoTrack);
}
})
);
const localPollInterval = setInterval(() => {
const lp = liveKitService.localParticipant;
if (lp) {
const videoPub = lp.getTrackPublication(Track.Source.Camera);
const track = (videoPub?.track ?? null) as VideoTrack | null;
setLocalVideoTrack((prev) => (prev === track ? prev : track));
} else {
setLocalVideoTrack((prev) => (prev === null ? prev : null));
}
}, 500);
return () => {
unsubs.forEach((unsub) => unsub());
clearInterval(localPollInterval);
};
}, []);
// Don't render full screen when minimized or no call
if (!currentCall || isMinimized) return null;
const getStatusText = (): string => {
switch (currentCall.status) {
case 'calling':
return '正在等待对方接听...';
case 'ringing':
return '通话功能暂不支持网页端';
return '来电响铃中...';
case 'connecting':
return '通话功能暂不支持网页端';
return '连接中...';
case 'connected':
return '通话功能暂不支持网页端';
return formatDuration(callDuration);
case 'reconnecting':
return '网络重连中...';
case 'ended':
return '通话已结束';
case 'failed':
return '连接失败';
default:
return '';
}
};
const handleEndCall = () => {
endCall('hangup');
};
const showRemoteVideo = !!remoteVideoTrack;
const showLocalVideo = currentCall?.isVideoEnabled && !!localVideoTrack;
const isVideoCallActive = showRemoteVideo || showLocalVideo;
return (
<View style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Background - single consistent color */}
<View style={styles.background} />
{/* Center: Peer info */}
<View style={styles.centerArea}>
<View style={styles.avatarOuter}>
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<MaterialCommunityIcons name="web-off" size={50} color="#fff" />
</View>
{showRemoteVideo && remoteVideoTrack && VideoView && (
<VideoView
videoTrack={remoteVideoTrack}
style={styles.fullScreenVideo}
objectFit="cover"
/>
)}
{showLocalVideo && localVideoTrack && VideoView && (
<View style={styles.localVideoContainer}>
<VideoView
videoTrack={localVideoTrack}
style={styles.localVideo}
objectFit="cover"
mirror={true}
/>
</View>
<Text style={styles.peerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.status}>{getStatusText()}</Text>
)}
<View style={styles.topBar}>
<TouchableOpacity
style={styles.topButton}
onPress={toggleMinimize}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
</TouchableOpacity>
</View>
{/* Bottom controls */}
{!isVideoCallActive && (
<View style={styles.centerArea}>
<View style={styles.avatarOuter}>
{currentCall.peerAvatar ? (
<Image source={{ uri: currentCall.peerAvatar }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
</View>
<Text style={styles.peerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.status}>{getStatusText()}</Text>
</View>
)}
{isVideoCallActive && (
<View style={styles.videoOverlayInfo}>
<Text style={styles.videoPeerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.videoStatus}>{getStatusText()}</Text>
</View>
)}
<View style={styles.controls}>
{/* End call - prominent red button */}
<View style={styles.controlRow}>
<TouchableOpacity
style={[styles.controlButton, currentCall.isMuted && styles.controlButtonActive]}
onPress={toggleMute}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, currentCall.isMuted && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={currentCall.isMuted ? 'microphone-off' : 'microphone'}
size={26}
color={currentCall.isMuted ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, currentCall.isMuted && styles.controlLabelActive]}>
{currentCall.isMuted ? '取消静音' : '静音'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.controlButton, currentCall.isVideoEnabled && styles.controlButtonActive]}
onPress={toggleVideo}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, currentCall.isVideoEnabled && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={currentCall.isVideoEnabled ? 'video' : 'video-off'}
size={26}
color={currentCall.isVideoEnabled ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, currentCall.isVideoEnabled && styles.controlLabelActive]}>
{currentCall.isVideoEnabled ? '关闭摄像头' : '打开摄像头'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]}
onPress={toggleSpeaker}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, currentCall.isSpeakerOn && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={currentCall.isSpeakerOn ? 'volume-high' : 'cellphone'}
size={26}
color={currentCall.isSpeakerOn ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, currentCall.isSpeakerOn && styles.controlLabelActive]}>
{currentCall.isSpeakerOn ? '免提' : '听筒'}
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
style={styles.endCallButton}
onPress={handleEndCall}
onPress={() => endCall('hangup')}
activeOpacity={0.8}
>
<View style={styles.endCallCircle}>
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
</View>
<Text style={styles.endCallLabel}></Text>
<Text style={styles.endCallLabel}></Text>
</TouchableOpacity>
</View>
</View>
);
};
const END_CALL_SIZE = 64;
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
backgroundColor: '#1A1A2E',
zIndex: 9999,
},
background: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
backgroundColor: '#1A1A2E',
},
topBar: {
position: 'absolute',
top: 50,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
height: 44,
zIndex: 100,
},
topButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
},
centerArea: {
position: 'absolute',
top: '28%',
@@ -132,20 +318,98 @@ const styles = StyleSheet.create({
color: 'rgba(255, 255, 255, 0.5)',
letterSpacing: 0.3,
},
fullScreenVideo: {
...StyleSheet.absoluteFill,
backgroundColor: '#1A1A2E',
},
localVideoContainer: {
position: 'absolute',
top: 100,
right: 20,
width: 120,
height: 160,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#2A2A4E',
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.2)',
zIndex: 50,
},
localVideo: {
width: '100%',
height: '100%',
},
videoOverlayInfo: {
position: 'absolute',
top: 100,
left: 0,
right: 0,
alignItems: 'center',
zIndex: 50,
},
videoPeerName: {
fontSize: 18,
fontWeight: '600',
color: '#FFFFFF',
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
videoStatus: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.8)',
marginTop: 4,
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
controls: {
position: 'absolute',
bottom: 60,
left: 0,
right: 0,
alignItems: 'center',
zIndex: 100,
},
controlRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: 24,
marginBottom: 24,
},
controlButton: {
alignItems: 'center',
width: 70,
},
controlCircle: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(255, 255, 255, 0.12)',
justifyContent: 'center',
alignItems: 'center',
},
controlCircleActive: {
backgroundColor: '#FFFFFF',
},
controlButtonActive: {},
controlLabel: {
fontSize: 11,
color: 'rgba(255, 255, 255, 0.55)',
marginTop: 8,
textAlign: 'center',
},
controlLabelActive: {
color: '#FFFFFF',
fontWeight: '500',
},
endCallButton: {
alignItems: 'center',
},
endCallCircle: {
width: END_CALL_SIZE,
height: END_CALL_SIZE,
borderRadius: END_CALL_SIZE / 2,
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',

View File

@@ -6,9 +6,12 @@ import {
TouchableOpacity,
Image,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { MaterialCommunityIcons, Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { callStore } from '../../stores/call';
const BUTTON_SIZE = 40;
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
@@ -22,69 +25,86 @@ const FloatingCallWindow: React.FC = () => {
const toggleMinimize = callStore((s) => s.toggleMinimize);
const endCall = callStore((s) => s.endCall);
// Only show when there's an active call and it's minimized
const insets = useSafeAreaInsets();
if (!currentCall || !isMinimized) return null;
const getStatusText = (): string => {
switch (currentCall.status) {
case 'calling':
return '等待接听...';
case 'ringing':
return '来电响铃...';
case 'connecting':
return '连接中...';
case 'connected':
return formatDuration(callDuration);
case 'connecting':
case 'calling':
case 'ringing':
return '连接中...';
case 'reconnecting':
return '重连中...';
case 'ended':
return '已结束';
case 'failed':
return '连接失败';
default:
return '';
}
};
const isConnected = currentCall.status === 'connected';
const participantCount = currentCall.participants.size;
const isVideoCall = currentCall.mediaType === 'video';
const isGroupCall = participantCount > 2;
const displayName = isGroupCall
? `群组通话 (${participantCount})`
: currentCall.peerName || '未知用户';
return (
<View style={styles.container}>
<View style={[styles.container, { top: insets.top + 4 }]}>
<TouchableOpacity
style={styles.window}
style={styles.bar}
onPress={toggleMinimize}
activeOpacity={0.9}
>
{/* Avatar */}
<View style={styles.avatarContainer}>
{currentCall.peerAvatar ? (
<Image
source={{ uri: currentCall.peerAvatar }}
style={styles.avatar}
/>
{/* Call type indicator */}
<View style={styles.callTypeIcon}>
<Ionicons
name={isVideoCall ? 'videocam' : 'call'}
size={14}
color={isConnected ? '#34C759' : 'rgba(255,255,255,0.6)'}
/>
</View>
{/* Avatar + status indicator ring */}
<View style={styles.avatarWrap}>
{currentCall.peerAvatar && !isGroupCall ? (
<Image source={{ uri: currentCall.peerAvatar }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
</Text>
<View style={[styles.avatar, styles.avatarFallback]}>
{isGroupCall ? (
<MaterialCommunityIcons name="account-group" size={22} color="#fff" />
) : (
<Text style={styles.avatarText}>
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
</Text>
)}
</View>
)}
{isConnected && <View style={styles.statusDot} />}
</View>
{/* Info */}
<View style={styles.info}>
<Text style={styles.name} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
{displayName}
</Text>
<Text style={[styles.status, isConnected && styles.statusTimer]}>
{getStatusText()}
</Text>
<Text style={styles.status}>{getStatusText()}</Text>
</View>
{/* End call button */}
<TouchableOpacity
style={styles.endButton}
style={styles.endBtn}
onPress={(e) => {
e.stopPropagation();
endCall('hangup');
}}
activeOpacity={0.7}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<MaterialCommunityIcons name="phone-hangup" size={20} color="#fff" />
</TouchableOpacity>
@@ -96,25 +116,34 @@ const FloatingCallWindow: React.FC = () => {
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 100,
left: 16,
right: 16,
left: 0,
right: 0,
zIndex: 10000,
paddingHorizontal: 16,
},
window: {
bar: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#2A2A4E',
borderRadius: 16,
padding: 12,
backgroundColor: 'rgba(20, 20, 40, 0.92)',
borderRadius: 18,
paddingVertical: 12,
paddingHorizontal: 14,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
shadowOpacity: 0.35,
shadowRadius: 12,
elevation: 10,
borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.08)',
},
avatarContainer: {
marginRight: 12,
callTypeIcon: {
marginRight: 8,
width: 20,
alignItems: 'center',
},
avatarWrap: {
position: 'relative',
marginRight: 14,
},
avatar: {
width: 44,
@@ -122,7 +151,7 @@ const styles = StyleSheet.create({
borderRadius: 22,
backgroundColor: '#3A3A5C',
},
avatarPlaceholder: {
avatarFallback: {
justifyContent: 'center',
alignItems: 'center',
},
@@ -131,28 +160,44 @@ const styles = StyleSheet.create({
color: '#fff',
fontWeight: '600',
},
statusDot: {
position: 'absolute',
bottom: 0,
right: 0,
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: '#34C759',
borderWidth: 2,
borderColor: 'rgba(20, 20, 40, 0.92)',
},
info: {
flex: 1,
justifyContent: 'center',
},
name: {
fontSize: 15,
fontSize: 16,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 2,
},
status: {
fontSize: 12,
color: 'rgba(255, 255, 255, 0.6)',
color: 'rgba(255, 255, 255, 0.45)',
},
endButton: {
width: 36,
height: 36,
borderRadius: 18,
statusTimer: {
color: '#34C759',
fontWeight: '500',
fontVariant: ['tabular-nums'],
},
endBtn: {
width: BUTTON_SIZE,
height: BUTTON_SIZE,
borderRadius: BUTTON_SIZE / 2,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
marginLeft: 8,
marginLeft: 10,
},
});

View File

@@ -8,160 +8,190 @@ import {
Animated,
Modal,
StatusBar,
Dimensions,
Platform,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { callStore } from '../../stores/call';
import { blurActiveElement } from '../../infrastructure/platform';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
const AVATAR_SIZE = 104;
const BTN_SIZE = 68;
const BTN_ICON = 30;
const IncomingCallModal: React.FC = () => {
const incomingCall = callStore((s) => s.incomingCall);
const acceptCall = callStore((s) => s.acceptCall);
const rejectCall = callStore((s) => s.rejectCall);
const insets = useSafeAreaInsets();
const pulseAnim = useRef(new Animated.Value(1)).current;
const rippleAnim = useRef(new Animated.Value(0)).current;
const rippleAnim1 = useRef(new Animated.Value(0)).current;
const rippleAnim2 = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (incomingCall) {
blurActiveElement();
const pulse = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.06,
duration: 1000,
toValue: 1.05,
duration: 1200,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
duration: 1200,
useNativeDriver: true,
}),
])
]),
);
const ripple = Animated.loop(
const ripple1 = Animated.loop(
Animated.sequence([
Animated.timing(rippleAnim, {
Animated.timing(rippleAnim1, {
toValue: 1,
duration: 2000,
duration: 2200,
useNativeDriver: true,
}),
Animated.timing(rippleAnim, {
Animated.timing(rippleAnim1, {
toValue: 0,
duration: 0,
useNativeDriver: true,
}),
])
]),
);
Animated.parallel([pulse, ripple]).start();
const ripple2 = Animated.loop(
Animated.sequence([
Animated.delay(1100),
Animated.timing(rippleAnim2, {
toValue: 1,
duration: 2200,
useNativeDriver: true,
}),
Animated.timing(rippleAnim2, {
toValue: 0,
duration: 0,
useNativeDriver: true,
}),
]),
);
Animated.parallel([pulse, ripple1, ripple2]).start();
return () => {
pulse.stop();
ripple.stop();
ripple1.stop();
ripple2.stop();
};
}
}, [incomingCall, pulseAnim, rippleAnim]);
const handleAccept = () => {
acceptCall();
};
const handleReject = () => {
rejectCall();
};
}, [incomingCall, pulseAnim, rippleAnim1, rippleAnim2]);
if (!incomingCall) return null;
const callerName = incomingCall.callerName || '未知用户';
const callerInitial = callerName.charAt(0).toUpperCase();
const isVideoCall = incomingCall.callType === 'video';
return (
<Modal
visible={!!incomingCall}
visible
transparent
animationType="fade"
statusBarTranslucent
>
<View style={styles.overlay}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<View style={styles.root}>
<StatusBar barStyle="light-content" translucent backgroundColor="transparent" />
{/* Avatar with ripple effect - positioned in upper area */}
{/* Top safe area + label */}
<View style={[styles.topLabel, { paddingTop: insets.top + 16 }]}>
<Text style={styles.topLabelText}>
{isVideoCall ? '视频通话邀请' : '语音通话邀请'}
</Text>
</View>
{/* Avatar section with ripples */}
<View style={styles.avatarSection}>
{/* Ripple rings */}
{[0, 1, 2].map((i) => (
{/* Ripple rings — staggered */}
{[
{ anim: rippleAnim1, scaleRange: [1, 1.6], opacityRange: [0.2, 0] as const },
{ anim: rippleAnim2, scaleRange: [1, 1.4], opacityRange: [0.15, 0] as const },
].map(({ anim, scaleRange, opacityRange }, i) => (
<Animated.View
key={i}
style={[
styles.rippleRing,
styles.ripple,
{
transform: [
{
scale: rippleAnim.interpolate({
scale: anim.interpolate({
inputRange: [0, 1],
outputRange: [1, 1.5 + i * 0.2],
outputRange: scaleRange,
}),
},
],
opacity: rippleAnim.interpolate({
inputRange: [0, 0.4, 1],
outputRange: [0.25, 0.12, 0],
opacity: anim.interpolate({
inputRange: [0, 0.5, 1],
outputRange: [0, ...opacityRange],
}),
},
]}
/>
))}
<Animated.View
style={[styles.avatarContainer, { transform: [{ scale: pulseAnim }] }]}
>
{incomingCall.callerAvatar ? (
<Image
source={{ uri: incomingCall.callerAvatar }}
style={styles.avatar}
/>
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{incomingCall.callerName?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
<View style={styles.avatarBorder}>
{incomingCall.callerAvatar ? (
<Image source={{ uri: incomingCall.callerAvatar }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarFallback]}>
<Text style={styles.avatarInitial}>{callerInitial}</Text>
</View>
)}
</View>
</Animated.View>
</View>
{/* Caller info */}
<View style={styles.infoSection}>
<Text style={styles.callerName} numberOfLines={1}>
{incomingCall.callerName || '未知用户'}
{callerName}
</Text>
<Text style={styles.callType}>
{incomingCall.callType === 'video' ? '视频通话' : '语音通话'}
{isVideoCall ? '邀请你进行视频通话' : '邀请你进行语音通话'}
</Text>
</View>
{/* Bottom controls */}
<View style={styles.controls}>
{/* Bottom action buttons */}
<View style={[styles.actions, { paddingBottom: insets.bottom + 32 }]}>
{/* Decline */}
<TouchableOpacity
style={styles.actionButton}
onPress={handleReject}
style={styles.actionTouchable}
onPress={rejectCall}
activeOpacity={0.8}
>
<View style={styles.declineCircle}>
<MaterialCommunityIcons name="phone-hangup" size={28} color="#fff" />
<MaterialCommunityIcons
name="phone-hangup"
size={BTN_ICON}
color="#fff"
/>
</View>
<Text style={styles.actionLabel}></Text>
</TouchableOpacity>
{/* Accept */}
<TouchableOpacity
style={styles.actionButton}
onPress={handleAccept}
style={styles.actionTouchable}
onPress={acceptCall}
activeOpacity={0.8}
>
<View style={styles.acceptCircle}>
<MaterialCommunityIcons name="phone" size={28} color="#fff" />
<MaterialCommunityIcons
name="phone"
size={BTN_ICON}
color="#fff"
/>
</View>
<Text style={styles.actionLabel}></Text>
</TouchableOpacity>
@@ -171,36 +201,45 @@ const IncomingCallModal: React.FC = () => {
);
};
const AVATAR_SIZE = 100;
const ACTION_CIRCLE_SIZE = 60;
export default IncomingCallModal;
const styles = StyleSheet.create({
overlay: {
root: {
flex: 1,
backgroundColor: '#0D0D0D',
backgroundColor: '#0A0A14',
},
avatarSection: {
position: 'absolute',
top: '18%',
left: 0,
right: 0,
height: AVATAR_SIZE + 60,
justifyContent: 'center',
topLabel: {
alignItems: 'center',
},
rippleRing: {
topLabelText: {
fontSize: 13,
fontWeight: '500',
color: 'rgba(255,255,255,0.35)',
letterSpacing: 0.5,
textTransform: 'uppercase',
},
// ---- Avatar + ripples ----
avatarSection: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
maxHeight: 260,
},
ripple: {
position: 'absolute',
width: AVATAR_SIZE + 16,
height: AVATAR_SIZE + 16,
borderRadius: (AVATAR_SIZE + 16) / 2,
width: AVATAR_SIZE + 32,
height: AVATAR_SIZE + 32,
borderRadius: (AVATAR_SIZE + 32) / 2,
borderWidth: 2,
borderColor: '#4CD964',
},
avatarContainer: {
width: AVATAR_SIZE + 8,
height: AVATAR_SIZE + 8,
borderRadius: (AVATAR_SIZE + 8) / 2,
backgroundColor: '#4CD964',
avatarBorder: {
width: AVATAR_SIZE + 10,
height: AVATAR_SIZE + 10,
borderRadius: (AVATAR_SIZE + 10) / 2,
borderWidth: 2.5,
borderColor: 'rgba(76, 217, 100, 0.4)',
justifyContent: 'center',
alignItems: 'center',
},
@@ -208,72 +247,80 @@ const styles = StyleSheet.create({
width: AVATAR_SIZE,
height: AVATAR_SIZE,
borderRadius: AVATAR_SIZE / 2,
backgroundColor: '#3A3A5C',
backgroundColor: '#2A2A4E',
},
avatarPlaceholder: {
avatarFallback: {
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 36,
avatarInitial: {
fontSize: 42,
color: '#fff',
fontWeight: '600',
},
// ---- Info ----
infoSection: {
position: 'absolute',
top: '42%',
left: 0,
right: 0,
alignItems: 'center',
paddingHorizontal: 40,
marginTop: -20,
marginBottom: 40,
},
callerName: {
fontSize: 24,
fontSize: 28,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 6,
textAlign: 'center',
maxWidth: 280,
maxWidth: 300,
},
callType: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.5)',
letterSpacing: 0.3,
fontSize: 15,
fontWeight: '400',
color: 'rgba(255,255,255,0.45)',
marginTop: 8,
},
controls: {
position: 'absolute',
bottom: '12%',
left: 0,
right: 0,
// ---- Actions ----
actions: {
flexDirection: 'row',
justifyContent: 'center',
gap: 60,
gap: 72,
paddingHorizontal: 40,
},
actionButton: {
actionTouchable: {
alignItems: 'center',
width: 80,
},
declineCircle: {
width: ACTION_CIRCLE_SIZE,
height: ACTION_CIRCLE_SIZE,
borderRadius: ACTION_CIRCLE_SIZE / 2,
width: BTN_SIZE,
height: BTN_SIZE,
borderRadius: BTN_SIZE / 2,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#E54D42',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.45,
shadowRadius: 10,
elevation: 8,
},
acceptCircle: {
width: ACTION_CIRCLE_SIZE,
height: ACTION_CIRCLE_SIZE,
borderRadius: ACTION_CIRCLE_SIZE / 2,
width: BTN_SIZE,
height: BTN_SIZE,
borderRadius: BTN_SIZE / 2,
backgroundColor: '#4CD964',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#4CD964',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.45,
shadowRadius: 10,
elevation: 8,
},
actionLabel: {
fontSize: 12,
color: 'rgba(255, 255, 255, 0.6)',
marginTop: 10,
fontSize: 13,
fontWeight: '500',
color: 'rgba(255,255,255,0.5)',
marginTop: 12,
},
});
export default IncomingCallModal;

View File

@@ -346,7 +346,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
},
overlayTouchable: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
},
overlay: {
flex: 1,

View File

@@ -5,9 +5,10 @@ interface HighlightTextProps {
text: string;
keyword: string;
style?: StyleProp<TextStyle>;
highlightStyle?: StyleProp<TextStyle>;
}
const HighlightText: React.FC<HighlightTextProps> = ({ text, keyword, style }) => {
const HighlightText: React.FC<HighlightTextProps> = ({ text, keyword, style, highlightStyle }) => {
const parts = useMemo(() => {
if (!text || !keyword) return [{ text, highlight: false }];
@@ -37,12 +38,10 @@ const HighlightText: React.FC<HighlightTextProps> = ({ text, keyword, style }) =
}, [text, keyword]);
return (
<Text>
<Text style={style}>
{parts.map((part, i) =>
part.highlight ? (
<Text key={i} style={style}>
{part.text}
</Text>
<Text key={i} style={highlightStyle}>{part.text}</Text>
) : (
<Text key={i}>{part.text}</Text>
)

View File

@@ -30,8 +30,6 @@ import Animated, {
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as MediaLibrary from 'expo-media-library';
import { File, Paths } from 'expo-file-system';
import { spacing, borderRadius, fontSizes } from '../../theme';
import { blurActiveElement } from '../../infrastructure/platform';
@@ -203,7 +201,10 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
// 保存图片到本地相册
const handleSaveImage = useCallback(async () => {
if (!currentImage || saving) return;
if (!currentImage || saving || Platform.OS === 'web') return;
const MediaLibrary = require('expo-media-library') as typeof import('expo-media-library');
const { File, Paths } = require('expo-file-system') as typeof import('expo-file-system');
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
@@ -212,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';
@@ -221,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');
@@ -235,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]);
@@ -548,7 +559,7 @@ const styles = StyleSheet.create({
height: SCREEN_HEIGHT * 0.8,
},
errorContainer: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#000',

View File

@@ -148,7 +148,7 @@ function createImageGridStyles(colors: AppColors) {
backgroundColor: colors.background.disabled,
},
moreOverlay: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
justifyContent: 'center',
alignItems: 'center',
@@ -390,10 +390,12 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
return (
<View style={[gridStyles.horizontalContainer, { gap }]}>
{displayImages.map((image, index) => {
const previewUrl = usePreview && displayMode
const mainUri = image.uri || image.url || '';
const previewForSmart = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode)
: (image.uri || image.url || '');
: '';
const useSmartPreview = !!(usePreview && previewForSmart && mainUri && previewForSmart !== mainUri);
return (
<Pressable
key={image.id || index}
@@ -408,7 +410,9 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
]}
>
<SmartImage
source={{ uri: previewUrl }}
source={{ uri: mainUri }}
previewUrl={useSmartPreview ? previewForSmart : undefined}
usePreview={useSmartPreview}
style={gridStyles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
@@ -427,10 +431,12 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
{displayImages.map((image, index) => {
const isLastVisible = index === displayImages.length - 1;
const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay;
const previewUrl = usePreview && displayMode
const mainUri = image.uri || image.url || '';
const previewForSmart = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode)
: (image.uri || image.url || '');
: '';
const useSmartPreview = !!(usePreview && previewForSmart && mainUri && previewForSmart !== mainUri);
return (
<Pressable
@@ -445,7 +451,9 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
]}
>
<SmartImage
source={{ uri: previewUrl }}
source={{ uri: mainUri }}
previewUrl={useSmartPreview ? previewForSmart : undefined}
usePreview={useSmartPreview}
style={gridStyles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
@@ -489,10 +497,12 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
? image.width / image.height
: 1;
const height = itemWidth / aspectRatio;
const previewUrl = usePreview && displayMode
const mainUri = image.uri || image.url || '';
const previewForSmart = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode)
: (image.uri || image.url || '');
: '';
const useSmartPreview = !!(usePreview && previewForSmart && mainUri && previewForSmart !== mainUri);
return (
<Pressable
@@ -508,7 +518,9 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
]}
>
<SmartImage
source={{ uri: previewUrl }}
source={{ uri: mainUri }}
previewUrl={useSmartPreview ? previewForSmart : undefined}
usePreview={useSmartPreview}
style={gridStyles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}

View File

@@ -30,7 +30,7 @@ function createSmartImageStyles(colors: AppColors) {
flex: 1,
},
overlay: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.disabled,
@@ -125,6 +125,7 @@ export const SmartImage: React.FC<SmartImageProps> = ({
const [loadState, setLoadState] = useState<ImageLoadState>('loading');
const [isVisible, setIsVisible] = useState(() => !lazyLoad || Platform.OS !== 'web');
const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false);
const isUpgradingFromPreview = useRef(false);
const lazyTargetRef = useRef<View>(null);
// 解析图片源 - 支持 uri 或 url 字段
@@ -137,6 +138,7 @@ export const SmartImage: React.FC<SmartImageProps> = ({
useEffect(() => {
setShouldLoadOriginal(false);
isUpgradingFromPreview.current = false;
setLoadState('loading');
}, [imageUri]);
@@ -180,16 +182,25 @@ export const SmartImage: React.FC<SmartImageProps> = ({
// 处理加载开始
const handleLoadStart = useCallback(() => {
setLoadState('loading');
// 从预览图切换到原图时不重置为loading状态避免闪烁
if (isUpgradingFromPreview.current) {
return;
}
// 已成功加载的图片不再回到loading状态列表回收/re-render时onLoadStart可能重新触发
setLoadState(prev => (prev === 'success' ? prev : 'loading'));
}, []);
// 处理加载完成
const handleLoad = useCallback(() => {
setLoadState('success');
// 如果正在加载预览图,切换到原图
// 如果正在加载预览图切换到原图不重置loading状态避免闪烁
if (usePreview && !shouldLoadOriginal && previewUri && getImageUrl() === previewUri) {
isUpgradingFromPreview.current = true;
setShouldLoadOriginal(true);
return;
}
// 原图加载完成(包括从预览图升级的情况)
isUpgradingFromPreview.current = false;
setLoadState('success');
onLoad?.();
}, [onLoad, usePreview, shouldLoadOriginal, previewUri, getImageUrl]);
@@ -198,9 +209,11 @@ export const SmartImage: React.FC<SmartImageProps> = ({
(error: any) => {
// 如果预览图加载失败,尝试加载原图
if (usePreview && !shouldLoadOriginal && getImageUrl() === previewUri && imageUri) {
isUpgradingFromPreview.current = true;
setShouldLoadOriginal(true);
return;
}
isUpgradingFromPreview.current = false;
setLoadState('error');
onError?.(error);
},
@@ -298,6 +311,7 @@ export const SmartImage: React.FC<SmartImageProps> = ({
resizeMode === 'center' ? 'scale-down' :
resizeMode
}
transition={200}
cachePolicy={cachePolicy}
onLoadStart={handleLoadStart}
onLoad={handleLoad}

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

@@ -152,7 +152,7 @@ export class CacheDataSource implements ICacheDataSource {
const keys = await AsyncStorage.getAllKeys();
const cacheKeys = keys.filter(k => k.startsWith(this.persistPrefix));
if (cacheKeys.length > 0) {
await AsyncStorage.multiRemove(cacheKeys);
await AsyncStorage.removeMany(cacheKeys);
}
} catch (error) {
throw new DataSourceError(

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

@@ -1,6 +1,7 @@
import { localDataSource } from '../LocalDataSource';
import { CachedMessage } from '../types';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
import { extractTextFromSegments } from '@/types/dto';
export interface IMessageRepository {
saveMessage(message: CachedMessage): Promise<void>;
@@ -8,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>;
@@ -16,7 +27,14 @@ 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 }>;
getLastMessageByConversation(conversationId: string): Promise<CachedMessage | null>;
}
@@ -77,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 ?`,
@@ -85,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]
@@ -136,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}%`]
@@ -143,6 +207,35 @@ export class MessageRepository implements IMessageRepository {
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined }));
}
async searchByConversation(
conversationId: string,
keyword: string,
limit: number = 50,
offset: number = 0
): Promise<CachedMessage[]> {
const likeParam = `%${keyword}%`;
const rows = await this.dataSource.query<any>(
`SELECT * FROM messages
WHERE conversationId = ?
AND status != 'deleted'
AND (segments LIKE ? OR content LIKE ?)
ORDER BY createdAt DESC
LIMIT ? OFFSET ?`,
[conversationId, likeParam, likeParam, limit, offset]
);
const lowerKeyword = keyword.toLowerCase();
return rows
.map(r => ({
...r,
isRead: r.isRead === 1,
segments: r.segments ? JSON.parse(r.segments) : undefined,
}))
.filter(msg => {
const text = extractTextFromSegments(msg.segments).toLowerCase();
return text.includes(lowerKeyword);
});
}
async getStats(): Promise<{ totalMessages: number; totalConversations: number }> {
const msgCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM messages`);
const convCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM conversations`);

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

@@ -70,6 +70,9 @@ export { useMediaCache } from './useMediaCache';
export type { UseMediaCacheReturn } from './useMediaCache';
// ==================== 推送设备注册 Hook ====================
export { useRegisterPushDevice } from './useRegisterPushDevice';
// ==================== 差异更新 Hooks ====================
export { useDifferentialMessages } from './useDifferentialMessages';

14
src/hooks/useChannels.ts Normal file
View File

@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { channelService, type ChannelItem } from '@/services/post/channelService';
export const channelKeys = {
all: ['channels'] as const,
};
export function useChannels() {
return useQuery({
queryKey: channelKeys.all,
queryFn: () => channelService.list(),
staleTime: 10 * 60 * 1000,
});
}

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

@@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react';
import { Platform } from 'react-native';
import { jpushService } from '../services/notification/jpushService';
export function useRegisterPushDevice(isAuthenticated: boolean, userID?: string) {
const deviceRegistered = useRef(false);
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;
jpushService.initialize().then((ok) => {
if (ok && userID) {
jpushService.setAliasForUser(userID);
}
});
}
return () => {
if (!isAuthenticated) {
deviceRegistered.current = false;
jpushService.cleanup();
}
};
}, [isAuthenticated, userID]);
}

View File

@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { messageManager } from '@/stores/message/MessageManager';
export const messageKeys = {
unread: ['messages', 'unread'] as const,
};
export function useUnreadCountQuery() {
return useQuery({
queryKey: messageKeys.unread,
queryFn: async () => {
await messageManager.fetchUnreadCount();
return null;
},
staleTime: 30 * 1000,
refetchInterval: 60 * 1000,
});
}

View File

@@ -154,9 +154,9 @@ export class MediaCacheManager {
if (recordKeys.length === 0) return;
const records = await AsyncStorage.multiGet(recordKeys);
for (const [key, value] of records) {
const records = await AsyncStorage.getMany(recordKeys);
for (const [key, value] of Object.entries(records)) {
if (value) {
const entry: CacheEntry = JSON.parse(value);
// 验证文件是否存在
@@ -614,7 +614,7 @@ export class MediaCacheManager {
const keys = await AsyncStorage.getAllKeys();
const recordKeys = keys.filter(k => k.startsWith(CACHE_RECORD_PREFIX));
if (recordKeys.length > 0) {
await AsyncStorage.multiRemove(recordKeys);
await AsyncStorage.removeMany(recordKeys);
}
}

View File

@@ -2,13 +2,16 @@
* Expo Router 路径构建(单一事实来源,避免魔法字符串散落)
*/
import type { SystemMessageResponse } from '../types/dto';
import { routePayloadCache } from '../stores';
export function hrefPostDetail(postId: string, scrollToComments?: boolean): string {
const q = scrollToComments ? '?scrollToComments=1' : '';
return `/post/${encodeURIComponent(postId)}${q}`;
}
export function hrefTradeDetail(tradeId: string): string {
return `/trade/${encodeURIComponent(tradeId)}`;
}
export function hrefUserProfile(userId: string): string {
return `/user/${encodeURIComponent(userId)}`;
}
@@ -73,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';
}
@@ -87,14 +98,16 @@ export function hrefChat(params: {
groupId?: string;
groupName?: string;
groupAvatar?: string;
scrollToSeq?: number;
}): string {
const { conversationId, userId, isGroupChat, groupId, groupName, groupAvatar } = params;
const { conversationId, userId, isGroupChat, groupId, groupName, groupAvatar, scrollToSeq } = params;
const q = new URLSearchParams();
if (userId) q.set('userId', userId);
if (isGroupChat) q.set('isGroupChat', '1');
if (groupId != null && groupId !== '') q.set('groupId', String(groupId));
if (groupName) q.set('groupName', groupName);
if (groupAvatar) q.set('groupAvatar', groupAvatar);
if (scrollToSeq != null) q.set('scrollToSeq', String(scrollToSeq));
const qs = q.toString();
return `/chat/${encodeURIComponent(conversationId)}${qs ? `?${qs}` : ''}`;
}
@@ -122,15 +135,26 @@ export function hrefGroupMembers(groupId: string): string {
}
export function hrefGroupRequestDetail(message: SystemMessageResponse): string {
routePayloadCache.stashSystemMessage(message);
return `/group/request?messageId=${encodeURIComponent(message.id)}`;
}
export function hrefGroupInviteDetail(message: SystemMessageResponse): string {
routePayloadCache.stashSystemMessage(message);
return `/group/invite?messageId=${encodeURIComponent(message.id)}`;
}
export function hrefMessageSearch(params: {
conversationId: string;
conversationName?: string;
isGroupChat?: boolean;
}): string {
const q = new URLSearchParams({
conversationId: params.conversationId,
});
if (params.conversationName) q.set('conversationName', params.conversationName);
if (params.isGroupChat) q.set('isGroupChat', '1');
return `/chat/message-search?${q.toString()}`;
}
export function hrefPrivateChatInfo(params: {
conversationId: string;
userId: string;
@@ -186,6 +210,10 @@ export function hrefProfileAbout(): string {
return '/profile/about';
}
export function hrefProfileHelp(): string {
return '/profile/help';
}
export function hrefProfileTerms(): string {
return '/terms';
}

43
src/polyfills.ts Normal file
View File

@@ -0,0 +1,43 @@
import { Platform } from 'react-native';
// Register WebRTC globals for LiveKit (native only — web has built-in WebRTC)
if (Platform.OS !== 'web') {
// Use @livekit/react-native's registerGlobals which sets up:
// - WebRTC native modules (via @livekit/react-native-webrtc)
// - iOS audio session management
// - LiveKitReactNativeGlobal (so livekit-client detects RN environment)
// - RN-specific polyfills (Promise.allSettled, webstreams, crypto, etc.)
const { registerGlobals } = require('@livekit/react-native');
registerGlobals();
}
if (typeof globalThis.Event === 'undefined') {
(globalThis as any).Event = class Event {
type: string;
bubbles: boolean;
cancelable: boolean;
defaultPrevented: boolean;
target: any;
currentTarget: any;
constructor(type: string, opts?: { bubbles?: boolean; cancelable?: boolean }) {
this.type = type;
this.bubbles = opts?.bubbles ?? false;
this.cancelable = opts?.cancelable ?? false;
this.defaultPrevented = false;
this.target = null;
this.currentTarget = null;
}
preventDefault() { this.defaultPrevented = true; }
stopPropagation() {}
stopImmediatePropagation() {}
};
}
if (typeof globalThis.DOMException === 'undefined') {
(globalThis as any).DOMException = class DOMException extends Error {
constructor(message?: string, name?: string) {
super(message);
this.name = name || 'DOMException';
}
};
}

View File

@@ -77,7 +77,7 @@ export const RegisterScreen: React.FC = () => {
resetRegisterData,
goToNextStep,
goToPrevStep,
} = useRegisterStore();
} = useRegisterStore.getState();
// 本地状态
const [loading, setLoading] = React.useState(false);

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,11 +25,12 @@ 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';
import { channelService, postService } from '../../services';
import { useChannels } from '../../hooks/useChannels';
import { PostCard, SearchBar, ShareSheet } from '../../components/business';
import type { PostCardAction } from '../../components/business/PostCard';
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem } from '../../components/common';
@@ -185,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,
@@ -204,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',
@@ -215,7 +266,7 @@ function createHomeStyles(colors: AppColors, responsivePadding: number) {
export const HomeScreen: React.FC = () => {
const router = useRouter();
const insets = useSafeAreaInsets();
const { posts: storePosts } = useUserStore();
const storePosts = useUserStore((s) => s.posts);
const currentUser = useCurrentUser();
const isAuthenticated = useIsAuthenticated();
const isVerified = useIsVerified();
@@ -242,19 +293,33 @@ export const HomeScreen: React.FC = () => {
const [sortIndex, setSortIndex] = useState(2);
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [latestCapsules, setLatestCapsules] = useState<LatestCapsule[]>([{ id: '', name: '全部' }]);
const { data: channelList = [] } = useChannels();
const latestCapsules = useMemo<LatestCapsule[]>(
() => [{ id: '', name: '全部' }, ...channelList.map(item => ({ id: item.id, name: item.name }))],
[channelList]
);
const [activeCapsuleId, setActiveCapsuleId] = useState('');
// 图片查看器状态
const [showImageViewer, setShowImageViewer] = useState(false);
const [postImages, setPostImages] = useState<ImageGridItem[]>([]);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const stableCloseImageViewer = useCallback(() => setShowImageViewer(false), []);
const stableGalleryImages = useMemo(() => postImages.map((img, i) => ({
id: img.id || img.url || `img-${i}`,
url: img.url || img.uri || ''
})), [postImages]);
// 搜索显示状态(用于内嵌搜索页面)
const [showSearch, setShowSearch] = useState(false);
// 发帖弹窗状态
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');
@@ -353,7 +418,7 @@ export const HomeScreen: React.FC = () => {
useEffect(() => {
if (homeTabPressCount > 0) {
// 滚动 FlashList 到顶部
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
// 滚动 ScrollView 到顶部(网格模式)
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
// 重置底部 Tab 栏状态
@@ -426,7 +491,6 @@ export const HomeScreen: React.FC = () => {
refreshing: isRefreshing,
hasMore,
error,
reset,
} = useDifferentialPosts<Post>(
[],
{
@@ -486,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();
}
@@ -587,18 +652,6 @@ export const HomeScreen: React.FC = () => {
});
}, []);
useEffect(() => {
const loadChannels = async () => {
const list = await channelService.list();
const capsules: LatestCapsule[] = [
{ id: '', name: '全部' },
...list.map(item => ({ id: item.id, name: item.name })),
];
setLatestCapsules(capsules);
};
loadChannels();
}, []);
// 跳转到搜索页(使用内嵌模式,不再依赖导航)
const handleSearchPress = () => {
setShowSearch(true);
@@ -714,16 +767,23 @@ export const HomeScreen: React.FC = () => {
}
}, []);
// 跳转到发帖页面(使用 Modal 方式)
// 点击加号:校验登录/实名后展开「瞬间/长文」两个小按钮
const handleCreatePost = () => {
if (!isAuthenticated) {
router.push('/login');
router.push(hrefs.hrefAuthLogin());
return;
}
if (!isVerified) {
router.push(hrefs.hrefVerificationGuide());
return;
}
setFabExpanded(prev => !prev);
};
// 选择发帖模式并打开发帖弹窗
const openCreatePostWithMode = (mode: 'moment' | 'long') => {
setPostMode(mode);
setFabExpanded(false);
setShowCreatePost(true);
};
@@ -796,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"
@@ -805,22 +865,23 @@ export const HomeScreen: React.FC = () => {
/>
</View>
);
}, [currentUser?.id, stableOnPostAction]);
}, [currentUser?.id, stableOnPostAction, responsiveGap]);
// 渲染响应式网格布局(所有端统一使用 FlashList masonry 模式)
const renderResponsiveGrid = () => {
return (
<FlashList
key={`${listKey}_grid`}
key="home-grid"
ref={scrollViewRef as any}
data={displayPosts}
extraData={listKey}
renderItem={renderGridItem}
keyExtractor={keyExtractor}
numColumns={gridColumns}
masonry
optimizeItemArrangement
contentContainerStyle={{
paddingHorizontal: responsivePadding,
paddingHorizontal: responsiveGap / 2,
paddingBottom: 80 + responsivePadding,
}}
showsVerticalScrollIndicator={false}
@@ -865,9 +926,10 @@ export const HomeScreen: React.FC = () => {
// 移动端和宽屏都使用单列 FlashList宽屏下居中显示
return (
<FlashList
key={listKey}
key="home-list"
ref={flashListRef}
data={displayPosts}
extraData={listKey}
renderItem={renderPostList}
keyExtractor={keyExtractor}
contentContainerStyle={{
@@ -901,6 +963,7 @@ export const HomeScreen: React.FC = () => {
<StatusBar barStyle={statusBarStyle} backgroundColor={colors.background.paper} />
<SearchScreen
onBack={() => setShowSearch(false)}
homeTab={homeTab}
/>
</SafeAreaView>
);
@@ -1041,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={[
@@ -1050,24 +1158,25 @@ export const HomeScreen: React.FC = () => {
floatingButtonBottom !== undefined && { bottom: floatingButtonBottom },
]}
onPress={homeTab === 'market' ? () => {
if (!isAuthenticated) { router.push('/login'); return; }
if (!isAuthenticated) { router.push(hrefs.hrefAuthLogin()); return; }
if (!isVerified) { router.push(hrefs.hrefVerificationGuide()); return; }
setShowCreateTrade(true);
} : 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>
{/* 图片查看器 */}
<ImageGallery
visible={showImageViewer}
images={postImages.map(img => ({
id: img.id || img.url || String(Math.random()),
url: img.url || img.uri || ''
}))}
images={stableGalleryImages}
initialIndex={selectedImageIndex}
onClose={() => setShowImageViewer(false)}
onClose={stableCloseImageViewer}
enableSave
/>
@@ -1079,6 +1188,8 @@ export const HomeScreen: React.FC = () => {
onRequestClose={() => setShowCreatePost(false)}
>
<CreatePostScreen
key={postMode}
postMode={postMode}
onClose={() => setShowCreatePost(false)}
/>
</Modal>

View File

@@ -14,6 +14,7 @@ import type {
} from '../../types/trade';
import { useRouter } from 'expo-router';
import { useResponsive, useResponsiveSpacing } from '../../hooks';
import * as hrefs from '../../navigation/hrefs';
type TradeFilterType = 'all' | 'sell' | 'buy';
@@ -27,7 +28,7 @@ function createMarketStyles(colors: AppColors, gap: number, padding: number) {
},
listContent: {
paddingHorizontal: padding,
paddingTop: gap / 2,
paddingTop: padding,
paddingBottom: 80,
},
});
@@ -137,7 +138,7 @@ export function MarketView({
}, [isLoadingMore, hasMore, cursor, filterType, selectedCategory]);
const handlePressItem = useCallback((id: string) => {
router.push(`/trade/${id}` as any);
router.push(hrefs.hrefTradeDetail(id));
}, [router]);
const handleFavorite = useCallback(async (id: string) => {
@@ -187,7 +188,8 @@ export function MarketView({
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={numColumns}
key={`market_${numColumns}`}
key="market-list"
extraData={numColumns}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
refreshControl={

View File

@@ -37,13 +37,21 @@ 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, uploadService, authService, showPrompt, voteService } from '../../services';
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 {
type PendingOrRemoteImage,
makePendingImageFromAsset,
getImageDisplayUri,
uploadAllPendingImages,
} from '../../utils/pendingImages';
import { handleError } from '@/services/core';
import { formatDateTime as formatDateTimeUtil, formatRelativeTime as formatRelativeTimeUtil } from '@/utils/formatTime';
import * as hrefs from '../../navigation/hrefs';
@@ -251,10 +259,11 @@ export const PostDetailScreen: React.FC = () => {
const [keyboardHeight, setKeyboardHeight] = useState(0);
const [isDeleting, setIsDeleting] = useState(false);
// 评论图片相关状态
const [commentImages, setCommentImages] = useState<{ uri: string; uploading?: boolean }[]>([]);
const [commentImages, setCommentImages] = useState<PendingOrRemoteImage[]>([]);
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); // 是否已记录浏览量
@@ -270,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,
@@ -378,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(() => {
@@ -688,31 +736,8 @@ export const PostDetailScreen: React.FC = () => {
});
if (!result.canceled && result.assets) {
const newImages = result.assets.map(asset => ({ uri: asset.uri, uploading: true }));
const newImages = result.assets.map(asset => makePendingImageFromAsset(asset, 'ci'));
setCommentImages(prev => [...prev, ...newImages]);
// 上传图片
for (let i = 0; i < newImages.length; i++) {
const asset = result.assets[i];
const uploadResult = await uploadService.uploadImage({
uri: asset.uri,
type: asset.mimeType || 'image/jpeg',
});
if (uploadResult) {
setCommentImages(prev => {
const updated = [...prev];
const index = prev.findIndex(img => img.uri === asset.uri);
if (index !== -1) {
updated[index] = { uri: uploadResult.url, uploading: false };
}
return updated;
});
} else {
Alert.alert('上传失败', '图片上传失败,请重试');
setCommentImages(prev => prev.filter(img => img.uri !== asset.uri));
}
}
}
};
@@ -727,11 +752,17 @@ export const PostDetailScreen: React.FC = () => {
const hasContent = commentText.trim() || commentImages.length > 0;
if (!hasContent || !post) return;
// 检查是否有图片正在上传
const uploadingImages = commentImages.filter(img => img.uploading);
if (uploadingImages.length > 0) {
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
return;
// 上传所有 pending 图片
let uploadedImageUrls: string[] = [];
if (commentImages.length > 0) {
const uploadResult = await uploadAllPendingImages(commentImages, (id, url) => {
setCommentImages(prev => prev.map(i => i.id === id ? { id, kind: 'remote' as const, url } : i));
});
if (!uploadResult.success) {
Alert.alert('上传失败', '部分图片上传失败,请重试');
return;
}
uploadedImageUrls = uploadResult.urls;
}
const tempId = `new-${Date.now()}`;
@@ -741,9 +772,6 @@ export const PostDetailScreen: React.FC = () => {
// 获取根评论 ID如果被回复评论有 root_id则使用 root_id否则使用 parent_id即被回复的是顶级评论
const rootId = isReply ? (replyingTo.root_id || replyingTo.id) : null;
// 获取已上传图片的URL列表
const uploadedImageUrls = commentImages.filter(img => !img.uploading).map(img => img.uri);
// 先创建临时评论显示在列表中
const tempComment: Comment = {
id: tempId,
@@ -1021,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) {
@@ -1084,6 +1114,12 @@ export const PostDetailScreen: React.FC = () => {
}));
}, [post?.images]);
// 为 ImageGallery 提供稳定的图片列表,避免 Math.random() 在每次渲染时生成新 ID
const stableGalleryImages = useMemo(() => allImages.map((img, i) => ({
id: img.id || img.url || `img-${i}`,
url: img.url || img.uri || ''
})), [allImages]);
// 渲染帖子头部 - 小红书/微博风格
const renderPostHeader = useCallback(() => {
if (!post) return null;
@@ -1279,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 = () => {
@@ -1353,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 + '@');
@@ -1479,6 +1538,7 @@ export const PostDetailScreen: React.FC = () => {
{/* Text Input —— 小红书风格:无边框、自动扩展 */}
<PostMentionInput
ref={commentInputRef}
value={commentText}
onChangeText={setCommentText}
onSegmentsChange={setCommentSegments}
@@ -1495,13 +1555,12 @@ export const PostDetailScreen: React.FC = () => {
{commentImages.length > 0 && (
<ScrollView horizontal style={styles.commentImagesPreview} showsHorizontalScrollIndicator={false}>
{commentImages.map((image, index) => (
<View key={index} style={styles.commentImageWrapper}>
<Image source={{ uri: image.uri }} style={styles.commentImageThumbnail} resizeMode="cover" />
{image.uploading && (
<View style={styles.uploadingOverlay}>
<Loading size="sm" />
</View>
)}
<View key={image.id} style={styles.commentImageWrapper}>
<Image
source={{ uri: getImageDisplayUri(image) }}
style={styles.commentImageThumbnail}
resizeMode="cover"
/>
<TouchableOpacity
style={styles.removeImageButton}
onPress={() => handleRemoveCommentImage(index)}
@@ -1516,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}
@@ -1740,10 +1809,7 @@ export const PostDetailScreen: React.FC = () => {
{/* 图片预览 ImageGallery */}
<ImageGallery
visible={showImageModal}
images={allImages.map(img => ({
id: img.id || img.url || String(Math.random()),
url: img.url || img.uri || ''
}))}
images={stableGalleryImages}
initialIndex={selectedImageIndex}
onClose={() => setShowImageModal(false)}
enableSave
@@ -1815,10 +1881,7 @@ export const PostDetailScreen: React.FC = () => {
{/* 图片预览 ImageGallery */}
<ImageGallery
visible={showImageModal}
images={allImages.map(img => ({
id: img.id || img.url || String(Math.random()),
url: img.url || img.uri || ''
}))}
images={stableGalleryImages}
initialIndex={selectedImageIndex}
onClose={() => setShowImageModal(false)}
enableSave
@@ -1882,9 +1945,17 @@ function createPostDetailStyles(colors: AppColors) {
customHeader: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
height: 52,
backgroundColor: colors.background.paper,
minHeight: 66,
paddingRight: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
zIndex: 10,
},
headerPlaceholder: {
flex: 1,
@@ -1894,24 +1965,55 @@ function createPostDetailStyles(colors: AppColors) {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
marginLeft: spacing.sm,
marginLeft: spacing.xs,
minWidth: 0,
},
headerAvatarWrapper: {
marginRight: spacing.sm,
padding: 2,
borderRadius: borderRadius.full,
backgroundColor: `${colors.primary.main}10`,
},
headerUserInfo: {
flex: 1,
justifyContent: 'center',
minWidth: 0,
},
headerTitleRow: {
flexDirection: 'row',
alignItems: 'center',
minWidth: 0,
},
headerNickname: {
fontSize: fontSizes.md,
fontWeight: '600',
fontWeight: '700',
color: colors.text.primary,
maxWidth: 150,
},
headerAuthorPill: {
marginLeft: spacing.xs,
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: borderRadius.sm,
backgroundColor: `${colors.primary.main}18`,
},
headerAuthorPillText: {
fontSize: 9,
lineHeight: 12,
color: colors.primary.main,
fontWeight: '700',
},
headerMetaText: {
marginTop: 3,
fontSize: fontSizes.sm,
lineHeight: fontSizes.sm + 4,
color: colors.text.secondary,
},
headerRightContainer: {
flexDirection: 'row',
alignItems: 'center',
marginRight: spacing.sm,
marginRight: spacing.xs,
marginLeft: spacing.xs,
backgroundColor: 'transparent',
},
headerBackButton: {
@@ -1922,38 +2024,42 @@ function createPostDetailStyles(colors: AppColors) {
// 帖子容器
postContainer: {
backgroundColor: colors.background.paper,
paddingBottom: spacing.md,
paddingBottom: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
},
// 关注按钮样式
followButton: {
paddingHorizontal: spacing.md,
paddingVertical: 4,
borderRadius: borderRadius.lg,
minWidth: 64,
paddingVertical: 6,
borderRadius: borderRadius.full,
minWidth: 72,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
},
followButtonPrimary: {
backgroundColor: colors.primary.main,
backgroundColor: `${colors.primary.main}10`,
borderWidth: 1,
borderColor: `${colors.primary.main}35`,
},
followButtonOutline: {
backgroundColor: 'transparent',
backgroundColor: `${colors.text.primary}04`,
borderWidth: 1,
borderColor: colors.divider,
},
followButtonMutual: {
backgroundColor: 'transparent',
backgroundColor: `${colors.primary.main}08`,
borderWidth: 1,
borderColor: colors.divider,
borderColor: `${colors.primary.main}24`,
},
followButtonLoading: {
opacity: 0.7,
},
followButtonPlaceholder: {
minWidth: 64,
minWidth: 72,
paddingHorizontal: spacing.md,
paddingVertical: 4,
paddingVertical: 6,
backgroundColor: 'transparent',
},
followButtonText: {
@@ -1961,7 +2067,7 @@ function createPostDetailStyles(colors: AppColors) {
fontWeight: '600',
},
followButtonTextPrimary: {
color: colors.text.inverse,
color: colors.primary.main,
},
followButtonTextOutline: {
color: colors.text.secondary,
@@ -2185,17 +2291,6 @@ function createPostDetailStyles(colors: AppColors) {
height: 60,
borderRadius: borderRadius.sm,
},
uploadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
justifyContent: 'center',
alignItems: 'center',
borderRadius: borderRadius.sm,
},
removeImageButton: {
position: 'absolute',
top: -6,
@@ -2305,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,39 +12,49 @@ import {
TouchableOpacity,
ScrollView,
RefreshControl,
Animated,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import { Post, User } from '../../types';
import type { TradeItemDTO } from '../../types/trade';
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';
import * as hrefs from '../../navigation/hrefs';
import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks';
import { useCursorPagination } from '../../hooks/useCursorPagination';
const TABS = ['帖子', '用户'];
const SQUARE_TABS = ['帖子', '用户'];
const MARKET_TABS = ['商品', '用户'];
const DEFAULT_PAGE_SIZE = 20;
// 与 TabsLayout 中的常量保持一致tabBarHeight 56 + 上下浮动间距 12*2
const FLOATING_TAB_BAR_HEIGHT = 56 + 12 * 2;
type SearchType = 'posts' | 'users';
type SearchType = 'posts' | 'users' | 'trades';
interface SearchScreenProps {
onBack?: () => void;
homeTab?: 'square' | 'market';
}
export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 'square' }) => {
const colors = useAppColors();
const styles = useMemo(() => createSearchScreenStyles(colors), [colors]);
const router = useRouter();
const insets = useSafeAreaInsets();
const { searchHistory: history, addSearchHistory, clearSearchHistory } = useUserStore();
const history = useUserStore((state) => state.searchHistory);
const { addSearchHistory, clearSearchHistory } = useUserStore.getState();
const isMarket = homeTab === 'market';
const TABS = isMarket ? MARKET_TABS : SQUARE_TABS;
// 使用响应式 hook
const {
@@ -85,6 +95,25 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
// 保存当前搜索关键词用于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 {
@@ -114,6 +143,10 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
const [userResults, setUserResults] = useState<User[]>([]);
const [userLoading, setUserLoading] = useState(false);
// 市集搜索结果
const [tradeResults, setTradeResults] = useState<TradeItemDTO[]>([]);
const [tradeLoading, setTradeLoading] = useState(false);
const refreshRef = useRef(refresh);
refreshRef.current = refresh;
const resetRef = useRef(reset);
@@ -142,14 +175,21 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
try {
const searchType = getSearchType();
if (searchType === 'users') {
setUserLoading(true);
const usersResponse = await authService.searchUsers(trimmedKeyword, 1, 20);
setUserResults(usersResponse.list || []);
} else if (searchType === 'trades') {
setTradeLoading(true);
const tradeResponse = await tradeService.getTradeItems({ keyword: trimmedKeyword, page_size: DEFAULT_PAGE_SIZE });
setTradeResults(tradeResponse.list || []);
}
} catch (error) {
console.error('搜索失败:', error);
} finally {
setUserLoading(false);
setTradeLoading(false);
}
setHasSearched(true);
@@ -218,6 +258,13 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
// 获取当前搜索类型
const getSearchType = (): SearchType => {
if (isMarket) {
switch (activeIndex) {
case 0: return 'trades';
case 1: return 'users';
default: return 'trades';
}
}
switch (activeIndex) {
case 0: return 'posts';
case 1: return 'users';
@@ -425,18 +472,54 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
);
};
// 渲染市集搜索结果
const renderTradeResults = () => {
if (tradeResults.length === 0 && !tradeLoading) {
return (
<EmptyState
title="未找到相关商品"
description="试试其他关键词吧"
icon="shopping-search-outline"
/>
);
}
return (
<FlatList
data={tradeResults}
renderItem={({ item }) => (
<View style={{ paddingHorizontal: responsivePadding, marginBottom: responsiveGap }}>
<TradeCard
item={item}
variant="list"
onPress={(id) => router.push(hrefs.hrefTradeDetail(id))}
/>
</View>
)}
keyExtractor={item => item.id}
contentContainerStyle={{ paddingVertical: responsiveGap, paddingBottom: listBottomInset }}
showsVerticalScrollIndicator={false}
ListFooterComponent={tradeLoading ? <Loading size="sm" /> : null}
/>
);
};
// 渲染搜索结果
const renderSearchResults = () => {
const searchType = getSearchType();
if (searchType === 'posts') {
return renderPostResults();
}
if (searchType === 'users') {
return renderUserResults();
}
if (searchType === 'trades') {
return renderTradeResults();
}
return null;
};
@@ -445,10 +528,16 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
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"
@@ -459,7 +548,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
>
</Text>
<TouchableOpacity onPress={handleClearHistory}>
<TouchableOpacity onPress={handleClearHistory} activeOpacity={0.7}>
<MaterialCommunityIcons name="delete-outline" size={isDesktop ? 22 : 18} color={colors.text.secondary} />
</TouchableOpacity>
</View>
@@ -477,6 +566,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
}
]}
onPress={() => handleHistoryPress(keyword)}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="history" size={isDesktop ? 16 : 14} color={colors.text.secondary} />
<Text
@@ -494,48 +584,36 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
</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="搜索帖子、用户"
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}
@@ -546,8 +624,8 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
performSearch(currentKeyword);
}
}}
variant="modern"
icons={['file-document-outline', 'account-outline']}
variant="home"
style={{ paddingHorizontal: responsivePadding }}
/>
</View>
@@ -563,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,
},
@@ -611,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

@@ -27,11 +27,12 @@ import {
Platform,
TouchableOpacity,
BackHandler,
StatusBar,
Alert,
} from 'react-native';
import { FlashList, ListRenderItem } from '@shopify/flash-list';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useNavigation, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text, ImageGallery, ImageGridItem } from '../../components/common';
import { useAppColors, useResolvedColorScheme } from '../../theme';
@@ -111,6 +112,11 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
const [chatImages, setChatImages] = useState<ImageGridItem[]>([]);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const stableGalleryImages = useMemo(() => chatImages.map((img, i) => ({
id: img.id || img.url || `img-${i}`,
url: img.url || img.uri || ''
})), [chatImages]);
// 图片点击处理函数
const handleImagePress = (images: ImageGridItem[], index: number) => {
setChatImages(images);
@@ -154,7 +160,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
longPressMenuVisible,
selectedMessage,
selectedMessageId,
setSelectedMessageId,
menuPosition,
isGroupChat,
groupInfo,
@@ -169,6 +174,10 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
effectiveGroupName,
otherUserLastReadSeq,
messageMap,
// 引用消息回填(会话级缓存:内存 → 本地 SQLite → 服务端)
getCachedReply,
ensureReplyMessage,
jumpToMessageSeq,
loadingMore,
hasMoreHistory,
conversationId,
@@ -196,13 +205,13 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
handleDeleteMessage,
handleReplyMessage,
handleCancelReply,
handleRetrySend,
handleAvatarPress,
handleAvatarLongPress,
handleSelectMention,
handleMentionAll,
getSenderInfo,
getTypingHint,
getInputBottom,
handleDismiss,
navigateToInfo,
navigateToChatSettings,
@@ -211,7 +220,30 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
handleReachLatestEdge,
jumpToLatestMessages,
setBrowsingHistory,
clearHistoryLock,
} = useChatScreen(props);
const stableOnBack = useCallback(() => router.back(), [router]);
const stableOnFocusInput = useCallback(() => {
if (activePanel !== 'none' && activePanel !== 'mention') {
closePanel();
}
}, [activePanel, closePanel]);
const stableFocusTextInput = useCallback(() => textInputRef.current?.focus(), []);
const stableOnLayout = useCallback((e: any) => {
scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height;
}, []);
const stableOnScrollBeginDrag = useCallback(() => {
isUserDraggingRef.current = true;
handleDismiss();
}, [handleDismiss]);
const stableOnScrollEndDrag = useCallback(() => {
isUserDraggingRef.current = false;
}, []);
const stableOnMomentumScrollEnd = useCallback(() => {
isUserDraggingRef.current = false;
}, []);
const displayMessages = useMemo(() => [...messages].reverse(), [messages]);
const longPressMenuMemberMap = useMemo(() => {
@@ -254,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(() => {
@@ -332,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}
@@ -340,6 +397,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
onImagePress={handleImagePress}
onReply={handleReplyMessage}
onReplyPress={handleReplyPreviewPress}
onRetrySend={handleRetrySend}
/>
);
}, [
@@ -352,6 +410,8 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
otherUserLastReadSeq,
selectedMessageId,
messageMap,
getCachedReply,
ensureReplyMessage,
handleLongPressMessage,
handleAvatarPress,
handleAvatarLongPress,
@@ -360,6 +420,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
handleImagePress,
handleReplyMessage,
handleReplyPreviewPress,
handleRetrySend,
]);
const keyExtractor = useCallback((item: GroupMessage) => String(item.id), []);
@@ -387,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 增大且距离尾部变小
@@ -435,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']}>
@@ -451,7 +513,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
>
{!props.isEmbedded && <StatusBar style={resolved === 'dark' ? 'light' : 'dark'} backgroundColor={colors.background.paper} />}
{!props.isEmbedded && <StatusBar barStyle={resolved === 'dark' ? 'light-content' : 'dark-content'} backgroundColor={colors.background.paper} />}
{/* 顶部栏 */}
<ChatHeader
@@ -460,7 +522,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
otherUser={otherUser}
routeGroupName={effectiveGroupName ?? undefined}
typingHint={typingHint}
onBack={props.onEmbeddedBack ? props.onEmbeddedBack : () => router.back()}
onBack={props.onEmbeddedBack || stableOnBack}
onTitlePress={navigateToInfo}
onMorePress={navigateToChatSettings}
onGroupInfoPress={handleGroupInfoPress}
@@ -470,9 +532,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
{/* 消息列表 */}
<View
style={styles.messageListContainer}
onLayout={e => {
scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height;
}}
onLayout={stableOnLayout}
onTouchEnd={handleDismiss}
>
{loading ? (
@@ -493,16 +553,9 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
scrollEnabled={true}
drawDistance={250}
onScroll={handleMessageListScroll}
onScrollBeginDrag={() => {
isUserDraggingRef.current = true;
handleDismiss();
}}
onScrollEndDrag={() => {
isUserDraggingRef.current = false;
}}
onMomentumScrollEnd={() => {
isUserDraggingRef.current = false;
}}
onScrollBeginDrag={stableOnScrollBeginDrag}
onScrollEndDrag={stableOnScrollEndDrag}
onMomentumScrollEnd={stableOnMomentumScrollEnd}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
/>
@@ -568,12 +621,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
pendingAttachments={pendingAttachments}
onRemovePendingAttachment={removePendingAttachment}
onCancelReply={handleCancelReply}
onFocus={() => {
// 输入框获得焦点时,关闭其他面板(但不要关闭键盘)
if (activePanel !== 'none' && activePanel !== 'mention') {
closePanel();
}
}}
onFocus={stableOnFocusInput}
currentUser={currentUser}
otherUser={otherUser}
getSenderInfo={getSenderInfo}
@@ -587,7 +635,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
onInsertEmoji={handleInsertEmoji}
onInsertSticker={handleSendSticker}
onClose={closePanel}
onFocusInput={() => textInputRef.current?.focus()}
onFocusInput={stableFocusTextInput}
/>
</View>
)}
@@ -657,10 +705,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
{/* 图片查看器 */}
<ImageGallery
visible={showImageViewer}
images={chatImages.map(img => ({
id: img.id || img.url || String(Math.random()),
url: img.url || img.uri || ''
}))}
images={stableGalleryImages}
initialIndex={selectedImageIndex}
onClose={handleCloseImageViewer}
enableSave

View File

@@ -2,6 +2,7 @@
* GroupInfoScreen 群组信息/设置界面
* 显示群组详细信息,提供群组管理功能
* 支持响应式布局
* QQ 风格:移除装饰性图标,使用简洁的文字列表
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
@@ -21,15 +22,13 @@ import {
Dimensions,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useFocusEffect } from '@react-navigation/native';
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,103 +731,54 @@ 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>
{renderSettingItem(
'查找聊天记录',
'图片、视频、文件等',
conversationId
? () => router.push(hrefs.hrefMessageSearch({
conversationId,
conversationName: group?.name || '群聊',
isGroupChat: true,
}))
: undefined,
)}
<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>
{renderSwitchItem('设为置顶', isPinned, handleTogglePinned, !conversationId || pinLoading)}
<Divider style={styles.settingDivider} />
{renderSwitchItem('消息免打扰', isNotificationMuted, handleToggleNotificationMuted, !conversationId)}
</View>
</View>
@@ -848,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>
@@ -859,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>
@@ -906,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>
@@ -1019,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"
@@ -1038,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>
))}
@@ -1096,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>
@@ -1132,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',
@@ -1155,7 +1089,6 @@ function createGroupInfoStyles(colors: AppColors) {
flex: 1,
},
scrollContent: {
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xl * 2,
},
errorContainer: {
@@ -1164,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,
@@ -1181,7 +1115,7 @@ function createGroupInfoStyles(colors: AppColors) {
},
groupName: {
fontWeight: '700',
fontSize: fontSizes['2xl'],
fontSize: fontSizes.xl,
marginBottom: spacing.xs,
},
groupMeta: {
@@ -1196,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: {
@@ -1307,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,
},
@@ -1318,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)',
@@ -1423,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,
@@ -1434,12 +1337,8 @@ function createGroupInfoStyles(colors: AppColors) {
saveButton: {
fontWeight: '600',
},
confirmButton: {
fontWeight: '600',
textAlign: 'right',
},
// 编辑表单样式 - 扁平化
// 编辑表单样式
editForm: {
alignItems: 'center',
},
@@ -1468,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,
},
@@ -1529,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,
},
@@ -1566,6 +1452,10 @@ function createGroupInfoStyles(colors: AppColors) {
fontWeight: '700',
color: colors.primary.main,
},
joinTypeCheck: {
fontSize: fontSizes.lg,
fontWeight: '700',
},
// 转让群主样式
transferDesc: {
@@ -1619,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

@@ -26,8 +26,8 @@ import {
import { FlashList, ListRenderItem } from '@shopify/flash-list';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useIsFocused } from '@react-navigation/native';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useIsFocused } from "expo-router/react-navigation";
import { useBottomTabBarHeight } from "expo-router/js-tabs";
import { MaterialCommunityIcons } from '@expo/vector-icons';
import {
spacing,
@@ -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
@@ -152,6 +152,11 @@ export const MessageListScreen: React.FC = () => {
// 系统通知显示状态 - 用于在移动端显示通知页面
const [showNotifications, setShowNotifications] = useState(false);
// Stable callbacks to avoid inline arrows passed to child components
const stableCloseNotifications = useCallback(() => setShowNotifications(false), []);
const stableClearSelectedConversation = useCallback(() => setSelectedConversation(null), []);
const stableCloseScanner = useCallback(() => setScannerVisible(false), []);
// 系统消息会话对象(用于选中状态)
const systemMessageConversation: ConversationResponse = useMemo(() => ({
id: SYSTEM_MESSAGE_CHANNEL_ID,
@@ -197,14 +202,11 @@ export const MessageListScreen: React.FC = () => {
return FLOATING_TAB_BAR_HEIGHT + insets.bottom + spacing.md;
}, [isWideScreen, insets.bottom]);
// 【新架构】页面获得焦点时初始化MessageManager
// 初始化MessageManager(仅首次),焦点时轻量刷新会话列表
useEffect(() => {
if (isFocused) {
messageManager.initialize();
}
}, [isFocused]);
messageManager.initialize();
}, []);
// 【新架构】使用focus刷新hook从ChatScreen返回时自动刷新未读数
useMessageListRefresh();
// 同步未读数到userStore用于TabBar角标显示
@@ -666,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>
@@ -840,25 +836,25 @@ export const MessageListScreen: React.FC = () => {
<View style={styles.chatArea}>
{showNotifications ? (
// 显示系统通知页面
<NotificationsScreen onBack={() => setShowNotifications(false)} />
(<NotificationsScreen onBack={stableCloseNotifications} />)
) : selectedConversation ? (
// 显示选中会话的聊天内容
<ChatScreen
(<ChatScreen
isEmbedded
embeddedConversationId={String(selectedConversation.id)}
embeddedIsGroupChat={selectedConversation.type === 'group'}
embeddedGroupId={selectedConversation.group ? String(selectedConversation.group.id) : undefined}
embeddedGroupName={selectedConversation.group?.name}
onEmbeddedBack={() => setSelectedConversation(null)}
/>
onEmbeddedBack={stableClearSelectedConversation}
/>)
) : (
// 默认占位符
<View style={styles.chatPlaceholder}>
(<View style={styles.chatPlaceholder}>
<View style={styles.chatPlaceholderContent}>
<MaterialCommunityIcons name="message-text-outline" size={64} color={colors.chat.iconMuted} />
<Text style={styles.chatPlaceholderText}></Text>
</View>
</View>
</View>)
)}
</View>
</View>
@@ -871,7 +867,7 @@ export const MessageListScreen: React.FC = () => {
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{showNotifications ? (
// 显示系统通知页面,传入 onBack 回调
<NotificationsScreen onBack={() => setShowNotifications(false)} />
(<NotificationsScreen onBack={stableCloseNotifications} />)
) : isSearchMode ? (
renderSearchMode()
) : isWideScreen ? (
@@ -882,7 +878,7 @@ export const MessageListScreen: React.FC = () => {
renderConversationList()
)}
{renderActionMenu()}
<QRCodeScanner visible={scannerVisible} onClose={() => setScannerVisible(false)} />
<QRCodeScanner visible={scannerVisible} onClose={stableCloseScanner} />
</SafeAreaView>
);
};
@@ -1046,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

@@ -0,0 +1,315 @@
/**
* MessageSearchScreen 聊天记录搜索页
* 在单个会话中搜索消息记录,基于本地 SQLite 缓存
*/
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import {
View,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import {
spacing,
fontSizes,
useAppColors,
type AppColors,
} from '../../theme';
import { CachedMessage } from '../../database/types';
import { extractTextFromSegments, UserDTO } from '../../types/dto';
import { messageRepository } from '../../database';
import { userManager } from '../../stores/user';
import { useAuthStore } from '../../stores';
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';
const PAGE_SIZE = 50;
interface SenderInfo {
nickname: string;
avatar?: string;
}
export const MessageSearchScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createMessageSearchStyles(colors), [colors]);
const router = useRouter();
const currentUserId = useAuthStore(state => state.currentUser?.id);
const raw = useLocalSearchParams<{
conversationId?: string;
conversationName?: string;
isGroupChat?: string;
}>();
const conversationId = raw.conversationId ?? '';
const conversationName = raw.conversationName ?? '聊天';
const isGroupChat = raw.isGroupChat === '1';
const [keyword, setKeyword] = useState('');
const [results, setResults] = useState<CachedMessage[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [searched, setSearched] = useState(false);
const senderCache = useRef<Map<string, SenderInfo>>(new Map());
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const doSearch = useCallback(async (text: string, offset = 0) => {
if (!text.trim() || !conversationId) return;
setLoading(true);
try {
const msgs = await messageRepository.searchByConversation(
conversationId,
text.trim(),
PAGE_SIZE,
offset,
);
if (offset === 0) {
setResults(msgs);
} else {
setResults(prev => [...prev, ...msgs]);
}
setHasMore(msgs.length >= PAGE_SIZE);
setSearched(true);
// prefetch sender info (skip system sender 10000)
const senderIds = new Set(msgs.map(m => m.senderId).filter(id => id !== '10000'));
for (const sid of senderIds) {
if (!senderCache.current.has(sid)) {
try {
const u = await userManager.getUserById(sid);
if (u) {
senderCache.current.set(sid, {
nickname: u.nickname || u.username || sid,
avatar: u.avatar,
});
}
} catch {
senderCache.current.set(sid, { nickname: sid });
}
}
}
// system sender cache entry
senderCache.current.set('10000', { nickname: '系统通知' });
} catch (e) {
console.error('搜索消息失败:', e);
} finally {
setLoading(false);
}
}, [conversationId]);
const handleTextChanged = useCallback((text: string) => {
setKeyword(text);
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!text.trim()) {
setResults([]);
setSearched(false);
setHasMore(false);
return;
}
debounceRef.current = setTimeout(() => {
doSearch(text, 0);
}, 300);
}, [doSearch]);
const handleSubmit = useCallback(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (keyword.trim()) {
doSearch(keyword, 0);
}
}, [keyword, doSearch]);
const handleEndReached = useCallback(() => {
if (!loading && hasMore && keyword.trim()) {
doSearch(keyword, results.length);
}
}, [loading, hasMore, keyword, results.length, doSearch]);
const handlePressResult = useCallback((msg: CachedMessage) => {
router.push(
hrefs.hrefChat({
conversationId,
isGroupChat,
scrollToSeq: msg.seq,
}) as any,
);
}, [router, conversationId, isGroupChat]);
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
const getSender = useCallback((senderId: string): SenderInfo => {
if (senderCache.current.has(senderId)) {
return senderCache.current.get(senderId)!;
}
return { nickname: senderId };
}, []);
const renderItem = useCallback(({ item }: { item: CachedMessage }) => {
const sender = getSender(item.senderId);
const isMe = item.senderId === currentUserId;
const displayText = extractTextFromSegments(item.segments) || item.content || '';
return (
<TouchableOpacity
style={styles.resultItem}
onPress={() => handlePressResult(item)}
activeOpacity={0.6}
>
<Avatar
source={sender.avatar ? { uri: sender.avatar } : undefined}
name={sender.nickname}
size={36}
/>
<View style={styles.resultContent}>
<View style={styles.resultHeader}>
<Text style={styles.senderName} numberOfLines={1}>
{isMe ? '我' : sender.nickname}
</Text>
<Text style={styles.resultTime}>
{formatTime(item.createdAt)}
</Text>
</View>
<HighlightText
text={displayText}
keyword={keyword}
style={styles.resultText}
highlightStyle={styles.highlightText}
/>
</View>
</TouchableOpacity>
);
}, [styles, keyword, currentUserId, getSender, handlePressResult]);
const ListEmptyComponent = useMemo(() => {
if (loading) {
return (
<View style={styles.emptyContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
);
}
if (!searched) {
return (
<View style={styles.emptyContainer}>
<EmptyState
icon="magnify"
title="搜索聊天记录"
description="输入关键词搜索聊天记录"
/>
</View>
);
}
return (
<View style={styles.emptyContainer}>
<EmptyState
icon="message-off-outline"
title="未找到相关消息"
description="换个关键词试试"
/>
</View>
);
}, [loading, searched, styles, colors]);
const ListFooterComponent = useMemo(() => {
if (loading && results.length > 0) {
return (
<View style={styles.footerLoader}>
<ActivityIndicator size="small" color={colors.primary.main} />
</View>
);
}
return null;
}, [loading, results.length, styles, colors]);
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* 搜索顶栏(与 HomeScreen 搜索页一致的统一组件) */}
<SearchHeader
value={keyword}
onChangeText={handleTextChanged}
onSubmit={handleSubmit}
onCancel={() => router.back()}
placeholder={`搜索 ${conversationName} 的聊天记录`}
compact
/>
<FlashList
data={results}
renderItem={renderItem}
keyExtractor={item => item.id}
onEndReached={handleEndReached}
onEndReachedThreshold={0.3}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
keyboardShouldPersistTaps="handled"
/>
</SafeAreaView>
);
};
function createMessageSearchStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
resultItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
backgroundColor: colors.background.paper,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
},
resultContent: {
flex: 1,
marginLeft: spacing.md,
},
resultHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 2,
},
senderName: {
fontSize: fontSizes.sm,
fontWeight: '500',
color: colors.text.primary,
flex: 1,
marginRight: spacing.sm,
},
resultTime: {
fontSize: fontSizes.xs,
color: colors.text.hint,
},
resultText: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
lineHeight: fontSizes.sm * 1.4,
},
highlightText: {
color: colors.primary.main,
fontWeight: '600',
},
emptyContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingTop: spacing.xl * 3,
},
footerLoader: {
paddingVertical: spacing.md,
alignItems: 'center',
},
});
}

View File

@@ -26,7 +26,7 @@ import {
} from 'react-native';
import { FlashList, ListRenderItem } from '@shopify/flash-list';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useIsFocused } from '@react-navigation/native';
import { useIsFocused } from "expo-router/react-navigation";
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { spacing, borderRadius, useAppColors, useResolvedColorScheme, type AppColors } from '../../theme';
@@ -38,7 +38,7 @@ import { SystemMessageItem } from '../../components/business';
import { Text, ResponsiveContainer, AppBackButton } from '../../components/common';
import { useCursorPagination } from '../../hooks/useCursorPagination';
import * as hrefs from '../../navigation/hrefs';
import { useMessageManagerSystemUnreadCount } from '../../stores';
import { useMessageManagerSystemUnreadCount, routePayloadCache } from '../../stores';
import { messageManager } from '../../stores/message';
const MESSAGE_TYPES = [
@@ -331,8 +331,10 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
router.push(hrefs.hrefUserProfile(extra_data.actor_id_str));
}
} else if (system_type === 'group_join_apply') {
routePayloadCache.stashSystemMessage(message);
router.push(hrefs.hrefGroupRequestDetail(message));
} else if (system_type === 'group_invite') {
routePayloadCache.stashSystemMessage(message);
router.push(hrefs.hrefGroupInviteDetail(message));
}
// 其他类型暂不处理跳转

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('错误', '清空聊天记录失败');
@@ -145,8 +147,11 @@ const PrivateChatInfoScreen: React.FC = () => {
// 查找聊天记录
const handleSearchMessages = () => {
// TODO: 实现聊天记录搜索功能
Alert.alert('提示', '聊天记录搜索功能开发中');
router.push(hrefs.hrefMessageSearch({
conversationId,
conversationName: userName || user?.nickname || '私聊',
isGroupChat: false,
}));
};
// 查看用户资料
@@ -256,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
@@ -291,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>
);
@@ -345,7 +344,7 @@ const PrivateChatInfoScreen: React.FC = () => {
>
<Avatar
source={displayUser.avatar}
size={80}
size={72}
name={displayUser.nickname || ''}
/>
<View style={styles.userInfo}>
@@ -358,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>
@@ -421,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',
@@ -444,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,
},
@@ -462,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

@@ -374,7 +374,7 @@ export const GroupInfoPanel: React.FC<GroupInfoPanelProps> = ({
function createGroupInfoPanelStyles(colors: AppColors) {
return StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
zIndex: 100,
},

View File

@@ -379,7 +379,7 @@ export const GroupInfoPanel: React.FC<GroupInfoPanelProps> = ({
function createGroupInfoPanelStyles(colors: AppColors) {
return StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
backgroundColor: 'rgba(0, 0, 0, 0.35)',
zIndex: 100,
},

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;
@@ -107,6 +114,24 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
const isPureImageMessage =
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
@@ -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);
// 首先尝试通过 ID 查找
if (!replySegment) return undefined;
const replyData = replySegment.data as { id: string; seq?: number };
const replyId = String(replyData.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}
@@ -431,18 +468,46 @@ 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

@@ -1,317 +0,0 @@
/**
* 消息气泡样式工具
* 参考 Element X 设计,实现动态圆角和现代化气泡样式
*/
import { StyleSheet, ViewStyle, TextStyle } from 'react-native';
import { spacing, type AppColors } from '../../../../theme';
import { useChatSettingsStore } from '../../../../stores/settings';
// 默认圆角,但会从 store 读取
export const BUBBLE_RADIUS = 16;
// 获取动态圆角值
export function useBubbleRadius(): number {
return useChatSettingsStore((s) => s.messageRadius);
}
export function getBubbleColors(colors: AppColors, outgoingBubbleColor?: string) {
return {
outgoing: {
background: outgoingBubbleColor || colors.chat.bubbleOutgoing,
text: colors.chat.textPrimary,
},
incoming: {
background: colors.chat.bubbleIncoming,
text: colors.chat.textPrimary,
},
replyHighlight: {
background: colors.chat.replyTint,
borderLeft: colors.chat.replyBorder,
},
};
}
export type MessageGroupPosition = 'single' | 'first' | 'middle' | 'last';
// 使用动态圆角的 hook
export function useBubbleBorderRadius(isMe: boolean, position: MessageGroupPosition): ViewStyle {
const radius = useBubbleRadius();
if (isMe) {
switch (position) {
case 'single':
return {
borderTopLeftRadius: radius,
borderTopRightRadius: radius,
borderBottomLeftRadius: radius,
borderBottomRightRadius: 4,
};
case 'first':
return {
borderTopLeftRadius: radius,
borderTopRightRadius: radius,
borderBottomLeftRadius: radius,
borderBottomRightRadius: 4,
};
case 'middle':
return {
borderTopLeftRadius: radius,
borderTopRightRadius: 4,
borderBottomLeftRadius: radius,
borderBottomRightRadius: 4,
};
case 'last':
return {
borderTopLeftRadius: radius,
borderTopRightRadius: 4,
borderBottomLeftRadius: radius,
borderBottomRightRadius: radius,
};
}
} else {
switch (position) {
case 'single':
return {
borderTopLeftRadius: 4,
borderTopRightRadius: radius,
borderBottomLeftRadius: radius,
borderBottomRightRadius: radius,
};
case 'first':
return {
borderTopLeftRadius: 4,
borderTopRightRadius: radius,
borderBottomLeftRadius: 4,
borderBottomRightRadius: radius,
};
case 'middle':
return {
borderTopLeftRadius: 4,
borderTopRightRadius: radius,
borderBottomLeftRadius: 4,
borderBottomRightRadius: radius,
};
case 'last':
return {
borderTopLeftRadius: radius,
borderTopRightRadius: radius,
borderBottomLeftRadius: radius,
borderBottomRightRadius: radius,
};
}
}
}
// 保持原有的静态函数用于兼容
export const getBubbleBorderRadius = (isMe: boolean, position: MessageGroupPosition): ViewStyle => {
const radius = BUBBLE_RADIUS;
if (isMe) {
switch (position) {
case 'single':
return {
borderTopLeftRadius: radius,
borderTopRightRadius: radius,
borderBottomLeftRadius: radius,
borderBottomRightRadius: 4,
};
case 'first':
return {
borderTopLeftRadius: radius,
borderTopRightRadius: radius,
borderBottomLeftRadius: radius,
borderBottomRightRadius: 4,
};
case 'middle':
return {
borderTopLeftRadius: radius,
borderTopRightRadius: 4,
borderBottomLeftRadius: radius,
borderBottomRightRadius: 4,
};
case 'last':
return {
borderTopLeftRadius: radius,
borderTopRightRadius: 4,
borderBottomLeftRadius: radius,
borderBottomRightRadius: radius,
};
}
} else {
switch (position) {
case 'single':
return {
borderTopLeftRadius: 4,
borderTopRightRadius: radius,
borderBottomLeftRadius: radius,
borderBottomRightRadius: radius,
};
case 'first':
return {
borderTopLeftRadius: 4,
borderTopRightRadius: radius,
borderBottomLeftRadius: 4,
borderBottomRightRadius: radius,
};
case 'middle':
return {
borderTopLeftRadius: 4,
borderTopRightRadius: radius,
borderBottomLeftRadius: 4,
borderBottomRightRadius: radius,
};
case 'last':
return {
borderTopLeftRadius: radius,
borderTopRightRadius: radius,
borderBottomLeftRadius: radius,
borderBottomRightRadius: radius,
};
}
}
};
export const getMessageGroupPosition = (
index: number,
messages: Array<{ sender_id: string }>,
currentUserId: string
): MessageGroupPosition => {
const currentMessage = messages[index];
if (!currentMessage) return 'single';
const prevMessage = index > 0 ? messages[index - 1] : null;
const nextMessage = index < messages.length - 1 ? messages[index + 1] : null;
const isSameSenderAsPrev = prevMessage && prevMessage.sender_id === currentMessage.sender_id;
const isSameSenderAsNext = nextMessage && nextMessage.sender_id === currentMessage.sender_id;
if (!isSameSenderAsPrev && !isSameSenderAsNext) {
return 'single';
} else if (!isSameSenderAsPrev && isSameSenderAsNext) {
return 'first';
} else if (isSameSenderAsPrev && isSameSenderAsNext) {
return 'middle';
} else {
return 'last';
}
};
export const shouldShowSenderInfo = (
index: number,
messages: Array<{ sender_id: string }>
): boolean => {
if (index === 0) return true;
const currentMessage = messages[index];
const prevMessage = messages[index - 1];
return prevMessage.sender_id !== currentMessage.sender_id;
};
export const getBubbleBaseStyle = (isMe: boolean, colors: AppColors, outgoingBubbleColor?: string): ViewStyle => {
const bc = getBubbleColors(colors, outgoingBubbleColor);
return {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 4,
minWidth: 60,
maxWidth: '75%',
backgroundColor: isMe ? bc.outgoing.background : bc.incoming.background,
shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 2,
elevation: 2,
};
};
// 使用动态字号的 hook
export function useChatFontSize(): number {
return useChatSettingsStore((s) => s.fontSize);
}
export const getBubbleTextStyle = (isMe: boolean, colors: AppColors, customFontSize?: number): TextStyle => {
const bc = getBubbleColors(colors);
const fontSize = customFontSize ?? 16;
return {
color: isMe ? bc.outgoing.text : bc.incoming.text,
fontSize,
lineHeight: Math.round(fontSize * 1.4),
letterSpacing: 0.2,
fontWeight: '400',
};
};
export function createBubbleStyles(colors: AppColors, customFontSize?: number, outgoingBubbleColor?: string) {
const bc = getBubbleColors(colors, outgoingBubbleColor);
const fontSize = customFontSize ?? 16;
return StyleSheet.create({
bubble: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 4,
minWidth: 60,
},
outgoing: {
backgroundColor: bc.outgoing.background,
},
incoming: {
backgroundColor: bc.incoming.background,
},
text: {
fontSize,
lineHeight: Math.round(fontSize * 1.4),
letterSpacing: 0.2,
fontWeight: '400',
},
shadow: {
shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 2,
elevation: 2,
},
longPressShadow: {
shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.12,
shadowRadius: 8,
elevation: 6,
},
replyHighlight: {
borderLeftWidth: 3,
borderLeftColor: colors.primary.main,
backgroundColor: bc.replyHighlight.background,
},
recalled: {
backgroundColor: colors.chat.surfaceMuted,
borderWidth: 1,
borderColor: colors.chat.border,
borderStyle: 'dashed',
borderRadius: 12,
},
systemNotice: {
alignItems: 'center',
marginVertical: spacing.sm,
},
systemNoticeText: {
fontSize: 12,
color: colors.chat.textSecondary,
backgroundColor: colors.chat.surfaceMuted,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: 12,
overflow: 'hidden',
},
});
}
export default {
BUBBLE_RADIUS,
getBubbleColors,
getBubbleBorderRadius,
getMessageGroupPosition,
shouldShowSenderInfo,
getBubbleBaseStyle,
getBubbleTextStyle,
createBubbleStyles,
};

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