Compare commits

52 Commits

Author SHA1 Message Date
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
147 changed files with 10661 additions and 7136 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 node_modules
# Source control
.git .git
.gitignore
# Expo caches
.expo .expo
.expo-shared
# Build artifacts
dist dist
dist-web dist-web
dist-android dist-android
dist-ios
dist-android-update.zip dist-android-update.zip
dist-ios-update.zip
android android
ios
web-build
# Native credentials (never ship into image)
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
# Misc
screenshots screenshots
*.log *.log
.vscode
.idea
.DS_Store
*.swp
*.swo
coverage
__tests__

View File

@@ -21,17 +21,18 @@ on:
env: env:
REGISTRY: code.littlelan.cn REGISTRY: code.littlelan.cn
IMAGE_NAME: carrot_bbs/frontend-web IMAGE_NAME: carrot_bbs/frontend-web
OTA_PLATFORM_ANDROID: android
OTA_PLATFORM_IOS: ios
OTA_PUBLISH_URL: https://updates.littlelan.cn/admin/publish OTA_PUBLISH_URL: https://updates.littlelan.cn/admin/publish
OTA_MANIFEST_URL: https://updates.littlelan.cn/api/manifest OTA_MANIFEST_URL: https://updates.littlelan.cn/api/manifest
jobs: jobs:
ota-android: ota:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_ota == 'true') if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_ota == 'true')
permissions: permissions:
contents: read contents: read
strategy:
matrix:
platform: [android, ios]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -40,13 +41,6 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '25.6.1' 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 - name: Install dependencies
run: npm ci run: npm ci
@@ -54,121 +48,48 @@ jobs:
- name: Resolve runtime version - name: Resolve runtime version
id: runtime id: runtime
run: | run: |
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")" RUNTIME_VERSION="$(node -p "require('./app.json').expo.runtimeVersion")"
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT" echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}" echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
- name: Export Android update bundle - name: Export update bundle
run: | run: |
rm -rf dist-android dist-android-update.zip rm -rf dist-${{ matrix.platform }} dist-${{ matrix.platform }}-update.zip
npx expo export --platform android --output-dir dist-android npx expo export --platform ${{ matrix.platform }} --output-dir dist-${{ matrix.platform }}
npx expo config --type public --json > dist-android/expoConfig.json npx expo config --type public --json > dist-${{ matrix.platform }}/expoConfig.json
- name: Archive Android update bundle - name: Archive update bundle
run: | run: |
python - <<'PY' python -c "
import os import os, zipfile
import zipfile p = '${{ matrix.platform }}'
dist = f'dist-{p}'
with zipfile.ZipFile('dist-android-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf: with zipfile.ZipFile(f'dist-{p}-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
for root, _, files in os.walk('dist-android'): for root, _, files in os.walk(dist):
for name in files: for name in files:
src = os.path.join(root, name) src = os.path.join(root, name)
arc = os.path.relpath(src, 'dist-android') arc = os.path.relpath(src, dist)
zf.write(src, arc) zf.write(src, arc)
PY "
- name: Publish OTA - name: Publish OTA
env: env:
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }} OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
run: | run: |
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1) 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 "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
-H "Content-Type: application/zip" \ -H "Content-Type: application/zip" \
--data-binary @"dist-android-update.zip" --data-binary @"dist-${{ matrix.platform }}-update.zip"
- name: Verify OTA manifest - name: Verify OTA manifest
run: | run: |
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \ REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
-H "expo-platform: ${OTA_PLATFORM_ANDROID}" \ -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-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-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \ -H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
-H "expo-protocol-version: 1" \ -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])")" | 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 "Remote bundle: ${REMOTE}"
echo "Local bundle: ${LOCAL}" echo "Local bundle: ${LOCAL}"
test "${REMOTE}" = "${LOCAL}" test "${REMOTE}" = "${LOCAL}"
@@ -178,12 +99,9 @@ jobs:
container: container:
image: reactnativecommunity/react-native-android:latest image: reactnativecommunity/react-native-android:latest
env: 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" GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=false -Dorg.gradle.workers.max=1"
_JAVA_OPTIONS: "-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m" NODE_OPTIONS: "--max-old-space-size=2048"
NODE_OPTIONS: "--max-old-space-size=8192"
NODE_ENV: "production" NODE_ENV: "production"
NDK_NUM_JOBS: "4"
CMAKE_BUILD_PARALLEL_LEVEL: "4"
GRADLE_USER_HOME: /root/.gradle GRADLE_USER_HOME: /root/.gradle
permissions: permissions:
contents: read contents: read
@@ -201,86 +119,25 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '25.6.1' 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 - name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci run: npm ci
- name: Generate Android native project - name: Generate Android native project
run: npx expo prebuild --platform android run: npx expo prebuild --platform android
- name: Switch Gradle distribution to Tencent mirror - name: Configure Gradle with signing
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
run: | run: |
cd android cd android
# Update settings.gradle with China Maven mirrors # Decode Android signing keystore
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > app/withyou-release-key.keystore
# Update settings.gradle
cat > settings.gradle << 'SETTINGS_EOF' cat > settings.gradle << 'SETTINGS_EOF'
pluginManagement { pluginManagement {
repositories { repositories {
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } maven { url 'https://developer.huawei.com/repo/' }
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() google()
mavenCentral() mavenCentral()
gradlePluginPortal() gradlePluginPortal()
@@ -309,6 +166,15 @@ jobs:
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0" id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
} }
dependencyResolutionManagement {
repositories {
maven { url 'https://developer.huawei.com/repo/' }
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') { if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand() ex.autolinkLibrariesFromCommand()
@@ -326,21 +192,22 @@ jobs:
includeBuild(expoAutolinking.reactNativeGradlePlugin) includeBuild(expoAutolinking.reactNativeGradlePlugin)
SETTINGS_EOF SETTINGS_EOF
# Update build.gradle with China Maven mirrors # Update build.gradle
cat > build.gradle << 'BUILD_EOF' cat > build.gradle << 'BUILD_EOF'
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
repositories { repositories {
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } maven { url 'https://developer.huawei.com/repo/' }
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() google()
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath('com.android.tools.build:gradle') classpath('com.huawei.agconnect:agcp:1.9.1.301') {
exclude group: 'com.huawei.agconnect', module: 'agconnect-apms-plugin'
}
classpath('com.google.gms:google-services:4.4.4')
classpath('com.android.tools.build:gradle:8.12.0')
classpath('com.facebook.react:react-native-gradle-plugin') classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin') classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
} }
@@ -348,12 +215,10 @@ jobs:
allprojects { allprojects {
repositories { repositories {
maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://developer.huawei.com/repo/' }
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://www.jitpack.io' }
google() google()
mavenCentral() mavenCentral()
maven { url 'https://www.jitpack.io' }
} }
} }
@@ -361,13 +226,14 @@ jobs:
apply plugin: "com.facebook.react.rootproject" apply plugin: "com.facebook.react.rootproject"
BUILD_EOF BUILD_EOF
# Update gradle.properties # Update gradle.properties (without secrets for better cache hit rate)
cat > gradle.properties << 'PROPS_EOF' cat > gradle.properties << 'PROPS_EOF'
org.gradle.daemon=false org.gradle.daemon=false
org.gradle.parallel=true org.gradle.parallel=false
org.gradle.configureondemand=true org.gradle.configureondemand=false
org.gradle.workers.max=4 org.gradle.workers.max=1
org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
kotlin.daemon.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m
android.enableJetifier=false android.enableJetifier=false
android.useAndroidX=true android.useAndroidX=true
hermesEnabled=true hermesEnabled=true
@@ -379,11 +245,11 @@ jobs:
expo.webp.animated=false expo.webp.animated=false
ndkVersion=27.1.12297006 ndkVersion=27.1.12297006
expo.useLegacyPackaging=false expo.useLegacyPackaging=false
systemProp.org.gradle.internal.http.connectionTimeout=30000
systemProp.org.gradle.internal.http.socketTimeout=30000
PROPS_EOF PROPS_EOF
- name: Configure Gradle signing properties # Append signing properties (secrets appended, not cached)
run: |
cd android
cat >> gradle.properties << SIGNING_PROPS cat >> gradle.properties << SIGNING_PROPS
MYAPP_UPLOAD_STORE_FILE=withyou-release-key.keystore MYAPP_UPLOAD_STORE_FILE=withyou-release-key.keystore
MYAPP_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }} MYAPP_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
@@ -391,11 +257,15 @@ jobs:
MYAPP_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }} MYAPP_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}
SIGNING_PROPS SIGNING_PROPS
# Verify signing config in app/build.gradle
echo "=== app/build.gradle signing section ==="
grep -A 20 'signingConfigs' app/build.gradle || echo "signingConfigs not found"
- name: Build Android release APK (arm64 only) - name: Build Android release APK (arm64 only)
run: | run: |
cd android cd android
chmod +x gradlew 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 - name: Upload APK artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
@@ -406,7 +276,7 @@ jobs:
- name: Resolve runtime version - name: Resolve runtime version
id: runtime id: runtime
run: | run: |
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")" RUNTIME_VERSION="$(node -p "require('./app.json').expo.runtimeVersion")"
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT" echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}" echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
@@ -481,8 +351,6 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64 platforms: linux/amd64
provenance: false 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 - name: Show image tags
run: | run: |

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine AS builder FROM node:25-alpine AS builder
WORKDIR /app WORKDIR /app
@@ -6,7 +6,7 @@ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . 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 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 releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1'; const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1';
function getGitShortHash() { function getCommitCount() {
try { 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 { } 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) { function toManifestUrl(baseUrl, portOverride) {
const parsed = new URL(baseUrl); const parsed = new URL(baseUrl);
@@ -60,9 +66,9 @@ const filteredPlugins = isWeb
module.exports = { module.exports = {
...expo, ...expo,
name: isDevVariant ? `${expo.name} Dev` : expo.name, name: isDevVariant ? `${expo.name} Dev` : expo.name,
runtimeVersion: { // runtimeVersion 用 build numbercommit count + 偏移):单调递增、纯数字、与 version (语义版本) 解耦
policy: 'appVersion', // 字符串形式,等价于 policy: 'custom'
}, runtimeVersion: buildNumber,
updates: { updates: {
...(expo.updates || {}), ...(expo.updates || {}),
url: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl, url: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
@@ -71,12 +77,12 @@ module.exports = {
}, },
ios: { ios: {
...expo.ios, ...expo.ios,
buildNumber: gitBuildSuffix, buildNumber: buildNumber,
}, },
android: { android: {
...expo.android, ...expo.android,
package: 'cn.qczlit.withyou', package: 'cn.qczlit.withyou',
versionCode: parseInt(gitBuildSuffix, 16), versionCode: parseInt(buildNumber, 10),
}, },
// Web 端使用过滤后的插件 // Web 端使用过滤后的插件
plugins: filteredPlugins, plugins: filteredPlugins,
@@ -95,7 +101,8 @@ module.exports = {
// 配置 Web 端别名,避免加载原生模块 // 配置 Web 端别名,避免加载原生模块
babel: { babel: {
dangerouslyAddModulePathsToTranspile: [ dangerouslyAddModulePathsToTranspile: [
'react-native-webrtc', 'livekit-client',
'@livekit',
], ],
}, },
}, },

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "威友", "name": "威友",
"slug": "qojo", "slug": "qojo",
"version": "0.0.2", "version": "1.0.1",
"orientation": "default", "orientation": "default",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
@@ -16,9 +16,8 @@
"supportsTablet": true, "supportsTablet": true,
"infoPlist": { "infoPlist": {
"NSMicrophoneUsageDescription": "允许威友访问您的麦克风以进行语音通话", "NSMicrophoneUsageDescription": "允许威友访问您的麦克风以进行语音通话",
"NSCameraUsageDescription": "允许威友访问您的相机以进行视频通话",
"UIBackgroundModes": [ "UIBackgroundModes": [
"fetch",
"remote-notification",
"fetch", "fetch",
"remote-notification", "remote-notification",
"audio" "audio"
@@ -28,6 +27,7 @@
"bundleIdentifier": "cn.qczlit.weiyou" "bundleIdentifier": "cn.qczlit.weiyou"
}, },
"android": { "android": {
"googleServicesFile": "./google-services.json",
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#0570F9", "backgroundColor": "#0570F9",
"foregroundImage": "./assets/android-icon-foreground.png", "foregroundImage": "./assets/android-icon-foreground.png",
@@ -35,12 +35,11 @@
"monochromeImage": "./assets/android-icon-monochrome.png" "monochromeImage": "./assets/android-icon-monochrome.png"
}, },
"predictiveBackGestureEnabled": false, "predictiveBackGestureEnabled": false,
"jsEngine": "hermes",
"package": "cn.qczlit.withyou", "package": "cn.qczlit.withyou",
"versionCode": 7,
"permissions": [ "permissions": [
"VIBRATE", "VIBRATE",
"RECORD_AUDIO", "RECORD_AUDIO",
"CAMERA",
"RECEIVE_BOOT_COMPLETED", "RECEIVE_BOOT_COMPLETED",
"WAKE_LOCK", "WAKE_LOCK",
"READ_EXTERNAL_STORAGE", "READ_EXTERNAL_STORAGE",
@@ -53,7 +52,6 @@
"android.permission.READ_EXTERNAL_STORAGE", "android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE", "android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED", "android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
"android.permission.ACCESS_MEDIA_LOCATION",
"android.permission.READ_MEDIA_IMAGES", "android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO", "android.permission.READ_MEDIA_VIDEO",
"android.permission.READ_MEDIA_AUDIO", "android.permission.READ_MEDIA_AUDIO",
@@ -66,6 +64,8 @@
"favicon": "./assets/favicon.png" "favicon": "./assets/favicon.png"
}, },
"plugins": [ "plugins": [
"./plugins/withCmakeJobLimit",
"./plugins/withJcorePatch",
"./plugins/withSigning", "./plugins/withSigning",
"./plugins/withMainActivityConfigChange", "./plugins/withMainActivityConfigChange",
[ [
@@ -100,12 +100,13 @@
"minimumInterval": 15 "minimumInterval": 15
} }
], ],
"./plugins/withRemoveAutoStart",
[ [
"expo-media-library", "expo-media-library",
{ {
"photosPermission": "允许威友访问您的照片以发布内容", "photosPermission": "允许威友访问您的照片以发布内容",
"savePhotosPermission": "允许威友保存照片到您的相册", "savePhotosPermission": "允许威友保存照片到您的相册",
"isAccessMediaLocationEnabled": true "isAccessMediaLocationEnabled": false
} }
], ],
[ [
@@ -153,7 +154,21 @@
"channel": "developer-default" "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": { "extra": {
"eas": { "eas": {

View File

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

View File

@@ -17,8 +17,6 @@ export default function ProfileStackLayout() {
<Stack.Screen name="blocked-users" /> <Stack.Screen name="blocked-users" />
<Stack.Screen name="chat-settings" /> <Stack.Screen name="chat-settings" />
<Stack.Screen name="about" /> <Stack.Screen name="about" />
<Stack.Screen name="terms" />
<Stack.Screen name="privacy" />
<Stack.Screen name="verification" /> <Stack.Screen name="verification" />
<Stack.Screen name="data-storage" /> <Stack.Screen name="data-storage" />
<Stack.Screen name="privacy-settings" /> <Stack.Screen name="privacy-settings" />

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 { useEffect, useState } from 'react';
import { Platform } from 'react-native'; import { ActivityIndicator, View } from 'react-native';
import { Redirect } from 'expo-router'; import { Redirect } from 'expo-router';
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack'; import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
import { messageManager, useAuthStore } from '../../src/stores'; import { messageManager, useAuthStore } from '../../src/stores';
import { jpushService } from '../../src/services/notification/jpushService'; import { useRegisterPushDevice } from '../../src/hooks';
import { hrefAuthLogin } from '../../src/navigation/hrefs';
function getDeviceType(): 'ios' | 'android' | 'web' { import { useAppColors } from '../../src/theme';
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()}`;
}
}
export default function AppLayout() { export default function AppLayout() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
const userID = useAuthStore((s) => s.currentUser?.id); 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(() => { useEffect(() => {
messageManager.initialize(); messageManager.initialize();
}, []); }, []);
useEffect(() => { // 持久化状态显示已登录则立即渲染(后台静默校验)
if (!isAuthenticated || !userID) return; if (isAuthenticated) {
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" />;
}
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 { useEffect, useState } from 'react';
import { useWindowDimensions } from 'react-native'; import { ActivityIndicator, View, useWindowDimensions } from 'react-native';
import { Redirect } from 'expo-router'; import { Redirect } from 'expo-router';
import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell'; import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell';
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack'; import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
import { BREAKPOINTS } from '../../src/hooks'; import { BREAKPOINTS } from '../../src/hooks';
import { messageManager, useAuthStore } from '../../src/stores'; import { messageManager, useAuthStore } from '../../src/stores';
import { hrefAuthLogin } from '../../src/navigation/hrefs';
import { useAppColors } from '../../src/theme';
export default function AppLayoutWeb() { export default function AppLayoutWeb() {
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
const useDesktopShell = width >= BREAKPOINTS.desktop; 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(() => { useEffect(() => {
messageManager.initialize(); messageManager.initialize();
}, []); }, []);
if (!isAuthenticated) { // 持久化状态显示已登录则立即渲染(后台静默校验)
return <Redirect href="/login" />; if (isAuthenticated) {
}
if (useDesktopShell) { if (useDesktopShell) {
return <AppDesktopShell />; return <AppDesktopShell />;
} }
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

@@ -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 { AppState, AppStateStatus, Platform, View, ActivityIndicator } from 'react-native';
import { Stack, useRouter } from 'expo-router'; import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
@@ -10,8 +11,6 @@ import * as SystemUI from 'expo-system-ui';
import { useFonts } from 'expo-font'; import { useFonts } from 'expo-font';
import { registerNotificationPresentationHandler } from '@/services/notification'; import { registerNotificationPresentationHandler } from '@/services/notification';
import { api } from '@/services/core';
import { wsService } from '@/services/core';
import { EventSubscriber } from '../src/infrastructure/EventSubscriber'; import { EventSubscriber } from '../src/infrastructure/EventSubscriber';
import { import {
ThemeBootstrap, ThemeBootstrap,
@@ -23,10 +22,12 @@ import {
import AppPromptBar from '../src/components/common/AppPromptBar'; import AppPromptBar from '../src/components/common/AppPromptBar';
import AppDialogHost from '../src/components/common/AppDialogHost'; import AppDialogHost from '../src/components/common/AppDialogHost';
import { installAlertOverride } from '@/services/ui'; import { installAlertOverride } from '@/services/ui';
import { useAuthStore } from '../src/stores';
import { checkForAPKUpdate } from '@/services/platform'; import { checkForAPKUpdate } from '@/services/platform';
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call'; import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
import { jpushService } from '@/services/notification/jpushService'; import { jpushService } from '@/services/notification/jpushService';
import { callKeepService } from '@/services/callkeep';
import { callStore } from '@/stores/call';
import * as hrefs from '../src/navigation/hrefs';
registerNotificationPresentationHandler(); registerNotificationPresentationHandler();
@@ -100,32 +101,6 @@ function SystemChrome() {
return null; 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() { function NotificationBootstrap() {
const appState = useRef<AppStateStatus>(AppState.currentState); const appState = useRef<AppStateStatus>(AppState.currentState);
const permissionRequested = useRef(false); const permissionRequested = useRef(false);
@@ -138,6 +113,7 @@ function NotificationBootstrap() {
const { systemNotificationService } = await import('@/services/notification'); const { systemNotificationService } = await import('@/services/notification');
await systemNotificationService.initialize(); await systemNotificationService.initialize();
const { initBackgroundService } = await import('@/services/background'); const { initBackgroundService } = await import('@/services/background');
// 默认静默模式,不会注册后台任务,不会触发自启动
await initBackgroundService(); await initBackgroundService();
const subscription = AppState.addEventListener('change', (nextAppState) => { const subscription = AppState.addEventListener('change', (nextAppState) => {
@@ -165,22 +141,21 @@ function NotificationBootstrap() {
if (conversationId) { if (conversationId) {
const isGroup = conversationType === 'group'; const isGroup = conversationType === 'group';
const q = new URLSearchParams(); router.push(
if (isGroup) { hrefs.hrefChat({
q.set('isGroupChat', '1'); conversationId,
if (groupId) q.set('groupId', groupId); userId: isGroup ? undefined : senderId,
if (groupName) q.set('groupName', groupName); isGroupChat: isGroup,
if (groupAvatar) q.set('groupAvatar', groupAvatar); groupId: isGroup ? groupId : undefined,
} else if (senderId) { groupName: isGroup ? groupName : undefined,
q.set('userId', senderId); groupAvatar: isGroup ? groupAvatar : undefined,
} })
const qs = q.toString(); );
router.push(`/chat/${encodeURIComponent(conversationId)}${qs ? `?${qs}` : ''}`);
} else { } else {
router.push('/messages/notifications'); router.push(hrefs.hrefNotifications());
} }
} else { } else {
router.push('/messages/notifications'); router.push(hrefs.hrefNotifications());
} }
}); });
@@ -232,6 +207,37 @@ function APKUpdateBootstrap() {
return null; 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() { function ThemedStack() {
const router = useRouter(); const router = useRouter();
const resolved = useResolvedColorScheme(); const resolved = useResolvedColorScheme();
@@ -241,39 +247,16 @@ function ThemedStack() {
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} /> <StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
<SystemChrome /> <SystemChrome />
<EventSubscriber /> <EventSubscriber />
<SessionGate>
<NotificationBootstrap /> <NotificationBootstrap />
<APKUpdateBootstrap /> <APKUpdateBootstrap />
<CallKeepBootstrap />
<Stack screenOptions={{ headerShown: false }}> <Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" options={{ headerShown: false }} /> <Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} /> <Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(app)" options={{ headerShown: false }} /> <Stack.Screen name="(app)" options={{ headerShown: false }} />
<Stack.Screen <Stack.Screen name="privacy" options={{ headerShown: false }} />
name="terms" <Stack.Screen name="terms" options={{ headerShown: false }} />
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> </Stack>
</SessionGate>
</> </>
); );
} }

View File

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

View File

@@ -7,10 +7,6 @@ const config = getDefaultConfig(__dirname);
// Add wasm asset support // Add wasm asset support
config.resolver.assetExts.push('wasm'); 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/) // Support TypeScript path aliases (@/ -> ./src/)
config.resolver.alias = { config.resolver.alias = {
'@': path.resolve(__dirname, 'src'), '@': path.resolve(__dirname, 'src'),
@@ -36,7 +32,6 @@ const webShimPaths = [
// These packages import requireNativeComponent directly from 'react-native' // These packages import requireNativeComponent directly from 'react-native'
// (which resolves to react-native-web on web) and don't have web implementations. // (which resolves to react-native-web on web) and don't have web implementations.
const webPackageShims = { const webPackageShims = {
'react-native-webrtc': path.resolve(__dirname, 'web-shims/react-native-webrtc/index.js'),
}; };
const originalResolveRequest = config.resolver.resolveRequest; const originalResolveRequest = config.resolver.resolveRequest;

6673
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "with_you", "name": "with_you",
"version": "0.0.2", "version": "1.0.1",
"main": "expo-router/entry", "main": "expo-router/entry",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
@@ -14,65 +14,78 @@
"ios:submit": "eas submit --platform ios --profile production" "ios:submit": "eas submit --platform ios --profile production"
}, },
"dependencies": { "dependencies": {
"@expo/ui": "~56.0.17",
"@expo/vector-icons": "^15.1.1", "@expo/vector-icons": "^15.1.1",
"@react-native-async-storage/async-storage": "^2.2.0", "@livekit/react-native": "^2.11.0",
"@react-navigation/bottom-tabs": "^7.15.2", "@livekit/react-native-webrtc": "^144.1.0",
"@react-navigation/material-top-tabs": "^7.4.16", "@react-native-async-storage/async-storage": "^3.1.1",
"@react-navigation/native": "^7.1.31",
"@react-navigation/native-stack": "^7.14.2",
"@shopify/flash-list": "^2.3.1", "@shopify/flash-list": "^2.3.1",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.100.14",
"axios": "^1.13.6", "@types/react": "~19.2.14",
"date-fns": "^4.1.0", "axios": "^1.16.1",
"expo": "~55.0.4", "date-fns": "^4.4.0",
"expo-background-task": "~55.0.10", "expo": "~56.0.11",
"expo-camera": "^55.0.10", "expo-background-task": "~56.0.18",
"expo-constants": "~55.0.7", "expo-callkit-telecom": "^0.3.9",
"expo-dev-client": "~55.0.10", "expo-camera": "~56.0.8",
"expo-file-system": "~55.0.10", "expo-constants": "~56.0.18",
"expo-font": "~55.0.4", "expo-dev-client": "~56.0.20",
"expo-haptics": "~55.0.8", "expo-document-picker": "~56.0.4",
"expo-image": "^55.0.5", "expo-file-system": "~56.0.8",
"expo-image-picker": "^55.0.10", "expo-font": "~56.0.6",
"expo-intent-launcher": "~55.0.9", "expo-haptics": "~56.0.3",
"expo-linear-gradient": "^55.0.8", "expo-image": "~56.0.6",
"expo-media-library": "~55.0.9", "expo-image-picker": "~56.0.17",
"expo-notifications": "~55.0.0", "expo-intent-launcher": "~56.0.4",
"expo-router": "^55.0.4", "expo-linear-gradient": "~56.0.4",
"expo-sqlite": "~55.0.10", "expo-linking": "~56.0.14",
"expo-status-bar": "~55.0.4", "expo-media-library": "~56.0.7",
"expo-system-ui": "^55.0.9", "expo-notifications": "~56.0.17",
"expo-task-manager": "~55.0.9", "expo-router": "~56.2.10",
"expo-updates": "^55.0.12", "expo-splash-screen": "~56.0.10",
"expo-video": "^55.0.10", "expo-sqlite": "~56.0.5",
"jcore-react-native": "^2.3.5", "expo-status-bar": "~56.0.4",
"jpush-react-native": "^3.2.6", "expo-system-ui": "~56.0.5",
"katex": "^0.16.42", "expo-task-manager": "~56.0.18",
"markdown-it": "^14.1.1", "expo-updates": "~56.0.19",
"expo-video": "~56.1.3",
"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", "prismjs": "^1.30.0",
"react": "19.2.0", "react": "19.2.3",
"react-dom": "19.2.0", "react-dom": "19.2.3",
"react-native": "0.83.2", "react-native": "0.85.3",
"react-native-gesture-handler": "^2.30.0", "react-native-gesture-handler": "^3.0.0",
"react-native-pager-view": "^8.0.0", "react-native-pager-view": "^8.0.2",
"react-native-paper": "^5.15.0", "react-native-paper": "^5.15.3",
"react-native-reanimated": "^4.2.1", "react-native-reanimated": "~4.3.1",
"react-native-safe-area-context": "~5.6.2", "react-native-safe-area-context": "~5.8.0",
"react-native-screens": "~4.23.0", "react-native-screens": "4.25.2",
"react-native-share": "^12.2.6", "react-native-share": "^12.3.1",
"react-native-sse": "^1.2.1", "react-native-sse": "^1.2.1",
"react-native-web": "^0.21.0", "react-native-web": "^0.21.0",
"react-native-webrtc": "^124.0.7", "react-native-webview": "13.16.1",
"react-native-webview": "13.16.0", "react-native-worklets": "^0.8.3",
"react-native-worklets": "0.7.2", "zod": "^4.4.3",
"zod": "^4.3.6", "zustand": "^5.0.14"
"zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@react-native-community/cli": "^20.1.2", "@react-native-community/cli": "^20.1.3",
"@types/react": "~19.2.2", "@types/pako": "^2.0.4",
"@types/react": "~19.2.16",
"@types/react-native-vector-icons": "^6.4.18", "@types/react-native-vector-icons": "^6.4.18",
"typescript": "~5.9.2" "typescript": "~6.0.3"
}, },
"private": true "private": true,
"expo": {
"install": {
"exclude": [
"expo-image"
]
}
}
} }

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;

205
plugins/withHonorPush.js Normal file
View File

@@ -0,0 +1,205 @@
const {
withProjectBuildGradle,
withSettingsGradle,
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 (v5.9.0+ 必需)
// 4) 添加 Proguard 规则 (com.hihonor.push.** 保留 + 5 条 -keepattributes)
//
// 实现说明app/build.gradle 改动用 withDangerousMod 直接读写磁盘文件。
// 原因Expo SDK 56+ 的 withAppBuildGradle hook 链中,第二个以后的 plugin 写回
// modResults.contents 会被静默忽略,导致多个 vendor plugin 只能写入第一个的修改。
// 仓库settings.gradle / build.gradle改动用 withProjectBuildGradle / withSettingsGradle
// 没有这个问题。
const withHonorPush = (config, options = {}) => {
const {
jpushVersion = '6.1.0',
appId = '104562917',
} = options;
const honorRepoUrl = 'https://developer.hihonor.com/repo';
// 1. settings.gradle: 添加荣耀 Maven 仓库
config = withSettingsGradle(config, (config) => {
let contents = config.modResults.contents;
if (!contents.includes('developer.hihonor.com/repo')) {
const repoLine = ` maven { url '${honorRepoUrl}' }`;
const hasRepositoriesInPM = /pluginManagement\s*\{[\s\S]*?repositories\s*\{/.test(contents);
if (hasRepositoriesInPM) {
config.modResults.contents = contents.replace(
/(pluginManagement\s*\{[\s\S]*?repositories\s*\{)/,
`$1\n ${repoLine}`
);
} else if (contents.includes('pluginManagement')) {
config.modResults.contents = contents.replace(
/(pluginManagement\s*\{)/,
`$1\n repositories {\n gradlePluginPortal()\n google()\n mavenCentral()\n ${repoLine}\n }`
);
} else {
config.modResults.contents =
`pluginManagement {\n repositories {\n ${repoLine}\n }\n}\n\n` +
config.modResults.contents;
}
}
// Gradle 9+ layout
if (
!config.modResults.contents.match(
/dependencyResolutionManagement[\s\S]*developer\.hihonor\.com\/repo/
)
) {
const depMgmtPattern = /dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{/;
if (depMgmtPattern.test(config.modResults.contents)) {
config.modResults.contents = config.modResults.contents.replace(
depMgmtPattern,
`$&\n maven { url '${honorRepoUrl}' }`
);
}
}
return config;
});
// 2. 根 build.gradle: 添加荣耀 Maven 仓库到 buildscript.repositories
config = withProjectBuildGradle(config, (config) => {
let contents = config.modResults.contents;
if (!contents.includes('developer.hihonor.com/repo')) {
const bsRepoPattern = /(buildscript\s*\{[\s\S]*?repositories\s*\{)/;
if (bsRepoPattern.test(contents)) {
contents = contents.replace(
bsRepoPattern,
`$1\n maven { url '${honorRepoUrl}' }`
);
}
}
config.modResults.contents = contents;
return config;
});
// 3. app/build.gradle: 注入 AAR 依赖 + manifestPlaceholders (用 withDangerousMod 直接读文件)
config = withDangerousMod(config, [
'android',
async (config) => {
const appBuildGradlePath = path.join(
config.modRequest.platformProjectRoot,
'app',
'build.gradle'
);
const proguardFilePath = path.join(
config.modRequest.platformProjectRoot,
'app',
'proguard-rules.pro'
);
// --- 3a. 处理 build.gradle ---
if (fs.existsSync(appBuildGradlePath)) {
let gradle = fs.readFileSync(appBuildGradlePath, 'utf-8');
let changed = false;
// 注入 cn.jiguang.sdk.plugin:honor 依赖
if (!gradle.includes('cn.jiguang.sdk.plugin:honor')) {
const depsPattern = /dependencies\s*\{/;
if (depsPattern.test(gradle)) {
gradle = gradle.replace(
depsPattern,
`dependencies {\n implementation 'cn.jiguang.sdk.plugin:honor:${jpushVersion}'`
);
changed = true;
}
}
// 注入 manifestPlaceholders: HONOR_APPID
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')) {
// 没有 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('[withHonorPush] updated app/build.gradle (AAR + manifestPlaceholders)');
}
}
// --- 3b. 处理 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 { const {
withAppBuildGradle, withAppBuildGradle,
withProjectBuildGradle, withProjectBuildGradle,
withSettingsGradle,
withDangerousMod, withDangerousMod,
withAndroidManifest, withAndroidManifest,
} = require('@expo/config-plugins'); } = require('@expo/config-plugins');
@@ -8,7 +9,52 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const withHuaweiPush = (config, options = {}) => { 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 9+)
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)) {
config.modResults.contents = config.modResults.contents.replace(
depMgmtPattern,
`$&\n maven { url 'https://developer.huawei.com/repo/' }`
);
}
}
return config;
});
config = withProjectBuildGradle(config, (config) => { config = withProjectBuildGradle(config, (config) => {
const contents = config.modResults.contents; const contents = config.modResults.contents;
@@ -28,24 +74,72 @@ const withHuaweiPush = (config, options = {}) => {
config = withProjectBuildGradle(config, (config) => { config = withProjectBuildGradle(config, (config) => {
const contents = config.modResults.contents; 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('com.huawei.agconnect:agcp')) {
if (contents.includes('buildscript')) { if (contents.includes('buildscript')) {
const depsPattern = /(buildscript\s*\{[\s\S]*?dependencies\s*\{)/; const depsPattern = /(buildscript\s*\{[\s\S]*?dependencies\s*\{)/;
if (depsPattern.test(contents)) { if (depsPattern.test(contents)) {
config.modResults.contents = contents.replace( config.modResults.contents = contents.replace(
depsPattern, 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; return config;
}); });
config = withAppBuildGradle(config, (config) => { config = withAppBuildGradle(config, (config) => {
const contents = config.modResults.contents; 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')) { if (!contents.includes('com.huawei.agconnect')) {
const applyPluginPattern = /apply plugin:\s*['"]com\.google\.gms\.google-services['"]/; const applyPluginPattern = /apply plugin:\s*['"]com\.google\.gms\.google-services['"]/;
if (applyPluginPattern.test(contents)) { if (applyPluginPattern.test(contents)) {
@@ -96,6 +190,41 @@ const withHuaweiPush = (config, options = {}) => {
console.warn('[withHuaweiPush] agconnect-services.json not found at project root'); 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; return config;
}, },
]); ]);

View File

@@ -95,7 +95,7 @@ const withJPush = (config, options = {}) => {
config = withAppDelegate(config, (config) => { config = withAppDelegate(config, (config) => {
const { contents } = config.modResults; const { contents } = config.modResults;
if (contents.includes('JPush') || contents.includes('JCoreModule')) { if (contents.includes('JPush initialization') && contents.includes('JPUSHService.registerDeviceToken')) {
return config; return config;
} }
@@ -104,7 +104,8 @@ const withJPush = (config, options = {}) => {
if (isSwift) { if (isSwift) {
let newContents = contents; let newContents = contents;
if (!newContents.includes('import JPushRN')) { // Add UNUserNotifications import if needed
if (!newContents.includes('import UserNotifications')) {
if (newContents.includes('import UIKit')) { if (newContents.includes('import UIKit')) {
newContents = newContents.replace( newContents = newContents.replace(
/(import UIKit\n)/, /(import UIKit\n)/,
@@ -118,56 +119,62 @@ const withJPush = (config, options = {}) => {
} }
} }
// Swift: Add JPush init in application(_:didFinishLaunchingWithOptions:) // Add UNUserNotificationCenterDelegate conformance if needed
// Match various Swift signatures if (!newContents.includes('UNUserNotificationCenterDelegate')) {
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( newContents = newContents.replace(
pattern, /class AppDelegate: ExpoAppDelegate \{/,
`$&\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` 'class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate {'
); );
didInsertInit = true;
break;
}
} }
// Fallback: find the didFinishLaunchingWithOptions method start and insert after the opening brace // Swift: Add JPush init in application(_:didFinishLaunchingWithOptions:)
if (!didInsertInit && newContents.includes('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'); const lines = newContents.split('\n');
let foundDidFinish = false;
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('didFinishLaunchingWithOptions') && lines[i].includes('{')) { if (lines[i].includes('didFinishLaunchingWithOptions')) {
lines.splice(i + 1, 0, foundDidFinish = true;
' // JPush initialization', }
' #if DEBUG', // Insert after the line that contains the opening brace of this method
` JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)`, if (foundDidFinish && lines[i].includes('{')) {
' #else', const indent = lines[i].match(/^(\s*)/)[1];
` JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)`, const initLines = jpushInitBlock.split('\n').map(l => l ? indent + l.trimStart() : l);
' #endif', lines.splice(i + 1, 0, ...initLines);
' UNUserNotificationCenter.current().delegate = self'
);
newContents = lines.join('\n'); newContents = lines.join('\n');
didInsertInit = true;
break; 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 // Add delegate = self after init insertion if not present
if (didInsertInit && !newContents.includes('UNUserNotificationCenter.current().delegate')) { if (!newContents.includes('UNUserNotificationCenter.current().delegate')) {
newContents = newContents.replace( newContents = newContents.replace(
/(#endif\n)(\s+let delegate)/, /(#endif\n)(\s+let delegate)/,
`$1 UNUserNotificationCenter.current().delegate = self\n$2` `$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 = ` const jpushSwiftMethods = `
// JPush: Register for remote notifications // JPush: Register for remote notifications
override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
JPUSHService.registerDeviceToken(deviceToken) JPUSHService.registerDeviceToken(deviceToken)
@@ -195,12 +202,22 @@ const withJPush = (config, options = {}) => {
`; `;
if (!newContents.includes('JPUSHService.registerDeviceToken')) { if (!newContents.includes('JPUSHService.registerDeviceToken')) {
// Find the closing brace of the AppDelegate class // 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('}'); const lastBraceIndex = newContents.lastIndexOf('}');
if (lastBraceIndex !== -1) { if (lastBraceIndex !== -1) {
newContents = newContents.slice(0, lastBraceIndex) + jpushSwiftMethods + '\n' + newContents.slice(lastBraceIndex); newContents = newContents.slice(0, lastBraceIndex) + jpushSwiftMethods + '\n' + newContents.slice(lastBraceIndex);
} }
} }
}
config.modResults.contents = newContents; config.modResults.contents = newContents;
} else { } else {

43
plugins/withJcorePatch.js Normal file
View File

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

144
plugins/withOppoPush.js Normal file
View File

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

View File

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

View File

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

141
plugins/withVivoPush.js Normal file
View File

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

133
plugins/withXiaomiPush.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,7 +61,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
return ( return (
<Text numberOfLines={numberOfLines} selectable style={textStyle}> <Text numberOfLines={numberOfLines} selectable style={textStyle}>
{highlightKeyword && content ? ( {highlightKeyword && content ? (
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} /> <HighlightText text={content} keyword={highlightKeyword} highlightStyle={highlightStyle} />
) : ( ) : (
content || '' content || ''
)} )}
@@ -85,7 +85,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
elements.push( elements.push(
<Text key={key} selectable style={textStyle}> <Text key={key} selectable style={textStyle}>
{highlightKeyword ? ( {highlightKeyword ? (
<HighlightText text={text} keyword={highlightKeyword} style={highlightStyle} /> <HighlightText text={text} keyword={highlightKeyword} highlightStyle={highlightStyle} />
) : ( ) : (
text text
)} )}
@@ -125,7 +125,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
return ( return (
<Text numberOfLines={numberOfLines} selectable style={textStyle}> <Text numberOfLines={numberOfLines} selectable style={textStyle}>
{highlightKeyword && content ? ( {highlightKeyword && content ? (
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} /> <HighlightText text={content} keyword={highlightKeyword} highlightStyle={highlightStyle} />
) : ( ) : (
content || '' 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 { import {
View, View,
TextInput, TextInput,
@@ -51,7 +51,12 @@ interface PostMentionInputProps {
returnKeyType?: 'default' | 'send' | 'done'; returnKeyType?: 'default' | 'send' | 'done';
} }
const PostMentionInput: React.FC<PostMentionInputProps> = ({ export interface PostMentionInputHandle {
focus: () => void;
blur: () => void;
}
const PostMentionInput = forwardRef<PostMentionInputHandle, PostMentionInputProps>(({
value, value,
onChangeText, onChangeText,
onSegmentsChange, onSegmentsChange,
@@ -63,7 +68,7 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
onPostRefPasted, onPostRefPasted,
onSubmitEditing, onSubmitEditing,
returnKeyType = 'default', returnKeyType = 'default',
}) => { }, ref) => {
const colors = useAppColors(); const colors = useAppColors();
const currentUser = useAuthStore(s => s.currentUser); const currentUser = useAuthStore(s => s.currentUser);
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]); const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
@@ -75,6 +80,21 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
const [postSearchTimer, setPostSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null); const [postSearchTimer, setPostSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
// 暴露 focus / blur 方法给父组件,用于在模式切换(如点击回复)后自动唤起键盘
useImperativeHandle(ref, () => ({
focus: () => {
// 展开动画/挂载需要时间,延迟一帧后再聚焦,避免在折叠态未真正展开时调用无效
requestAnimationFrame(() => {
setTimeout(() => {
inputRef.current?.focus();
}, 50);
});
},
blur: () => {
inputRef.current?.blur();
},
}), []);
useEffect(() => { useEffect(() => {
if (!loaded && currentUser?.id) { if (!loaded && currentUser?.id) {
loadFollowing(); loadFollowing();
@@ -361,7 +381,7 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
)} )}
</View> </View>
); );
}; });
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {

View File

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

View File

@@ -330,4 +330,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, minWidth: 160,
backgroundColor: colors.background.paper, backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg, borderRadius: borderRadius.lg,
...shadows.lg,
borderWidth: StyleSheet.hairlineWidth, borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.divider, borderColor: colors.divider,
overflow: 'hidden', overflow: 'hidden',

View File

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

View File

@@ -24,6 +24,7 @@ export { default as VoteEditor } from './VoteEditor';
export { default as VotePreview } from './VotePreview'; export { default as VotePreview } from './VotePreview';
export { default as PostContentRenderer } from './PostContentRenderer'; export { default as PostContentRenderer } from './PostContentRenderer';
export { default as PostMentionInput } from './PostMentionInput'; export { default as PostMentionInput } from './PostMentionInput';
export type { PostMentionInputHandle } from './PostMentionInput';
export { default as BlockEditor } from './BlockEditor'; export { default as BlockEditor } from './BlockEditor';
export type { BlockEditorHandle } from './BlockEditor'; export type { BlockEditorHandle } from './BlockEditor';
export { default as ReportDialog } from './ReportDialog'; export { default as ReportDialog } from './ReportDialog';

View File

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

View File

@@ -1,13 +1,26 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { import {
View, View,
Text, Text,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
Image,
StatusBar, StatusBar,
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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 { callStore } from '../../stores/call';
import { liveKitService } from '@/services/livekit';
type VideoTrack = LocalVideoTrack | RemoteVideoTrack;
const formatDuration = (seconds: number): string => { const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
@@ -19,80 +32,253 @@ const CallScreen: React.FC = () => {
const currentCall = callStore((s) => s.currentCall); const currentCall = callStore((s) => s.currentCall);
const callDuration = callStore((s) => s.callDuration); const callDuration = callStore((s) => s.callDuration);
const endCall = callStore((s) => s.endCall); 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 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; if (!currentCall || isMinimized) return null;
const getStatusText = (): string => { const getStatusText = (): string => {
switch (currentCall.status) { switch (currentCall.status) {
case 'calling':
return '正在等待对方接听...';
case 'ringing': case 'ringing':
return '通话功能暂不支持网页端'; return '来电响铃中...';
case 'connecting': case 'connecting':
return '通话功能暂不支持网页端'; return '连接中...';
case 'connected': case 'connected':
return '通话功能暂不支持网页端'; return formatDuration(callDuration);
case 'reconnecting':
return '网络重连中...';
case 'ended': case 'ended':
return '通话已结束'; return '通话已结束';
case 'failed':
return '连接失败';
default: default:
return ''; return '';
} }
}; };
const handleEndCall = () => { const showRemoteVideo = !!remoteVideoTrack;
endCall('hangup'); const showLocalVideo = currentCall?.isVideoEnabled && !!localVideoTrack;
}; const isVideoCallActive = showRemoteVideo || showLocalVideo;
return ( return (
<View style={styles.container}> <View style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent /> <StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Background - single consistent color */}
<View style={styles.background} /> <View style={styles.background} />
{/* Center: Peer info */} {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>
)}
<View style={styles.topBar}>
<TouchableOpacity
style={styles.topButton}
onPress={toggleMinimize}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
</TouchableOpacity>
</View>
{!isVideoCallActive && (
<View style={styles.centerArea}> <View style={styles.centerArea}>
<View style={styles.avatarOuter}> <View style={styles.avatarOuter}>
{currentCall.peerAvatar ? (
<Image source={{ uri: currentCall.peerAvatar }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}> <View style={[styles.avatar, styles.avatarPlaceholder]}>
<MaterialCommunityIcons name="web-off" size={50} color="#fff" /> <Text style={styles.avatarText}>
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
</Text>
</View> </View>
)}
</View> </View>
<Text style={styles.peerName} numberOfLines={1}> <Text style={styles.peerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'} {currentCall.peerName || '未知用户'}
</Text> </Text>
<Text style={styles.status}>{getStatusText()}</Text> <Text style={styles.status}>{getStatusText()}</Text>
</View> </View>
)}
{isVideoCallActive && (
<View style={styles.videoOverlayInfo}>
<Text style={styles.videoPeerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.videoStatus}>{getStatusText()}</Text>
</View>
)}
{/* Bottom controls */}
<View style={styles.controls}> <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 <TouchableOpacity
style={styles.endCallButton} style={styles.endCallButton}
onPress={handleEndCall} onPress={() => endCall('hangup')}
activeOpacity={0.8} activeOpacity={0.8}
> >
<View style={styles.endCallCircle}> <View style={styles.endCallCircle}>
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" /> <MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
</View> </View>
<Text style={styles.endCallLabel}></Text> <Text style={styles.endCallLabel}></Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
); );
}; };
const END_CALL_SIZE = 64;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFill,
backgroundColor: '#1A1A2E', backgroundColor: '#1A1A2E',
zIndex: 9999, zIndex: 9999,
}, },
background: { background: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFill,
backgroundColor: '#1A1A2E', 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: { centerArea: {
position: 'absolute', position: 'absolute',
top: '28%', top: '28%',
@@ -132,20 +318,98 @@ const styles = StyleSheet.create({
color: 'rgba(255, 255, 255, 0.5)', color: 'rgba(255, 255, 255, 0.5)',
letterSpacing: 0.3, 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: { controls: {
position: 'absolute', position: 'absolute',
bottom: 60, bottom: 60,
left: 0, left: 0,
right: 0, right: 0,
alignItems: 'center', 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: { endCallButton: {
alignItems: 'center', alignItems: 'center',
}, },
endCallCircle: { endCallCircle: {
width: END_CALL_SIZE, width: 64,
height: END_CALL_SIZE, height: 64,
borderRadius: END_CALL_SIZE / 2, borderRadius: 32,
backgroundColor: '#E54D42', backgroundColor: '#E54D42',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,8 +30,6 @@ import Animated, {
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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 { spacing, borderRadius, fontSizes } from '../../theme';
import { blurActiveElement } from '../../infrastructure/platform'; import { blurActiveElement } from '../../infrastructure/platform';
@@ -203,7 +201,10 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
// 保存图片到本地相册 // 保存图片到本地相册
const handleSaveImage = useCallback(async () => { 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(); const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') { if (status !== 'granted') {
@@ -212,6 +213,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
} }
setSaving(true); setSaving(true);
let downloaded: InstanceType<typeof File> | null = null;
try { try {
const urlPath = currentImage.url.split('?')[0]; const urlPath = currentImage.url.split('?')[0];
const ext = urlPath.split('.').pop()?.toLowerCase() ?? 'jpg'; const ext = urlPath.split('.').pop()?.toLowerCase() ?? 'jpg';
@@ -221,13 +223,14 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
const fileName = `withyou_${Date.now()}.${fileExt}`; const fileName = `withyou_${Date.now()}.${fileExt}`;
const destination = new File(Paths.cache, fileName); const destination = new File(Paths.cache, fileName);
// File.downloadFileAsync 是新版 expo-file-system/next 的静态方法 // File.downloadFileAsync 是新版 expo-file-system 的静态方法
const downloaded = await File.downloadFileAsync(currentImage.url, destination); downloaded = await File.downloadFileAsync(currentImage.url, destination, {
idempotent: true,
});
await MediaLibrary.saveToLibraryAsync(downloaded.uri); // expo-media-library v17+ 已移除顶层 saveToLibraryAsync,改用 Asset.create()
// 在 Android 上 filePath 必须以 file:/// 开头
// 清理缓存文件 await MediaLibrary.Asset.create(downloaded.uri);
downloaded.delete();
onSave?.(currentImage.url); onSave?.(currentImage.url);
showToast('success'); showToast('success');
@@ -235,6 +238,14 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
console.error('[ImageGallery] 保存图片失败:', err); console.error('[ImageGallery] 保存图片失败:', err);
showToast('error'); showToast('error');
} finally { } finally {
// 清理缓存文件(无论成功失败都清理,避免残留)
if (downloaded) {
try {
downloaded.delete();
} catch (cleanupErr) {
console.warn('[ImageGallery] 清理缓存文件失败:', cleanupErr);
}
}
setSaving(false); setSaving(false);
} }
}, [currentImage, saving, onSave, showToast]); }, [currentImage, saving, onSave, showToast]);
@@ -548,7 +559,7 @@ const styles = StyleSheet.create({
height: SCREEN_HEIGHT * 0.8, height: SCREEN_HEIGHT * 0.8,
}, },
errorContainer: { errorContainer: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFill,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
backgroundColor: '#000', backgroundColor: '#000',

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { localDataSource } from '../LocalDataSource'; import { localDataSource } from '../LocalDataSource';
import { CachedMessage } from '../types'; import { CachedMessage } from '../types';
import type { ILocalDataSource } from '@/data/datasources/interfaces'; import type { ILocalDataSource } from '@/data/datasources/interfaces';
import { extractTextFromSegments } from '@/types/dto';
export interface IMessageRepository { export interface IMessageRepository {
saveMessage(message: CachedMessage): Promise<void>; saveMessage(message: CachedMessage): Promise<void>;
@@ -17,6 +18,7 @@ export interface IMessageRepository {
updateStatus(messageId: string, status: string, clearContent?: boolean): Promise<void>; updateStatus(messageId: string, status: string, clearContent?: boolean): Promise<void>;
clearConversation(conversationId: string): Promise<void>; clearConversation(conversationId: string): Promise<void>;
search(keyword: string): Promise<CachedMessage[]>; search(keyword: string): Promise<CachedMessage[]>;
searchByConversation(conversationId: string, keyword: string, limit?: number, offset?: number): Promise<CachedMessage[]>;
getStats(): Promise<{ totalMessages: number; totalConversations: number }>; getStats(): Promise<{ totalMessages: number; totalConversations: number }>;
getLastMessageByConversation(conversationId: string): Promise<CachedMessage | null>; getLastMessageByConversation(conversationId: string): Promise<CachedMessage | null>;
} }
@@ -143,6 +145,35 @@ export class MessageRepository implements IMessageRepository {
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined })); 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 }> { async getStats(): Promise<{ totalMessages: number; totalConversations: number }> {
const msgCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM messages`); 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`); const convCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM conversations`);

View File

@@ -70,6 +70,9 @@ export { useMediaCache } from './useMediaCache';
export type { UseMediaCacheReturn } from './useMediaCache'; export type { UseMediaCacheReturn } from './useMediaCache';
// ==================== 推送设备注册 Hook ====================
export { useRegisterPushDevice } from './useRegisterPushDevice';
// ==================== 差异更新 Hooks ==================== // ==================== 差异更新 Hooks ====================
export { useDifferentialMessages } from './useDifferentialMessages'; 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); const currentState = usePostListStore.getState().getPostsState(listKey);
syncFromStore(currentState); 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 unsubscribe = usePostListStore.subscribe(state => {
const postsState = state.postsStateMap.get(listKey); const postsState = state.postsStateMap.get(listKey);
if (postsState) syncFromStore(postsState); if (postsState) syncFromStore(postsState);
@@ -277,6 +285,18 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
const reset = useCallback(() => { const reset = useCallback(() => {
calculatorRef.current?.reset(); calculatorRef.current?.reset();
batcherRef.current?.clearPending(); 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([]); setPosts([]);
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
@@ -284,7 +304,7 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
setHasMore(true); setHasMore(true);
previousPostsRef.current = []; previousPostsRef.current = [];
setDiffUpdates({ addedCount: 0, updatedCount: 0, deletedCount: 0, lastUpdateTime: 0 }); setDiffUpdates({ addedCount: 0, updatedCount: 0, deletedCount: 0, lastUpdateTime: 0 });
}, []); }, [listKey]);
const forceUpdate = useCallback((newPosts: T[]) => { const forceUpdate = useCallback((newPosts: T[]) => {
setPosts(newPosts); 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; if (recordKeys.length === 0) return;
const records = await AsyncStorage.multiGet(recordKeys); const records = await AsyncStorage.getMany(recordKeys);
for (const [key, value] of records) { for (const [key, value] of Object.entries(records)) {
if (value) { if (value) {
const entry: CacheEntry = JSON.parse(value); const entry: CacheEntry = JSON.parse(value);
// 验证文件是否存在 // 验证文件是否存在
@@ -614,7 +614,7 @@ export class MediaCacheManager {
const keys = await AsyncStorage.getAllKeys(); const keys = await AsyncStorage.getAllKeys();
const recordKeys = keys.filter(k => k.startsWith(CACHE_RECORD_PREFIX)); const recordKeys = keys.filter(k => k.startsWith(CACHE_RECORD_PREFIX));
if (recordKeys.length > 0) { if (recordKeys.length > 0) {
await AsyncStorage.multiRemove(recordKeys); await AsyncStorage.removeMany(recordKeys);
} }
} }

View File

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

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, resetRegisterData,
goToNextStep, goToNextStep,
goToPrevStep, goToPrevStep,
} = useRegisterStore(); } = useRegisterStore.getState();
// 本地状态 // 本地状态
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);

View File

@@ -38,15 +38,26 @@ import {
import { Text, ResponsiveContainer } from '../../components/common'; import { Text, ResponsiveContainer } from '../../components/common';
import { channelService, postService, showPrompt, voteService } from '../../services'; import { channelService, postService, showPrompt, voteService } from '../../services';
import { ApiError } from '@/services/core'; import { ApiError } from '@/services/core';
import { uploadService } from '@/services/upload';
import VoteEditor from '../../components/business/VoteEditor'; import VoteEditor from '../../components/business/VoteEditor';
import PostMentionInput from '../../components/business/PostMentionInput'; import PostMentionInput from '../../components/business/PostMentionInput';
import BlockEditor, { BlockEditorHandle } from '../../components/business/BlockEditor'; import BlockEditor, { BlockEditorHandle } from '../../components/business/BlockEditor';
import { segmentsToBlocks } from '../../components/business/BlockEditor/blocksToSegments'; import { segmentsToBlocks } from '../../components/business/BlockEditor/blocksToSegments';
import type { EditorBlock } from '../../components/business/BlockEditor/blockEditorTypes'; import {
generateBlockId,
type EditorBlock,
type ImageBlock,
} from '../../components/business/BlockEditor/blockEditorTypes';
import { useResponsive, useResponsiveValue } from '../../hooks'; import { useResponsive, useResponsiveValue } from '../../hooks';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
import { MessageSegment } from '../../types'; import { MessageSegment } from '../../types';
import {
type PendingOrRemoteImage,
type RemoteImage,
generatePendingImageId,
makePendingImageFromAsset,
getImageDisplayUri,
uploadAllPendingImages,
} from '../../utils/pendingImages';
// Props 接口 // Props 接口
interface CreatePostScreenProps { interface CreatePostScreenProps {
@@ -130,7 +141,8 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [segments, setSegments] = useState<MessageSegment[]>([]); const [segments, setSegments] = useState<MessageSegment[]>([]);
const [images, setImages] = useState<{ uri: string; uploading?: boolean }[]>([]); // 图片状态pending 表示本地待上传remote 表示已是服务器 URL编辑模式下的已存在图片
const [images, setImages] = useState<PendingOrRemoteImage[]>([]);
const [channelOptions, setChannelOptions] = useState<ChannelOption[]>([]); const [channelOptions, setChannelOptions] = useState<ChannelOption[]>([]);
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null); const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
const [showChannelPicker, setShowChannelPicker] = useState(false); const [showChannelPicker, setShowChannelPicker] = useState(false);
@@ -213,7 +225,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
} }
setTitle(existingPost.title || ''); setTitle(existingPost.title || '');
setContent(existingPost.content || ''); setContent(existingPost.content || '');
setImages((existingPost.images || []).map((img) => ({ uri: img.url, uploading: false }))); setImages((existingPost.images || []).map((img): RemoteImage => ({ id: generatePendingImageId(), kind: 'remote', url: img.url })));
// 自动判断长文模式segments 中包含图片块即为长文帖子 // 自动判断长文模式segments 中包含图片块即为长文帖子
const hasImageSegments = (existingPost.segments || []).some(s => s.type === 'image'); const hasImageSegments = (existingPost.segments || []).some(s => s.type === 'image');
@@ -233,7 +245,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
loadPostForEdit(); loadPostForEdit();
}, [isEditMode, editPostID, router]); }, [isEditMode, editPostID, router]);
// 选择图片(普通模式) // 选择图片(普通模式)—— 仅选择,不立即上传
const handlePickImage = async () => { const handlePickImage = async () => {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
@@ -250,35 +262,12 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
}); });
if (!result.canceled && result.assets) { if (!result.canceled && result.assets) {
const newImages = result.assets.map(asset => ({ uri: asset.uri, uploading: true })); const newImages = result.assets.map(asset => makePendingImageFromAsset(asset));
setImages(prev => [...prev, ...newImages]); setImages(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) {
setImages(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('上传失败', '图片上传失败,请重试');
setImages(prev => prev.filter(img => img.uri !== asset.uri));
}
}
} }
}; };
// 拍照 // 拍照 —— 仅添加,不立即上传
const handleTakePhoto = async () => { const handleTakePhoto = async () => {
const permissionResult = await ImagePicker.requestCameraPermissionsAsync(); const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
@@ -294,33 +283,13 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
if (!result.canceled && result.assets[0]) { if (!result.canceled && result.assets[0]) {
const asset = result.assets[0]; const asset = result.assets[0];
setImages(prev => [...prev, { uri: asset.uri, uploading: true }]); setImages(prev => [...prev, makePendingImageFromAsset(asset)]);
// 上传图片
const uploadResult = await uploadService.uploadImage({
uri: asset.uri,
type: asset.mimeType || 'image/jpeg',
});
if (uploadResult) {
setImages(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('上传失败', '图片上传失败,请重试');
setImages(prev => prev.filter(img => img.uri !== asset.uri));
}
} }
}; };
// 删除图片 // 删除图片
const handleRemoveImage = (index: number) => { const handleRemoveImage = (index: number) => {
setImages(images.filter((_, i) => i !== index)); setImages(prev => prev.filter((_, i) => i !== index));
}; };
// 选择频道(单选,可再次点击取消) // 选择频道(单选,可再次点击取消)
@@ -350,26 +319,44 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
// 切换长文/非长文模式时保留数据 // 切换长文/非长文模式时保留数据
const handleToggleLongPostMode = useCallback(() => { const handleToggleLongPostMode = useCallback(() => {
if (!isLongPostMode) { if (!isLongPostMode) {
// 非长文 -> 长文:将当前文本和图片转为 blocks // 非长文 -> 长文:将当前文本和图片(包括 pending转为 blocks
const segs: MessageSegment[] = []; const blocks: EditorBlock[] = [];
if (content.trim()) { if (content.trim()) {
segs.push({ type: 'text', data: { text: content } }); blocks.push({
id: generateBlockId(),
type: 'text',
text: content,
activeMentions: new Map(),
});
} }
for (const img of images) { for (const img of images) {
if (img.uri && !img.uploading) { const imageBlock: ImageBlock = img.kind === 'remote'
segs.push({ type: 'image', data: { url: img.uri } }); ? { id: generateBlockId(), type: 'image', localUri: img.url, remoteUrl: img.url }
: { id: generateBlockId(), type: 'image', localUri: img.localUri, mimeType: img.mimeType };
blocks.push(imageBlock);
} }
} setInitialBlocks(blocks.length > 0 ? blocks : undefined);
setInitialBlocks(segs.length > 0 ? segmentsToBlocks(segs) : undefined);
} else { } else {
// 长文 -> 非长文:从 BlockEditor 提取文本和图片 // 长文 -> 非长文:从 BlockEditor 提取文本和所有图片(包括 pending
const editorContent = blockEditorRef.current?.getContent() || ''; const editorContent = blockEditorRef.current?.getContent() || '';
const editorSegments = blockEditorRef.current?.getSegments() || []; const editorBlocks = blockEditorRef.current?.getBlocks() || [];
if (editorContent) setContent(editorContent); if (editorContent) setContent(editorContent);
const editorImages = editorSegments
.filter((s): s is Extract<typeof s, { type: 'image' }> => s.type === 'image') const editorImages: PendingOrRemoteImage[] = [];
.map(s => ({ uri: s.data.url, uploading: false })); for (const block of editorBlocks) {
if (editorImages.length > 0) setImages(editorImages); if (block.type !== 'image') continue;
if (block.remoteUrl) {
editorImages.push({ id: generatePendingImageId(), kind: 'remote', url: block.remoteUrl });
} else {
editorImages.push({
id: generatePendingImageId(),
kind: 'pending',
localUri: block.localUri,
mimeType: block.mimeType,
});
}
}
setImages(editorImages);
} }
setIsLongPostMode(prev => !prev); setIsLongPostMode(prev => !prev);
}, [isLongPostMode, content, images]); }, [isLongPostMode, content, images]);
@@ -436,25 +423,37 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
return; return;
} }
// 检查是否有图片正在上传 // 检查 BlockEditor 中是否有正在上传的图片
if (isLongPostMode) { // (已无此状态,发布时会统一上传,无需检查)
if (blockEditorRef.current?.hasUploadingImages()) {
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
return;
}
} else {
const uploadingImages = images.filter(img => img.uploading);
if (uploadingImages.length > 0) {
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
return;
}
}
setPosting(true); setPosting(true);
try { try {
// 普通模式下,发布前上传所有待上传的图片
let uploadedImageUrls: string[] = [];
if (!isLongPostMode && images.length > 0) {
const uploadResult = await uploadAllPendingImages(images, (id, url) => {
setImages(prev => prev.map(i => i.id === id ? { id, kind: 'remote' as const, url } : i));
});
if (!uploadResult.success) {
setPosting(false);
Alert.alert('上传失败', '部分图片上传失败,请重试');
return;
}
uploadedImageUrls = uploadResult.urls;
}
// 长文模式下,发布前上传 BlockEditor 中所有 pending 图片
if (isLongPostMode) {
const uploadOk = await blockEditorRef.current?.uploadPendingImages();
if (uploadOk === false) {
setPosting(false);
Alert.alert('上传失败', '部分图片上传失败,请重试');
return;
}
}
if (isVotePost) { if (isVotePost) {
const imageUrls = images.map(img => img.uri);
// 验证投票选项 // 验证投票选项
const validOptions = voteOptions.filter(opt => opt.trim() !== ''); const validOptions = voteOptions.filter(opt => opt.trim() !== '');
if (validOptions.length < 2) { if (validOptions.length < 2) {
@@ -467,7 +466,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
await voteService.createVotePost({ await voteService.createVotePost({
title: title.trim(), title: title.trim(),
content: content.trim(), content: content.trim(),
images: imageUrls.length > 0 ? imageUrls : undefined, images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined,
channel_id: selectedChannelId || undefined, channel_id: selectedChannelId || undefined,
vote_options: validOptions.map(opt => ({ content: opt })), vote_options: validOptions.map(opt => ({ content: opt })),
}); });
@@ -506,13 +505,12 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
} }
} else { } else {
// 普通模式 // 普通模式
const imageUrls = images.map(img => img.uri);
if (isEditMode && editPostID) { if (isEditMode && editPostID) {
const updated = await postService.updatePost(editPostID, { const updated = await postService.updatePost(editPostID, {
title: title.trim(), title: title.trim(),
content: content.trim(), content: content.trim(),
segments: segments.some(s => s.type !== 'text') ? segments : undefined, segments: segments.some(s => s.type !== 'text') ? segments : undefined,
images: imageUrls.length > 0 ? imageUrls : undefined, images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined,
}); });
if (!updated) { if (!updated) {
throw new Error('更新帖子失败'); throw new Error('更新帖子失败');
@@ -524,7 +522,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
title: title.trim(), title: title.trim(),
content: content.trim(), content: content.trim(),
segments: segments.some(s => s.type !== 'text') ? segments : undefined, segments: segments.some(s => s.type !== 'text') ? segments : undefined,
images: imageUrls.length > 0 ? imageUrls : undefined, images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined,
channel_id: selectedChannelId || undefined, channel_id: selectedChannelId || undefined,
}); });
showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 }); showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 });
@@ -547,7 +545,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
<View style={styles.imageGrid}> <View style={styles.imageGrid}>
{images.map((img, index) => ( {images.map((img, index) => (
<Animated.View <Animated.View
key={`${img.uri}-${index}`} key={img.id}
style={[ style={[
styles.imageGridItem, styles.imageGridItem,
{ {
@@ -558,16 +556,14 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
} }
]} ]}
> >
<Image source={{ uri: img.uri }} style={styles.gridImage} resizeMode="cover" /> <Image
{img.uploading && ( source={{ uri: getImageDisplayUri(img) }}
<View style={styles.uploadingOverlay}> style={styles.gridImage}
<MaterialCommunityIcons name="loading" size={20} color={colors.text.inverse} /> resizeMode="cover"
</View> />
)}
<TouchableOpacity <TouchableOpacity
style={styles.removeImageButton} style={styles.removeImageButton}
onPress={() => handleRemoveImage(index)} onPress={() => handleRemoveImage(index)}
disabled={img.uploading}
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }} hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
> >
<View style={styles.removeImageButtonInner}> <View style={styles.removeImageButtonInner}>
@@ -1044,12 +1040,6 @@ function createCreatePostStyles(colors: AppColors) {
width: '100%', width: '100%',
height: '100%', height: '100%',
}, },
uploadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
removeImageButton: { removeImageButton: {
position: 'absolute', position: 'absolute',
top: 4, top: 4,

View File

@@ -29,6 +29,7 @@ import { Post } from '../../types';
import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores'; import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores';
import { useCurrentUser, useIsAuthenticated, useIsVerified, useVerificationStore } from '../../stores/auth'; import { useCurrentUser, useIsAuthenticated, useIsVerified, useVerificationStore } from '../../stores/auth';
import { channelService, postService } from '../../services'; import { channelService, postService } from '../../services';
import { useChannels } from '../../hooks/useChannels';
import { PostCard, SearchBar, ShareSheet } from '../../components/business'; import { PostCard, SearchBar, ShareSheet } from '../../components/business';
import type { PostCardAction } from '../../components/business/PostCard'; import type { PostCardAction } from '../../components/business/PostCard';
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem } from '../../components/common'; import { Loading, EmptyState, Text, ImageGallery, ImageGridItem } from '../../components/common';
@@ -215,7 +216,7 @@ function createHomeStyles(colors: AppColors, responsivePadding: number) {
export const HomeScreen: React.FC = () => { export const HomeScreen: React.FC = () => {
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { posts: storePosts } = useUserStore(); const storePosts = useUserStore((s) => s.posts);
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const isAuthenticated = useIsAuthenticated(); const isAuthenticated = useIsAuthenticated();
const isVerified = useIsVerified(); const isVerified = useIsVerified();
@@ -242,7 +243,11 @@ export const HomeScreen: React.FC = () => {
const [sortIndex, setSortIndex] = useState(2); const [sortIndex, setSortIndex] = useState(2);
const [viewMode, setViewMode] = useState<ViewMode>('list'); 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 [activeCapsuleId, setActiveCapsuleId] = useState('');
// 图片查看器状态 // 图片查看器状态
@@ -250,6 +255,12 @@ export const HomeScreen: React.FC = () => {
const [postImages, setPostImages] = useState<ImageGridItem[]>([]); const [postImages, setPostImages] = useState<ImageGridItem[]>([]);
const [selectedImageIndex, setSelectedImageIndex] = useState(0); 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 [showSearch, setShowSearch] = useState(false);
@@ -353,7 +364,7 @@ export const HomeScreen: React.FC = () => {
useEffect(() => { useEffect(() => {
if (homeTabPressCount > 0) { if (homeTabPressCount > 0) {
// 滚动 FlashList 到顶部 // 滚动 FlashList 到顶部
flashListRef.current?.scrollToOffset({ offset: 0, animated: true }); flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
// 滚动 ScrollView 到顶部(网格模式) // 滚动 ScrollView 到顶部(网格模式)
scrollViewRef.current?.scrollTo({ y: 0, animated: true }); scrollViewRef.current?.scrollTo({ y: 0, animated: true });
// 重置底部 Tab 栏状态 // 重置底部 Tab 栏状态
@@ -426,7 +437,6 @@ export const HomeScreen: React.FC = () => {
refreshing: isRefreshing, refreshing: isRefreshing,
hasMore, hasMore,
error, error,
reset,
} = useDifferentialPosts<Post>( } = useDifferentialPosts<Post>(
[], [],
{ {
@@ -486,8 +496,9 @@ export const HomeScreen: React.FC = () => {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const run = async () => { const run = async () => {
reset(); // 注意:不再在此处调用 reset()。
await new Promise(resolve => requestAnimationFrame(resolve)); // fetchPosts 内部会在参数变化时立即清空 store 中的旧数据并设置 loading
// 这样可以避免本地清空后、请求发起前的间隙渲染到上一个分区的帖子造成闪烁。
if (!cancelled) { if (!cancelled) {
await refresh(); await refresh();
} }
@@ -587,18 +598,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 = () => { const handleSearchPress = () => {
setShowSearch(true); setShowSearch(true);
@@ -717,7 +716,7 @@ export const HomeScreen: React.FC = () => {
// 跳转到发帖页面(使用 Modal 方式) // 跳转到发帖页面(使用 Modal 方式)
const handleCreatePost = () => { const handleCreatePost = () => {
if (!isAuthenticated) { if (!isAuthenticated) {
router.push('/login'); router.push(hrefs.hrefAuthLogin());
return; return;
} }
if (!isVerified) { if (!isVerified) {
@@ -811,9 +810,10 @@ export const HomeScreen: React.FC = () => {
const renderResponsiveGrid = () => { const renderResponsiveGrid = () => {
return ( return (
<FlashList <FlashList
key={`${listKey}_grid`} key="home-grid"
ref={scrollViewRef as any} ref={scrollViewRef as any}
data={displayPosts} data={displayPosts}
extraData={listKey}
renderItem={renderGridItem} renderItem={renderGridItem}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
numColumns={gridColumns} numColumns={gridColumns}
@@ -865,9 +865,10 @@ export const HomeScreen: React.FC = () => {
// 移动端和宽屏都使用单列 FlashList宽屏下居中显示 // 移动端和宽屏都使用单列 FlashList宽屏下居中显示
return ( return (
<FlashList <FlashList
key={listKey} key="home-list"
ref={flashListRef} ref={flashListRef}
data={displayPosts} data={displayPosts}
extraData={listKey}
renderItem={renderPostList} renderItem={renderPostList}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
contentContainerStyle={{ contentContainerStyle={{
@@ -901,6 +902,7 @@ export const HomeScreen: React.FC = () => {
<StatusBar barStyle={statusBarStyle} backgroundColor={colors.background.paper} /> <StatusBar barStyle={statusBarStyle} backgroundColor={colors.background.paper} />
<SearchScreen <SearchScreen
onBack={() => setShowSearch(false)} onBack={() => setShowSearch(false)}
homeTab={homeTab}
/> />
</SafeAreaView> </SafeAreaView>
); );
@@ -1050,7 +1052,7 @@ export const HomeScreen: React.FC = () => {
floatingButtonBottom !== undefined && { bottom: floatingButtonBottom }, floatingButtonBottom !== undefined && { bottom: floatingButtonBottom },
]} ]}
onPress={homeTab === 'market' ? () => { onPress={homeTab === 'market' ? () => {
if (!isAuthenticated) { router.push('/login'); return; } if (!isAuthenticated) { router.push(hrefs.hrefAuthLogin()); return; }
if (!isVerified) { router.push(hrefs.hrefVerificationGuide()); return; } if (!isVerified) { router.push(hrefs.hrefVerificationGuide()); return; }
setShowCreateTrade(true); setShowCreateTrade(true);
} : handleCreatePost} } : handleCreatePost}
@@ -1062,12 +1064,9 @@ export const HomeScreen: React.FC = () => {
{/* 图片查看器 */} {/* 图片查看器 */}
<ImageGallery <ImageGallery
visible={showImageViewer} visible={showImageViewer}
images={postImages.map(img => ({ images={stableGalleryImages}
id: img.id || img.url || String(Math.random()),
url: img.url || img.uri || ''
}))}
initialIndex={selectedImageIndex} initialIndex={selectedImageIndex}
onClose={() => setShowImageViewer(false)} onClose={stableCloseImageViewer}
enableSave enableSave
/> />

View File

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

View File

@@ -38,12 +38,19 @@ import {
import { Post, Comment, VoteResultDTO, VoteOptionDTO, MessageSegment } from '../../types'; import { Post, Comment, VoteResultDTO, VoteOptionDTO, MessageSegment } from '../../types';
import { useUserStore } from '../../stores'; import { useUserStore } from '../../stores';
import { useCurrentUser } from '../../stores/auth'; 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 { postSyncService } from '@/services/post';
import { useCursorPagination } from '../../hooks/useCursorPagination'; import { useCursorPagination } from '../../hooks/useCursorPagination';
import { CommentItem, VoteCard, ReportDialog, ShareSheet, PostContentRenderer, PostMentionInput } from '../../components/business'; 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 { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks'; import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks';
import {
type PendingOrRemoteImage,
makePendingImageFromAsset,
getImageDisplayUri,
uploadAllPendingImages,
} from '../../utils/pendingImages';
import { handleError } from '@/services/core'; import { handleError } from '@/services/core';
import { formatDateTime as formatDateTimeUtil, formatRelativeTime as formatRelativeTimeUtil } from '@/utils/formatTime'; import { formatDateTime as formatDateTimeUtil, formatRelativeTime as formatRelativeTimeUtil } from '@/utils/formatTime';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
@@ -251,7 +258,7 @@ export const PostDetailScreen: React.FC = () => {
const [keyboardHeight, setKeyboardHeight] = useState(0); const [keyboardHeight, setKeyboardHeight] = useState(0);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
// 评论图片相关状态 // 评论图片相关状态
const [commentImages, setCommentImages] = useState<{ uri: string; uploading?: boolean }[]>([]); const [commentImages, setCommentImages] = useState<PendingOrRemoteImage[]>([]);
const [showEmojiPanel, setShowEmojiPanel] = useState(false); const [showEmojiPanel, setShowEmojiPanel] = useState(false);
const [isFollowing, setIsFollowing] = useState(false); const [isFollowing, setIsFollowing] = useState(false);
const [isFollowingMe, setIsFollowingMe] = useState(false); const [isFollowingMe, setIsFollowingMe] = useState(false);
@@ -688,31 +695,8 @@ export const PostDetailScreen: React.FC = () => {
}); });
if (!result.canceled && result.assets) { 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]); 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,12 +711,18 @@ export const PostDetailScreen: React.FC = () => {
const hasContent = commentText.trim() || commentImages.length > 0; const hasContent = commentText.trim() || commentImages.length > 0;
if (!hasContent || !post) return; if (!hasContent || !post) return;
// 检查是否有图片正在上传 // 上传所有 pending 图片
const uploadingImages = commentImages.filter(img => img.uploading); let uploadedImageUrls: string[] = [];
if (uploadingImages.length > 0) { if (commentImages.length > 0) {
Alert.alert('请稍候', '图片正在上传中,请稍后再试'); 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; return;
} }
uploadedImageUrls = uploadResult.urls;
}
const tempId = `new-${Date.now()}`; const tempId = `new-${Date.now()}`;
const userId = currentUser?.id || ''; const userId = currentUser?.id || '';
@@ -741,9 +731,6 @@ export const PostDetailScreen: React.FC = () => {
// 获取根评论 ID如果被回复评论有 root_id则使用 root_id否则使用 parent_id即被回复的是顶级评论 // 获取根评论 ID如果被回复评论有 root_id则使用 root_id否则使用 parent_id即被回复的是顶级评论
const rootId = isReply ? (replyingTo.root_id || replyingTo.id) : null; const rootId = isReply ? (replyingTo.root_id || replyingTo.id) : null;
// 获取已上传图片的URL列表
const uploadedImageUrls = commentImages.filter(img => !img.uploading).map(img => img.uri);
// 先创建临时评论显示在列表中 // 先创建临时评论显示在列表中
const tempComment: Comment = { const tempComment: Comment = {
id: tempId, id: tempId,
@@ -1084,6 +1071,12 @@ export const PostDetailScreen: React.FC = () => {
})); }));
}, [post?.images]); }, [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(() => { const renderPostHeader = useCallback(() => {
if (!post) return null; if (!post) return null;
@@ -1279,10 +1272,22 @@ export const PostDetailScreen: React.FC = () => {
// 回复评论 // 回复评论
const [replyingTo, setReplyingTo] = useState<Comment | null>(null); const [replyingTo, setReplyingTo] = useState<Comment | null>(null);
const [isComposerVisible, setIsComposerVisible] = useState(false); 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) => { const handleReply = (comment: Comment) => {
setReplyingTo(comment); setReplyingTo(comment);
setIsComposerVisible(true); setIsComposerVisible(true);
focusCommentInput();
}; };
const handleCancelReply = () => { const handleCancelReply = () => {
@@ -1371,6 +1376,7 @@ export const PostDetailScreen: React.FC = () => {
const openComposer = () => { const openComposer = () => {
setIsComposerVisible(true); setIsComposerVisible(true);
focusCommentInput();
}; };
const closeComposer = () => { const closeComposer = () => {
@@ -1479,6 +1485,7 @@ export const PostDetailScreen: React.FC = () => {
{/* Text Input —— 小红书风格:无边框、自动扩展 */} {/* Text Input —— 小红书风格:无边框、自动扩展 */}
<PostMentionInput <PostMentionInput
ref={commentInputRef}
value={commentText} value={commentText}
onChangeText={setCommentText} onChangeText={setCommentText}
onSegmentsChange={setCommentSegments} onSegmentsChange={setCommentSegments}
@@ -1495,13 +1502,12 @@ export const PostDetailScreen: React.FC = () => {
{commentImages.length > 0 && ( {commentImages.length > 0 && (
<ScrollView horizontal style={styles.commentImagesPreview} showsHorizontalScrollIndicator={false}> <ScrollView horizontal style={styles.commentImagesPreview} showsHorizontalScrollIndicator={false}>
{commentImages.map((image, index) => ( {commentImages.map((image, index) => (
<View key={index} style={styles.commentImageWrapper}> <View key={image.id} style={styles.commentImageWrapper}>
<Image source={{ uri: image.uri }} style={styles.commentImageThumbnail} resizeMode="cover" /> <Image
{image.uploading && ( source={{ uri: getImageDisplayUri(image) }}
<View style={styles.uploadingOverlay}> style={styles.commentImageThumbnail}
<Loading size="sm" /> resizeMode="cover"
</View> />
)}
<TouchableOpacity <TouchableOpacity
style={styles.removeImageButton} style={styles.removeImageButton}
onPress={() => handleRemoveCommentImage(index)} onPress={() => handleRemoveCommentImage(index)}
@@ -1740,10 +1746,7 @@ export const PostDetailScreen: React.FC = () => {
{/* 图片预览 ImageGallery */} {/* 图片预览 ImageGallery */}
<ImageGallery <ImageGallery
visible={showImageModal} visible={showImageModal}
images={allImages.map(img => ({ images={stableGalleryImages}
id: img.id || img.url || String(Math.random()),
url: img.url || img.uri || ''
}))}
initialIndex={selectedImageIndex} initialIndex={selectedImageIndex}
onClose={() => setShowImageModal(false)} onClose={() => setShowImageModal(false)}
enableSave enableSave
@@ -1815,10 +1818,7 @@ export const PostDetailScreen: React.FC = () => {
{/* 图片预览 ImageGallery */} {/* 图片预览 ImageGallery */}
<ImageGallery <ImageGallery
visible={showImageModal} visible={showImageModal}
images={allImages.map(img => ({ images={stableGalleryImages}
id: img.id || img.url || String(Math.random()),
url: img.url || img.uri || ''
}))}
initialIndex={selectedImageIndex} initialIndex={selectedImageIndex}
onClose={() => setShowImageModal(false)} onClose={() => setShowImageModal(false)}
enableSave enableSave
@@ -2185,17 +2185,6 @@ function createPostDetailStyles(colors: AppColors) {
height: 60, height: 60,
borderRadius: borderRadius.sm, 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: { removeImageButton: {
position: 'absolute', position: 'absolute',
top: -6, top: -6,

View File

@@ -12,39 +12,49 @@ import {
TouchableOpacity, TouchableOpacity,
ScrollView, ScrollView,
RefreshControl, RefreshControl,
Animated,
} from 'react-native'; } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import { Post, User } from '../../types'; import { Post, User } from '../../types';
import type { TradeItemDTO } from '../../types/trade';
import { useUserStore } from '../../stores'; import { useUserStore } from '../../stores';
import { postService, authService } from '../../services'; import { postService, authService } from '../../services';
import { tradeService } from '../../services/trade/tradeService';
import { postSyncService } from '@/services/post'; import { postSyncService } from '@/services/post';
import { PostCard, TabBar, SearchBar } from '../../components/business'; import { PostCard, SearchBar } from '../../components/business';
import { TradeCard } from '../../components/business/TradeCard/TradeCard';
import type { PostCardAction } from '../../components/business/PostCard'; import type { PostCardAction } from '../../components/business/PostCard';
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common'; import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks'; import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks';
import { useCursorPagination } from '../../hooks/useCursorPagination'; import { useCursorPagination } from '../../hooks/useCursorPagination';
const TABS = ['帖子', '用户']; const SQUARE_TABS = ['帖子', '用户'];
const MARKET_TABS = ['商品', '用户'];
const DEFAULT_PAGE_SIZE = 20; const DEFAULT_PAGE_SIZE = 20;
// 与 TabsLayout 中的常量保持一致tabBarHeight 56 + 上下浮动间距 12*2 // 与 TabsLayout 中的常量保持一致tabBarHeight 56 + 上下浮动间距 12*2
const FLOATING_TAB_BAR_HEIGHT = 56 + 12 * 2; const FLOATING_TAB_BAR_HEIGHT = 56 + 12 * 2;
type SearchType = 'posts' | 'users'; type SearchType = 'posts' | 'users' | 'trades';
interface SearchScreenProps { interface SearchScreenProps {
onBack?: () => void; 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 colors = useAppColors();
const styles = useMemo(() => createSearchScreenStyles(colors), [colors]); const styles = useMemo(() => createSearchScreenStyles(colors), [colors]);
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); 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 // 使用响应式 hook
const { const {
@@ -85,6 +95,25 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
// 保存当前搜索关键词用于Tab切换时重新搜索 // 保存当前搜索关键词用于Tab切换时重新搜索
const [currentKeyword, setCurrentKeyword] = useState(''); 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 searchExtraParams = useMemo(() => ({ query: currentKeyword }), [currentKeyword]);
const { const {
@@ -114,6 +143,10 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
const [userResults, setUserResults] = useState<User[]>([]); const [userResults, setUserResults] = useState<User[]>([]);
const [userLoading, setUserLoading] = useState(false); const [userLoading, setUserLoading] = useState(false);
// 市集搜索结果
const [tradeResults, setTradeResults] = useState<TradeItemDTO[]>([]);
const [tradeLoading, setTradeLoading] = useState(false);
const refreshRef = useRef(refresh); const refreshRef = useRef(refresh);
refreshRef.current = refresh; refreshRef.current = refresh;
const resetRef = useRef(reset); const resetRef = useRef(reset);
@@ -147,9 +180,16 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
setUserLoading(true); setUserLoading(true);
const usersResponse = await authService.searchUsers(trimmedKeyword, 1, 20); const usersResponse = await authService.searchUsers(trimmedKeyword, 1, 20);
setUserResults(usersResponse.list || []); 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) { } catch (error) {
console.error('搜索失败:', error); console.error('搜索失败:', error);
} finally {
setUserLoading(false);
setTradeLoading(false);
} }
setHasSearched(true); setHasSearched(true);
@@ -218,6 +258,13 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
// 获取当前搜索类型 // 获取当前搜索类型
const getSearchType = (): SearchType => { const getSearchType = (): SearchType => {
if (isMarket) {
switch (activeIndex) {
case 0: return 'trades';
case 1: return 'users';
default: return 'trades';
}
}
switch (activeIndex) { switch (activeIndex) {
case 0: return 'posts'; case 0: return 'posts';
case 1: return 'users'; case 1: return 'users';
@@ -425,6 +472,38 @@ 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 renderSearchResults = () => {
const searchType = getSearchType(); const searchType = getSearchType();
@@ -437,6 +516,10 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
return renderUserResults(); return renderUserResults();
} }
if (searchType === 'trades') {
return renderTradeResults();
}
return null; return null;
}; };
@@ -445,10 +528,16 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
if (hasSearched) return null; if (hasSearched) return null;
return ( return (
<View style={[styles.suggestionsContainer, { paddingHorizontal: responsivePadding }]}> <Animated.View
style={[
styles.suggestionsContainer,
{ paddingHorizontal: responsivePadding },
{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }
]}
>
{/* 搜索历史 */} {/* 搜索历史 */}
{history.length > 0 && ( {history.length > 0 && (
<View style={[styles.section, { marginTop: responsiveGap }]}> <View style={[styles.section, { marginTop: responsiveGap * 1.5 }]}>
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text <Text
variant="body" variant="body"
@@ -459,7 +548,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
> >
</Text> </Text>
<TouchableOpacity onPress={handleClearHistory}> <TouchableOpacity onPress={handleClearHistory} activeOpacity={0.7}>
<MaterialCommunityIcons name="delete-outline" size={isDesktop ? 22 : 18} color={colors.text.secondary} /> <MaterialCommunityIcons name="delete-outline" size={isDesktop ? 22 : 18} color={colors.text.secondary} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -477,6 +566,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
} }
]} ]}
onPress={() => handleHistoryPress(keyword)} onPress={() => handleHistoryPress(keyword)}
activeOpacity={0.7}
> >
<MaterialCommunityIcons name="history" size={isDesktop ? 16 : 14} color={colors.text.secondary} /> <MaterialCommunityIcons name="history" size={isDesktop ? 16 : 14} color={colors.text.secondary} />
<Text <Text
@@ -494,7 +584,18 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
</View> </View>
</View> </View>
)} )}
{/* 空状态提示 */}
{history.length === 0 && (
<View style={styles.emptySuggestions}>
<EmptyState
title="开始搜索"
description="输入关键词搜索帖子、用户或商品"
icon="magnify"
/>
</View> </View>
)}
</Animated.View>
); );
}; };
@@ -516,8 +617,9 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
value={searchText} value={searchText}
onChangeText={setSearchText} onChangeText={setSearchText}
onSubmit={handleSearch} onSubmit={handleSearch}
placeholder="搜索帖子、用户" placeholder={isMarket ? "搜索商品、用户" : "搜索帖子、用户"}
autoFocus autoFocus
compact
/> />
</View> </View>
<TouchableOpacity <TouchableOpacity
@@ -535,20 +637,30 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Tab切换 */} {/* Tab切换 - 与主页风格一致的下划线样式 */}
<View style={styles.tabWrapper}> <View style={styles.tabWrapper}>
<TabBar <View style={[styles.homeTabSwitcher, { paddingHorizontal: responsivePadding }]}>
tabs={TABS} {TABS.map((tab, index) => {
activeIndex={activeIndex} const isActive = activeIndex === index;
onTabChange={(index) => { return (
<TouchableOpacity
key={tab}
activeOpacity={0.7}
style={[styles.homeTabItem, isActive && styles.homeTabItemActive]}
onPress={() => {
setActiveIndex(index); setActiveIndex(index);
if (currentKeyword && hasSearched) { if (currentKeyword && hasSearched) {
performSearch(currentKeyword); performSearch(currentKeyword);
} }
}} }}
variant="modern" >
icons={['file-document-outline', 'account-outline']} <Text style={isActive ? styles.homeTabTextActive : styles.homeTabText}>
/> {tab}
</Text>
</TouchableOpacity>
);
})}
</View>
</View> </View>
{/* 内容区域 */} {/* 内容区域 */}
@@ -566,15 +678,12 @@ function createSearchScreenStyles(colors: AppColors) {
searchHeader: { searchHeader: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: colors.background.paper, backgroundColor: colors.background.default,
borderBottomWidth: 1,
borderBottomColor: `${colors.divider}70`,
}, },
searchShell: { searchShell: {
flex: 1, flex: 1,
}, },
cancelButton: { cancelButton: {
backgroundColor: `${colors.primary.main}12`,
borderRadius: borderRadius.full, borderRadius: borderRadius.full,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
@@ -584,13 +693,43 @@ function createSearchScreenStyles(colors: AppColors) {
fontWeight: '600', fontWeight: '600',
}, },
tabWrapper: { tabWrapper: {
backgroundColor: colors.background.paper, backgroundColor: colors.background.default,
borderBottomWidth: 1, paddingVertical: spacing.xs,
borderBottomColor: `${colors.divider}50`, },
homeTabSwitcher: {
flexDirection: 'row',
alignItems: 'center',
gap: 20,
},
homeTabItem: {
paddingVertical: spacing.sm,
alignItems: 'center',
justifyContent: 'center',
borderBottomWidth: 2,
borderBottomColor: 'transparent',
},
homeTabItemActive: {
borderBottomColor: colors.text.primary,
},
homeTabText: {
fontSize: 18,
fontWeight: '400',
color: colors.text.hint,
},
homeTabTextActive: {
fontSize: 18,
fontWeight: '700',
color: colors.text.primary,
}, },
suggestionsContainer: { suggestionsContainer: {
flex: 1, flex: 1,
}, },
emptySuggestions: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
marginTop: -40,
},
section: { section: {
marginTop: spacing.md, marginTop: spacing.md,
}, },
@@ -611,13 +750,14 @@ function createSearchScreenStyles(colors: AppColors) {
tag: { tag: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: `${colors.primary.main}10`, backgroundColor: colors.background.paper,
borderRadius: borderRadius.full, borderRadius: borderRadius.full,
borderWidth: 0, borderWidth: 1,
borderColor: colors.divider,
}, },
tagText: { tagText: {
marginLeft: spacing.xs, marginLeft: spacing.xs,
color: colors.primary.main, color: colors.text.secondary,
fontWeight: '500', fontWeight: '500',
}, },
userCard: { userCard: {

View File

@@ -27,11 +27,11 @@ import {
Platform, Platform,
TouchableOpacity, TouchableOpacity,
BackHandler, BackHandler,
StatusBar,
} from 'react-native'; } from 'react-native';
import { FlashList, ListRenderItem } from '@shopify/flash-list'; import { FlashList, ListRenderItem } from '@shopify/flash-list';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useNavigation, useRouter } from 'expo-router'; import { useNavigation, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text, ImageGallery, ImageGridItem } from '../../components/common'; import { Text, ImageGallery, ImageGridItem } from '../../components/common';
import { useAppColors, useResolvedColorScheme } from '../../theme'; import { useAppColors, useResolvedColorScheme } from '../../theme';
@@ -111,6 +111,11 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
const [chatImages, setChatImages] = useState<ImageGridItem[]>([]); const [chatImages, setChatImages] = useState<ImageGridItem[]>([]);
const [selectedImageIndex, setSelectedImageIndex] = useState(0); 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) => { const handleImagePress = (images: ImageGridItem[], index: number) => {
setChatImages(images); setChatImages(images);
@@ -154,7 +159,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
longPressMenuVisible, longPressMenuVisible,
selectedMessage, selectedMessage,
selectedMessageId, selectedMessageId,
setSelectedMessageId,
menuPosition, menuPosition,
isGroupChat, isGroupChat,
groupInfo, groupInfo,
@@ -202,7 +206,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
handleMentionAll, handleMentionAll,
getSenderInfo, getSenderInfo,
getTypingHint, getTypingHint,
getInputBottom,
handleDismiss, handleDismiss,
navigateToInfo, navigateToInfo,
navigateToChatSettings, navigateToChatSettings,
@@ -212,6 +215,28 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
jumpToLatestMessages, jumpToLatestMessages,
setBrowsingHistory, setBrowsingHistory,
} = useChatScreen(props); } = 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 displayMessages = useMemo(() => [...messages].reverse(), [messages]);
const longPressMenuMemberMap = useMemo(() => { const longPressMenuMemberMap = useMemo(() => {
@@ -451,7 +476,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0} 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 <ChatHeader
@@ -460,7 +485,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
otherUser={otherUser} otherUser={otherUser}
routeGroupName={effectiveGroupName ?? undefined} routeGroupName={effectiveGroupName ?? undefined}
typingHint={typingHint} typingHint={typingHint}
onBack={props.onEmbeddedBack ? props.onEmbeddedBack : () => router.back()} onBack={props.onEmbeddedBack || stableOnBack}
onTitlePress={navigateToInfo} onTitlePress={navigateToInfo}
onMorePress={navigateToChatSettings} onMorePress={navigateToChatSettings}
onGroupInfoPress={handleGroupInfoPress} onGroupInfoPress={handleGroupInfoPress}
@@ -470,9 +495,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
{/* 消息列表 */} {/* 消息列表 */}
<View <View
style={styles.messageListContainer} style={styles.messageListContainer}
onLayout={e => { onLayout={stableOnLayout}
scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height;
}}
onTouchEnd={handleDismiss} onTouchEnd={handleDismiss}
> >
{loading ? ( {loading ? (
@@ -493,16 +516,9 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
scrollEnabled={true} scrollEnabled={true}
drawDistance={250} drawDistance={250}
onScroll={handleMessageListScroll} onScroll={handleMessageListScroll}
onScrollBeginDrag={() => { onScrollBeginDrag={stableOnScrollBeginDrag}
isUserDraggingRef.current = true; onScrollEndDrag={stableOnScrollEndDrag}
handleDismiss(); onMomentumScrollEnd={stableOnMomentumScrollEnd}
}}
onScrollEndDrag={() => {
isUserDraggingRef.current = false;
}}
onMomentumScrollEnd={() => {
isUserDraggingRef.current = false;
}}
scrollEventThrottle={16} scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange} onContentSizeChange={handleContentSizeChange}
/> />
@@ -568,12 +584,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
pendingAttachments={pendingAttachments} pendingAttachments={pendingAttachments}
onRemovePendingAttachment={removePendingAttachment} onRemovePendingAttachment={removePendingAttachment}
onCancelReply={handleCancelReply} onCancelReply={handleCancelReply}
onFocus={() => { onFocus={stableOnFocusInput}
// 输入框获得焦点时,关闭其他面板(但不要关闭键盘)
if (activePanel !== 'none' && activePanel !== 'mention') {
closePanel();
}
}}
currentUser={currentUser} currentUser={currentUser}
otherUser={otherUser} otherUser={otherUser}
getSenderInfo={getSenderInfo} getSenderInfo={getSenderInfo}
@@ -587,7 +598,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
onInsertEmoji={handleInsertEmoji} onInsertEmoji={handleInsertEmoji}
onInsertSticker={handleSendSticker} onInsertSticker={handleSendSticker}
onClose={closePanel} onClose={closePanel}
onFocusInput={() => textInputRef.current?.focus()} onFocusInput={stableFocusTextInput}
/> />
</View> </View>
)} )}
@@ -657,10 +668,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
{/* 图片查看器 */} {/* 图片查看器 */}
<ImageGallery <ImageGallery
visible={showImageViewer} visible={showImageViewer}
images={chatImages.map(img => ({ images={stableGalleryImages}
id: img.id || img.url || String(Math.random()),
url: img.url || img.uri || ''
}))}
initialIndex={selectedImageIndex} initialIndex={selectedImageIndex}
onClose={handleCloseImageViewer} onClose={handleCloseImageViewer}
enableSave enableSave

View File

@@ -21,7 +21,7 @@ import {
Dimensions, Dimensions,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; 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 { useLocalSearchParams, useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
@@ -837,6 +837,19 @@ const GroupInfoScreen: React.FC = () => {
thumbColor={isNotificationMuted ? colors.primary.main : colors.background.paper} thumbColor={isNotificationMuted ? colors.primary.main : colors.background.paper}
/> />
</View> </View>
<Divider style={styles.settingDivider} />
{renderSettingItem(
'magnify',
'查找聊天记录',
undefined,
conversationId
? () => router.push(hrefs.hrefMessageSearch({
conversationId,
conversationName: group?.name || '群聊',
isGroupChat: true,
}))
: undefined,
)}
</View> </View>
</View> </View>

View File

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

View File

@@ -0,0 +1,347 @@
/**
* 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,
borderRadius,
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, AppBackButton } from '../../components/common';
import { SearchBar } 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={['bottom']}>
<View style={styles.header}>
<AppBackButton onPress={() => router.back()} />
<Text style={styles.headerTitle} numberOfLines={1}>
{conversationName}
</Text>
<View style={styles.headerSpacer} />
</View>
<View style={styles.searchWrap}>
<SearchBar
value={keyword}
onChangeText={handleTextChanged}
onSubmit={handleSubmit}
placeholder="搜索聊天记录"
autoFocus
/>
</View>
<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,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.background.paper,
},
headerTitle: {
flex: 1,
fontSize: fontSizes.lg,
fontWeight: '600',
color: colors.text.primary,
textAlign: 'center',
marginHorizontal: spacing.sm,
},
headerSpacer: {
width: 40,
},
searchWrap: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.background.paper,
},
resultItem: {
flexDirection: 'row',
alignItems: 'center',
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'; } from 'react-native';
import { FlashList, ListRenderItem } from '@shopify/flash-list'; import { FlashList, ListRenderItem } from '@shopify/flash-list';
import { SafeAreaView } from 'react-native-safe-area-context'; 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 { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { spacing, borderRadius, useAppColors, useResolvedColorScheme, type AppColors } from '../../theme'; 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 { Text, ResponsiveContainer, AppBackButton } from '../../components/common';
import { useCursorPagination } from '../../hooks/useCursorPagination'; import { useCursorPagination } from '../../hooks/useCursorPagination';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
import { useMessageManagerSystemUnreadCount } from '../../stores'; import { useMessageManagerSystemUnreadCount, routePayloadCache } from '../../stores';
import { messageManager } from '../../stores/message'; import { messageManager } from '../../stores/message';
const MESSAGE_TYPES = [ const MESSAGE_TYPES = [
@@ -331,8 +331,10 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
router.push(hrefs.hrefUserProfile(extra_data.actor_id_str)); router.push(hrefs.hrefUserProfile(extra_data.actor_id_str));
} }
} else if (system_type === 'group_join_apply') { } else if (system_type === 'group_join_apply') {
routePayloadCache.stashSystemMessage(message);
router.push(hrefs.hrefGroupRequestDetail(message)); router.push(hrefs.hrefGroupRequestDetail(message));
} else if (system_type === 'group_invite') { } else if (system_type === 'group_invite') {
routePayloadCache.stashSystemMessage(message);
router.push(hrefs.hrefGroupInviteDetail(message)); router.push(hrefs.hrefGroupInviteDetail(message));
} }
// 其他类型暂不处理跳转 // 其他类型暂不处理跳转

View File

@@ -145,8 +145,11 @@ const PrivateChatInfoScreen: React.FC = () => {
// 查找聊天记录 // 查找聊天记录
const handleSearchMessages = () => { const handleSearchMessages = () => {
// TODO: 实现聊天记录搜索功能 router.push(hrefs.hrefMessageSearch({
Alert.alert('提示', '聊天记录搜索功能开发中'); conversationId,
conversationName: userName || user?.nickname || '私聊',
isGroupChat: false,
}));
}; };
// 查看用户资料 // 查看用户资料

View File

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

View File

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

View File

@@ -476,23 +476,102 @@ const renderVideoSegment = (data: VideoSegmentData, isMe: boolean): React.ReactN
return <VideoSegment key={`video-${data.url}`} data={data} isMe={isMe} />; 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 * 渲染文件 Segment
* 当 data.expired 为 true 时显示"文件已过期"失效态,禁用点击。
*/ */
const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ data, isMe }) => { const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ data, isMe }) => {
const styles = useSegmentStyles(); const styles = useSegmentStyles();
const themeColors = useAppColors(); const themeColors = useAppColors();
const [downloading, setDownloading] = useState(false);
const fileSize = data.size ? formatFileSize(data.size) : ''; 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 ( return (
<TouchableOpacity <TouchableOpacity
style={[styles.fileContainer, isMe ? styles.fileMe : styles.fileOther]} style={[styles.fileContainer, isMe ? styles.fileMe : styles.fileOther]}
onPress={handlePress}
activeOpacity={0.7} activeOpacity={0.7}
> >
<View style={styles.fileIcon}> <View style={[styles.fileIcon, { backgroundColor: `${visual.color}22` }]}>
<MaterialCommunityIcons <MaterialCommunityIcons
name="file-document" name={visual.icon as any}
size={28} size={26}
color={isMe ? themeColors.primary.contrast : themeColors.primary.main} color={isMe ? themeColors.primary.contrast : visual.color}
/> />
</View> </View>
<View style={styles.fileInfo}> <View style={styles.fileInfo}>
@@ -502,8 +581,15 @@ const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ d
> >
{data.name} {data.name}
</Text> </Text>
{fileSize ? <Text style={styles.fileSize}>{fileSize}</Text> : null} <Text style={styles.fileSize}>
{downloading ? '下载中…' : (fileSize || (data.mime_type || '文件'))}
</Text>
</View> </View>
<MaterialCommunityIcons
name={downloading ? 'progress-download' : 'download-outline'}
size={20}
color={isMe ? themeColors.primary.contrast : themeColors.chat.textTertiary}
/>
</TouchableOpacity> </TouchableOpacity>
); );
}; };
@@ -1021,6 +1107,14 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
fontWeight: '500', fontWeight: '500',
}, },
// 文件过期失效态
fileExpired: {
opacity: 0.65,
},
fileExpiredText: {
color: colors.chat.textTertiary,
},
// 链接 - QQ风格卡片式设计 // 链接 - QQ风格卡片式设计
linkContainer: { linkContainer: {
borderRadius: 16, borderRadius: 16,

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

View File

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

View File

@@ -208,11 +208,6 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
backgroundColor: 'rgba(255, 255, 255, 0.96)', backgroundColor: 'rgba(255, 255, 255, 0.96)',
borderWidth: StyleSheet.hairlineWidth, borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(0, 0, 0, 0.08)', borderColor: 'rgba(0, 0, 0, 0.08)',
shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.12,
shadowRadius: 6,
elevation: 4,
}, },
jumpToLatestFabText: { jumpToLatestFabText: {
fontSize: 13, fontSize: 13,
@@ -312,11 +307,6 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
messageBubbleOuter: { messageBubbleOuter: {
borderRadius: messageRadius, borderRadius: messageRadius,
minWidth: 44, minWidth: 44,
shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 2,
elevation: 1,
}, },
myBubbleOuter: { myBubbleOuter: {
borderTopRightRadius: Math.max(4, messageRadius * 0.3), // 箭头在右上 borderTopRightRadius: Math.max(4, messageRadius * 0.3), // 箭头在右上
@@ -556,7 +546,7 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
// 输入框@高亮 // 输入框@高亮
inputHighlightOverlay: { inputHighlightOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFill,
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',
paddingTop: 8, paddingTop: 8,
@@ -1077,7 +1067,7 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
// 遮罩层 // 遮罩层
overlay: { overlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFill,
backgroundColor: 'rgba(0, 0, 0, 0.5)', backgroundColor: 'rgba(0, 0, 0, 0.5)',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -1147,7 +1137,7 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
justifyContent: 'flex-end', justifyContent: 'flex-end',
}, },
manageModalBackdrop: { manageModalBackdrop: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFill,
backgroundColor: 'rgba(0, 0, 0, 0.4)', backgroundColor: 'rgba(0, 0, 0, 0.4)',
}, },
manageModalHandle: { manageModalHandle: {
@@ -1170,7 +1160,7 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
borderColor: colors.chat.replyBorder, borderColor: colors.chat.replyBorder,
}, },
stickerCheckOverlay: { stickerCheckOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFill,
backgroundColor: 'rgba(127, 182, 230, 0.14)', backgroundColor: 'rgba(127, 182, 230, 0.14)',
alignItems: 'flex-end', alignItems: 'flex-end',
justifyContent: 'flex-start', justifyContent: 'flex-start',

View File

@@ -2,7 +2,7 @@
* ChatScreen 类型定义 * ChatScreen 类型定义
*/ */
import { MessageResponse, UserDTO, GroupMemberResponse, GroupResponse } from '../../../../types/dto'; import { MessageResponse, UserDTO, GroupMemberResponse } from '../../../../types/dto';
// 面板类型 // 面板类型
export type PanelType = 'none' | 'emoji' | 'more' | 'mention'; export type PanelType = 'none' | 'emoji' | 'more' | 'mention';
@@ -33,6 +33,7 @@ export interface ChatRouteParams {
isGroupChat?: boolean; isGroupChat?: boolean;
groupId?: string; groupId?: string;
groupName?: string; groupName?: string;
scrollToSeq?: number;
} }
// 发送者信息 // 发送者信息
@@ -214,39 +215,3 @@ export interface SwipeableMessageBubbleProps {
onReply: () => void; onReply: () => void;
enabled?: boolean; enabled?: boolean;
} }
// ChatScreen 状态接口
export interface ChatScreenState {
// 基础状态
messages: GroupMessage[];
conversationId: string | null;
inputText: string;
otherUser: UserDTO | null;
currentUser: UserDTO | null;
keyboardHeight: number;
loading: boolean;
sending: boolean;
currentUserId: string;
lastSeq: number;
otherUserLastReadSeq: number;
activePanel: PanelType;
sendingImage: boolean;
// 回复消息状态
replyingTo: GroupMessage | null;
// 长按菜单状态
longPressMenuVisible: boolean;
selectedMessage: GroupMessage | null;
// 群聊相关状态
groupInfo: GroupResponse | null;
groupMembers: GroupMemberResponse[];
typingUsers: string[];
currentUserRole: UserRole;
mentionQuery: string;
selectedMentions: string[];
mentionAll: boolean;
isMuted: boolean;
muteAll: boolean;
}

View File

@@ -20,12 +20,15 @@ import {
import { useLocalSearchParams, router } from 'expo-router'; import { useLocalSearchParams, router } from 'expo-router';
import { formatChatTime } from '@/utils/formatTime'; import { formatChatTime } from '@/utils/formatTime';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { GroupMemberResponse, MessageSegment, TextSegmentData, ImageSegmentData, AtSegmentData, ReplySegmentData, MessageStatus } from '../../../../types/dto'; import * as DocumentPicker from 'expo-document-picker';
import { Linking } from 'react-native';
import { GroupMemberResponse, MessageSegment, TextSegmentData, ImageSegmentData, FileSegmentData, AtSegmentData, ReplySegmentData, MessageStatus } from '../../../../types/dto';
import { messageService } from '@/services/message'; import { messageService } from '@/services/message';
import { uploadService } from '@/services/upload'; import { uploadService } from '@/services/upload';
import { ApiError } from '@/services/core'; import { ApiError } from '@/services/core';
// 【新架构】使用 MessageManager // 【新架构】使用 MessageManager
import { useChat, useGroupTyping, useGroupMuted, messageManager, callStore } from '../../../../stores'; import { useChat, useGroupTyping, useGroupMuted, messageManager, callStore } from '../../../../stores';
import { useMessageStore } from '../../../../stores/message';
import { groupService } from '@/services/message'; import { groupService } from '@/services/message';
import { userManager } from '../../../../stores/user'; import { userManager } from '../../../../stores/user';
import { groupManager } from '../../../../stores/group'; import { groupManager } from '../../../../stores/group';
@@ -97,6 +100,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
groupId?: string | string[]; groupId?: string | string[];
groupName?: string | string[]; groupName?: string | string[];
groupAvatar?: string | string[]; groupAvatar?: string | string[];
scrollToSeq?: string | string[];
}>(); }>();
// 路由参数(动态段 + query群 ID 勿用 Number(),避免超过 2^53-1 时精度丢失 // 路由参数(动态段 + query群 ID 勿用 Number(),避免超过 2^53-1 时精度丢失
// 嵌入式模式下优先使用 props否则从路由参数获取 // 嵌入式模式下优先使用 props否则从路由参数获取
@@ -112,6 +116,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
}; };
} }
const isGroupFlag = firstRouteParam(rawParams.isGroupChat); const isGroupFlag = firstRouteParam(rawParams.isGroupChat);
const scrollToSeqParam = firstRouteParam(rawParams.scrollToSeq);
return { return {
conversationId: firstRouteParam(rawParams.conversationId) ?? null, conversationId: firstRouteParam(rawParams.conversationId) ?? null,
userId: firstRouteParam(rawParams.userId) ?? null, userId: firstRouteParam(rawParams.userId) ?? null,
@@ -119,6 +124,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
groupId: firstRouteParam(rawParams.groupId), groupId: firstRouteParam(rawParams.groupId),
groupName: firstRouteParam(rawParams.groupName), groupName: firstRouteParam(rawParams.groupName),
groupAvatar: firstRouteParam(rawParams.groupAvatar), groupAvatar: firstRouteParam(rawParams.groupAvatar),
scrollToSeq: scrollToSeqParam ? Number(scrollToSeqParam) : null,
}; };
}, [ }, [
props?.embeddedConversationId, props?.embeddedConversationId,
@@ -131,9 +137,10 @@ export const useChatScreen = (props?: ChatScreenProps) => {
rawParams.groupId, rawParams.groupId,
rawParams.groupName, rawParams.groupName,
rawParams.groupAvatar, rawParams.groupAvatar,
rawParams.scrollToSeq,
]); ]);
const { conversationId: routeConversationId, userId: routeUserId, isGroupChat, groupId: routeGroupId, groupName: routeGroupName, groupAvatar: routeGroupAvatar } = routeParams; const { conversationId: routeConversationId, userId: routeUserId, isGroupChat, groupId: routeGroupId, groupName: routeGroupName, groupAvatar: routeGroupAvatar, scrollToSeq: routeScrollToSeq } = routeParams;
// 当前会话ID可能在新建私聊会话后动态更新 // 当前会话ID可能在新建私聊会话后动态更新
const [conversationId, setConversationId] = useState<string | null>(routeConversationId); const [conversationId, setConversationId] = useState<string | null>(routeConversationId);
@@ -176,8 +183,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
const [keyboardHeight, setKeyboardHeight] = useState(0); const [keyboardHeight, setKeyboardHeight] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [lastSeq, setLastSeq] = useState<number>(0);
const [firstSeq, setFirstSeq] = useState<number>(0);
const [otherUserLastReadSeq, setOtherUserLastReadSeq] = useState<number>(0); const [otherUserLastReadSeq, setOtherUserLastReadSeq] = useState<number>(0);
const [activePanel, setActivePanel] = useState<PanelType>('none'); const [activePanel, setActivePanel] = useState<PanelType>('none');
const [sendingImage, setSendingImage] = useState(false); const [sendingImage, setSendingImage] = useState(false);
@@ -191,15 +196,14 @@ export const useChatScreen = (props?: ChatScreenProps) => {
// 滚动状态机 refsTelegram/Element 风格:底部粘附 + 阅读锚点) // 滚动状态机 refsTelegram/Element 风格:底部粘附 + 阅读锚点)
const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0, viewportHeight: 0 }); const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0, viewportHeight: 0 });
const hasInitialAnchorDoneRef = useRef(false); const hasInitialAnchorDoneRef = useRef(false);
const prevMessageCountRef = useRef(0);
const prevLatestSeqRef = useRef(0); const prevLatestSeqRef = useRef(0);
const prevMarkedReadSeqRef = useRef(0); const prevMarkedReadSeqRef = useRef(0);
const enterMarkedKeyRef = useRef<string>(''); const enterMarkedKeyRef = useRef<string>('');
const isProgrammaticScrollRef = useRef(false);
const suppressAutoFollowRef = useRef(false); const suppressAutoFollowRef = useRef(false);
const isBrowsingHistoryRef = useRef(false); const isBrowsingHistoryRef = useRef(false);
const hasShownMessageListRef = useRef(false); const hasShownMessageListRef = useRef(false);
const historyLoadingLockUntilRef = useRef(0); const historyLoadingLockUntilRef = useRef(0);
const scrollToSeqRef = useRef<number | null>(null);
// 回复消息状态 // 回复消息状态
const [replyingTo, setReplyingTo] = useState<GroupMessage | null>(null); const [replyingTo, setReplyingTo] = useState<GroupMessage | null>(null);
@@ -223,6 +227,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
setGroupInfo(prev => prev ? prev : { name: name || undefined, avatar: avatar || null }); setGroupInfo(prev => prev ? prev : { name: name || undefined, avatar: avatar || null });
} }
}, [isGroupChat, effectiveGroupName, effectiveGroupAvatar]); }, [isGroupChat, effectiveGroupName, effectiveGroupAvatar]);
const [currentUserRole, setCurrentUserRole] = useState<UserRole>('member'); const [currentUserRole, setCurrentUserRole] = useState<UserRole>('member');
const [mentionQuery, setMentionQuery] = useState<string>(''); const [mentionQuery, setMentionQuery] = useState<string>('');
const [selectedMentions, setSelectedMentions] = useState<string[]>([]); const [selectedMentions, setSelectedMentions] = useState<string[]>([]);
@@ -290,17 +295,14 @@ export const useChatScreen = (props?: ChatScreenProps) => {
}, [followRestricted, myPrivateSentCount]); }, [followRestricted, myPrivateSentCount]);
// 加载态语义修正: // 加载态语义修正:
// isLoadingMessages 在分页加载历史时也会短暂为 true。 // 首屏加载:尚未展示过消息列表 且 无消息 → 显示 loading避免 FlashList 以空 data 挂载后 inverted 布局异常)
// 若直接驱动 ChatScreen 的 loading会导致消息列表被卸载重挂载触发“回到底部”。 // 一旦有消息或加载完成isLoadingMessages 归 false即解除 loading
// 这里只在“首屏且尚未展示过消息列表”时展示 loading 占位。
useEffect(() => { useEffect(() => {
if (messageManagerMessages.length > 0) { if (messageManagerMessages.length > 0) {
hasShownMessageListRef.current = true; hasShownMessageListRef.current = true;
} }
const shouldShowInitialLoading = const shouldShowInitialLoading =
!hasShownMessageListRef.current && !hasShownMessageListRef.current && messageManagerMessages.length === 0 && isLoadingMessages;
isLoadingMessages &&
messageManagerMessages.length === 0;
setLoading(shouldShowInitialLoading); setLoading(shouldShowInitialLoading);
}, [isLoadingMessages, messageManagerMessages.length]); }, [isLoadingMessages, messageManagerMessages.length]);
@@ -366,10 +368,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
}; };
}, [isGroupChat, otherUserId]); }, [isGroupChat, otherUserId]);
// 进入新会话时重置滚动状态 // 进入新会话时重置滚动/草稿状态
useEffect(() => { useEffect(() => {
hasInitialAnchorDoneRef.current = false; hasInitialAnchorDoneRef.current = false;
prevMessageCountRef.current = 0;
prevLatestSeqRef.current = 0; prevLatestSeqRef.current = 0;
prevMarkedReadSeqRef.current = 0; prevMarkedReadSeqRef.current = 0;
enterMarkedKeyRef.current = ''; enterMarkedKeyRef.current = '';
@@ -377,9 +378,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
isBrowsingHistoryRef.current = false; isBrowsingHistoryRef.current = false;
hasShownMessageListRef.current = false; hasShownMessageListRef.current = false;
historyLoadingLockUntilRef.current = 0; historyLoadingLockUntilRef.current = 0;
}, [conversationId]); scrollToSeqRef.current = routeScrollToSeq ?? null;
useEffect(() => {
setPendingAttachments([]); setPendingAttachments([]);
}, [conversationId]); }, [conversationId]);
@@ -399,14 +398,10 @@ export const useChatScreen = (props?: ChatScreenProps) => {
const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => { const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => {
if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked())) return; if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked())) return;
// inverted 列表下,最新消息端对应 offset=0 // inverted 列表下,最新消息端对应 offset=0
isProgrammaticScrollRef.current = true;
flatListRef.current?.scrollToOffset({ flatListRef.current?.scrollToOffset({
offset: 0, offset: 0,
animated, animated,
}); });
setTimeout(() => {
isProgrammaticScrollRef.current = false;
}, animated ? 220 : 32);
}, [flatListRef, isHistoryLoadingLocked]); }, [flatListRef, isHistoryLoadingLocked]);
const isNearBottom = useCallback(() => { const isNearBottom = useCallback(() => {
@@ -415,18 +410,24 @@ export const useChatScreen = (props?: ChatScreenProps) => {
return scrollY <= 100; return scrollY <= 100;
}, [scrollPositionRef]); }, [scrollPositionRef]);
// 首屏加载完成后仅锚定一次到最新消息端 // 首屏加载完成后仅锚定一次到最新消息端(有 scrollToSeq 时跳过)
// 注意:必须在消息首次出现时立刻把 hasInitialAnchorDoneRef 置为 true
// 不能再依赖 viewportHeight 等通过 ref 同步的值——否则后续首次 loadMoreHistory
// 让 messages.length 变化时此 effect 会被再度命中,并以 force=true 调用
// scrollToLatest 强行把列表拉回最底端,造成"上滑首次加载更多就回底"的问题。
useEffect(() => { useEffect(() => {
if ( if (
loading || loading ||
loadingMore || loadingMore ||
messages.length === 0 || messages.length === 0 ||
hasInitialAnchorDoneRef.current || hasInitialAnchorDoneRef.current
scrollPositionRef.current.viewportHeight <= 0
) { ) {
return; return;
} }
hasInitialAnchorDoneRef.current = true; hasInitialAnchorDoneRef.current = true;
if (scrollToSeqRef.current != null) return; // 由下方 scrollToSeq effect 处理
// inverted FlashList 天然从 offset=0最新端开始
// 这里仅做一次保险锚定,即使布局尚未就绪也不会有副作用。
const timer = setTimeout(() => { const timer = setTimeout(() => {
scrollToLatest(false, true, 'initial-anchor'); scrollToLatest(false, true, 'initial-anchor');
}, 0); }, 0);
@@ -437,10 +438,8 @@ export const useChatScreen = (props?: ChatScreenProps) => {
// 仅当"最新端消息 seq 增长"且用户在底部附近时才跟随; // 仅当"最新端消息 seq 增长"且用户在底部附近时才跟随;
// 历史加载只会增加旧消息,不会提升 latest seq因此不会触发回底。 // 历史加载只会增加旧消息,不会提升 latest seq因此不会触发回底。
useEffect(() => { useEffect(() => {
const currentCount = messages.length; const latestSeq = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
const latestSeq = currentCount > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
const prevLatestSeq = prevLatestSeqRef.current; const prevLatestSeq = prevLatestSeqRef.current;
prevMessageCountRef.current = currentCount;
prevLatestSeqRef.current = latestSeq; prevLatestSeqRef.current = latestSeq;
if (loading || loadingMore) return; if (loading || loadingMore) return;
@@ -570,33 +569,20 @@ export const useChatScreen = (props?: ChatScreenProps) => {
}; };
}, [routeUserId, conversationId, currentUserId, isGroupChat]); }, [routeUserId, conversationId, currentUserId, isGroupChat]);
// 加载更多历史消息inverted 下保持阅读锚点 // 加载更多历史消息inverted + maintainVisibleContentPosition 由列表原生保持位置
const loadMoreHistory = useCallback(async () => { const loadMoreHistory = useCallback(async () => {
if (!conversationId || !hasMoreHistory || loadingMore) { if (!conversationId || !hasMoreHistory || loadingMore) {
return; return;
} }
// 历史加载期间禁止新消息自动跟随到底 // 历史加载期间禁止"新消息自动跟随到底"
suppressAutoFollowRef.current = true; suppressAutoFollowRef.current = true;
isBrowsingHistoryRef.current = true; isBrowsingHistoryRef.current = true;
historyLoadingLockUntilRef.current = Date.now() + 3000; historyLoadingLockUntilRef.current = Date.now() + 3000;
// 保存加载前的滚动位置和内容高度
const scrollYBefore = scrollPositionRef.current.scrollY;
const contentHeightBefore = scrollPositionRef.current.contentHeight;
setLoadingMore(true); setLoadingMore(true);
try { try {
await loadMoreMessages(); await loadMoreMessages();
// 更新 firstSeq
if (messages.length > 0) {
const minSeq = Math.min(...messages.map(m => m.seq));
setFirstSeq(minSeq);
}
// inverted + maintainVisibleContentPosition 下由列表原生保持位置
// 不做手动 scrollToOffset避免与原生锚点冲突导致回到底部
} catch (error) { } catch (error) {
console.error('加载历史消息失败:', error); console.error('加载历史消息失败:', error);
} finally { } finally {
@@ -604,7 +590,37 @@ export const useChatScreen = (props?: ChatScreenProps) => {
// 加载结束后继续保留短暂锁窗,避免布局结算阶段误触自动回底 // 加载结束后继续保留短暂锁窗,避免布局结算阶段误触自动回底
historyLoadingLockUntilRef.current = Date.now() + 800; historyLoadingLockUntilRef.current = Date.now() + 800;
} }
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]); }, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages]);
// 从搜索结果跳转:滚动到目标 seq
useEffect(() => {
const targetSeq = scrollToSeqRef.current;
if (targetSeq == null || !hasInitialAnchorDoneRef.current) return;
if (loading || loadingMore) return;
const found = messages.find(m => m.seq === targetSeq);
if (found) {
scrollToSeqRef.current = null;
isBrowsingHistoryRef.current = true;
suppressAutoFollowRef.current = true;
const ascendingIndex = messages.findIndex(m => m.seq === targetSeq);
const displayIndex = messages.length - 1 - ascendingIndex;
setTimeout(() => {
flatListRef.current?.scrollToIndex({
index: displayIndex,
animated: false,
viewPosition: 0.5,
});
}, 50);
return;
}
if (hasMoreHistory) {
loadMoreHistory();
} else {
scrollToSeqRef.current = null;
}
}, [loading, loadingMore, messages, hasMoreHistory, loadMoreHistory]);
// 列表内容尺寸变化:仅同步内容高度,锚点补偿由 loadMoreHistory 统一处理 // 列表内容尺寸变化:仅同步内容高度,锚点补偿由 loadMoreHistory 统一处理
const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => { const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
@@ -623,13 +639,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
isBrowsingHistoryRef.current = false; isBrowsingHistoryRef.current = false;
// FlashList 在 inverted 模式下 scrollToOffset({ offset: 0 }) 可能无法真正滚到最底部, // FlashList 在 inverted 模式下 scrollToOffset({ offset: 0 }) 可能无法真正滚到最底部,
// 因为列表内容高度变化后最小 offset 可能不是 0。先尝试滚到负值确保到底再补偿回 0。 // 因为列表内容高度变化后最小 offset 可能不是 0。先尝试滚到负值确保到底再补偿回 0。
isProgrammaticScrollRef.current = true;
flatListRef.current?.scrollToOffset({ offset: -99999, animated: false }); flatListRef.current?.scrollToOffset({ offset: -99999, animated: false });
requestAnimationFrame(() => { requestAnimationFrame(() => {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
setTimeout(() => {
isProgrammaticScrollRef.current = false;
}, 220);
}); });
}, [flatListRef]); }, [flatListRef]);
@@ -663,6 +675,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
// 自动标记已读QQ/Telegram 风格): // 自动标记已读QQ/Telegram 风格):
// 仅当出现更大的 latest seq且用户在最新端附近时才上报。 // 仅当出现更大的 latest seq且用户在最新端附近时才上报。
// 历史加载/浏览历史不会触发 read。 // 历史加载/浏览历史不会触发 read。
// 注意:不能复用 prevLatestSeqRef 做判定——它被新消息跟随 effect 无条件推进,
// 会导致这里的去重判断永远成立,标记已读逻辑事实上不会执行。
// 这里只依赖专属的 prevMarkedReadSeqRef 做去重。
useEffect(() => { useEffect(() => {
if (!conversationId || messages.length === 0) return; if (!conversationId || messages.length === 0) return;
if (loading || loadingMore) return; if (loading || loadingMore) return;
@@ -672,10 +687,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
const latestSeq = Math.max(...messages.map(m => m.seq), 0); const latestSeq = Math.max(...messages.map(m => m.seq), 0);
if (latestSeq <= 0) return; if (latestSeq <= 0) return;
// 没有新增最新消息,不重复上报
if (latestSeq <= prevLatestSeqRef.current) return;
prevLatestSeqRef.current = latestSeq;
// 避免对同一 seq 重复标记 // 避免对同一 seq 重复标记
if (latestSeq <= prevMarkedReadSeqRef.current) return; if (latestSeq <= prevMarkedReadSeqRef.current) return;
prevMarkedReadSeqRef.current = latestSeq; prevMarkedReadSeqRef.current = latestSeq;
@@ -685,18 +696,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
}); });
}, [messages, conversationId, markAsRead, loading, loadingMore, isNearBottom]); }, [messages, conversationId, markAsRead, loading, loadingMore, isNearBottom]);
// 使用 ref 存储 groupMembers
const groupMembersRef = useRef(groupMembers);
useEffect(() => {
groupMembersRef.current = groupMembers;
}, [groupMembers]);
// 使用 ref 存储 currentUserId
const currentUserIdRef = useRef(currentUserId);
useEffect(() => {
currentUserIdRef.current = currentUserId;
}, [currentUserId]);
// 监听键盘事件 // 监听键盘事件
useEffect(() => { useEffect(() => {
const keyboardWillShow = (e: KeyboardEvent) => { const keyboardWillShow = (e: KeyboardEvent) => {
@@ -1014,6 +1013,38 @@ export const useChatScreen = (props?: ChatScreenProps) => {
return segments; return segments;
}, []); }, []);
/**
* 构建文件消息的 segments 数组
*/
const buildFileSegments = useCallback((
fileUrl: string,
name: string,
size?: number,
mimeType?: string,
replyToMessage?: GroupMessage | null,
): MessageSegment[] => {
const segments: MessageSegment[] = [];
if (replyToMessage) {
segments.push({
type: 'reply',
data: { id: replyToMessage.id, seq: replyToMessage.seq } as ReplySegmentData
});
}
segments.push({
type: 'file',
data: {
url: fileUrl,
name,
size,
mime_type: mimeType,
} as FileSegmentData
});
return segments;
}, []);
// 【新架构】发送消息(支持纯文字、纯多图、图文同条) // 【新架构】发送消息(支持纯文字、纯多图、图文同条)
const handleSend = useCallback(async () => { const handleSend = useCallback(async () => {
const trimmedText = inputText.trim(); const trimmedText = inputText.trim();
@@ -1069,7 +1100,10 @@ export const useChatScreen = (props?: ChatScreenProps) => {
setSending(true); setSending(true);
try { try {
const segments: MessageSegment[] = [...buildTextSegments(trimmedText, replyingTo)]; // 有文本或回复时才构建文本段,纯图片时不塞空 text
const segments: MessageSegment[] = (trimmedText || replyingTo)
? [...buildTextSegments(trimmedText, replyingTo)]
: [];
for (const url of uploadedUrls) { for (const url of uploadedUrls) {
segments.push({ segments.push({
type: 'image', type: 'image',
@@ -1215,6 +1249,89 @@ export const useChatScreen = (props?: ChatScreenProps) => {
setActivePanel('none'); setActivePanel('none');
}, [pendingAttachments.length]); }, [pendingAttachments.length]);
// 选择并发送文件(单选,立即上传后发送)
const handlePickFile = useCallback(async () => {
if (!conversationId) {
setActivePanel('none');
return;
}
if (isGroupChat && isMuted) {
Alert.alert('无法发送', muteAll ? '当前群组已开启全员禁言' : '你已被管理员禁言');
setActivePanel('none');
return;
}
setActivePanel('none');
try {
const result = await DocumentPicker.getDocumentAsync({
multiple: false,
copyToCacheDirectory: true,
});
if (result.canceled || !result.assets || result.assets.length === 0) {
return;
}
const asset = result.assets[0];
setUploadingAttachments(true);
const uploaded = await uploadService.uploadFile(
{
uri: asset.uri,
name: asset.name,
type: asset.mimeType || undefined,
},
'chat'
);
if (!uploaded?.url) {
Alert.alert('上传失败', getSendErrorMessage(null, '文件上传失败,请重试'));
return;
}
const segments = buildFileSegments(
uploaded.url,
uploaded.name || asset.name,
uploaded.size ?? asset.size,
uploaded.mime_type ?? asset.mimeType,
replyingTo,
);
setSending(true);
try {
if (isGroupChat && effectiveGroupId) {
await messageService.sendMessageByAction('group', conversationId, segments);
} else {
await sendMessageViaManager(segments);
}
setReplyingTo(null);
setTimeout(() => scrollToLatest(false, false, 'send-file'), 100);
} catch (error) {
console.error('发送文件消息失败:', error);
Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试'));
} finally {
setSending(false);
}
} catch (error) {
console.error('选择文件失败:', error);
Alert.alert('错误', getSendErrorMessage(error, '选择文件失败'));
} finally {
setUploadingAttachments(false);
}
}, [
conversationId,
isGroupChat,
isMuted,
muteAll,
effectiveGroupId,
replyingTo,
buildFileSegments,
sendMessageViaManager,
scrollToLatest,
getSendErrorMessage,
]);
// 处理更多功能 // 处理更多功能
const handleMoreAction = useCallback((actionId: string) => { const handleMoreAction = useCallback((actionId: string) => {
switch (actionId) { switch (actionId) {
@@ -1243,17 +1360,12 @@ export const useChatScreen = (props?: ChatScreenProps) => {
handleTakePhoto(); handleTakePhoto();
break; break;
case 'file': case 'file':
Alert.alert('提示', '文件功能即将上线'); handlePickFile();
setActivePanel('none');
break;
case 'location':
Alert.alert('提示', '位置功能即将上线');
setActivePanel('none');
break; break;
default: default:
setActivePanel('none'); setActivePanel('none');
} }
}, [handlePickImage, handleTakePhoto, isGroupChat, otherUser, conversationId]); }, [handlePickImage, handleTakePhoto, handlePickFile, isGroupChat, otherUser, conversationId]);
// 插入表情 // 插入表情
const handleInsertEmoji = useCallback((emoji: string) => { const handleInsertEmoji = useCallback((emoji: string) => {
@@ -1326,20 +1438,15 @@ export const useChatScreen = (props?: ChatScreenProps) => {
setActivePanel('none'); setActivePanel('none');
}, []); }, []);
// 撤回消息 - 现在通过 MessageManager 处理 // 撤回消息 - 现在通过 MessageManager 处理(撤回事件由 MessageManager 同步状态)
const handleRecall = useCallback(async (messageId: string) => { const handleRecall = useCallback(async (messageId: string) => {
try { try {
if (isGroupChat && effectiveGroupId) {
await messageService.recallMessage(messageId); await messageService.recallMessage(messageId);
} else {
await messageService.recallMessage(messageId);
}
// 不需要手动更新状态MessageManager 会处理撤回事件
} catch (error) { } catch (error) {
console.error('撤回消息失败:', error); console.error('撤回消息失败:', error);
Alert.alert('撤回失败', '无法撤回消息'); Alert.alert('撤回失败', '无法撤回消息');
} }
}, [isGroupChat, effectiveGroupId, conversationId]); }, []);
// 长按消息显示操作菜单 // 长按消息显示操作菜单
const handleLongPressMessage = useCallback((message: GroupMessage, position?: MenuPosition) => { const handleLongPressMessage = useCallback((message: GroupMessage, position?: MenuPosition) => {
@@ -1363,24 +1470,21 @@ export const useChatScreen = (props?: ChatScreenProps) => {
const handleDeleteMessage = useCallback(async (messageId: string) => { const handleDeleteMessage = useCallback(async (messageId: string) => {
try { try {
await messageService.deleteMessage(messageId); await messageService.deleteMessage(messageId);
// 从本地状态移除MessageManager 不会自动处理删除)
const updatedMessages = messages.filter(m => m.id !== messageId);
// 注意:这里我们依赖 MessageManager 的数据,所以需要通过其他方式刷新
// 暂时只删除本地数据库
await messageRepository.delete(messageId); await messageRepository.delete(messageId);
if (conversationId) {
useMessageStore.getState().removeMessage(conversationId, messageId);
}
} catch (error) { } catch (error) {
console.error('删除消息失败:', error); console.error('删除消息失败:', error);
Alert.alert('删除失败', '无法删除消息'); Alert.alert('删除失败', '无法删除消息');
} }
}, [messages]); }, [conversationId]);
// 清空会话的所有聊天记录 // 清空会话的所有聊天记录
const handleClearConversation = useCallback(async () => { const handleClearConversation = useCallback(async () => {
if (!conversationId) return; if (!conversationId) return;
try { try {
setLastSeq(0);
setFirstSeq(0);
setHasMoreHistory(true); setHasMoreHistory(true);
await messageRepository.clearConversation(conversationId); await messageRepository.clearConversation(conversationId);
// 刷新消息列表 // 刷新消息列表
@@ -1389,9 +1493,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
console.error('清空会话失败:', error); console.error('清空会话失败:', error);
Alert.alert('清空失败', '无法清空聊天记录'); Alert.alert('清空失败', '无法清空聊天记录');
} }
}, [conversationId, refreshMessages]); }, [conversationId, refreshMessages]); // 回复消息
// 回复消息
const handleReplyMessage = useCallback((message: GroupMessage) => { const handleReplyMessage = useCallback((message: GroupMessage) => {
setReplyingTo(message); setReplyingTo(message);
textInputRef.current?.focus(); textInputRef.current?.focus();
@@ -1444,26 +1546,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
} }
}, [groupTypingUsers, groupMembers]); }, [groupTypingUsers, groupMembers]);
// 计算底部面板高度
const getPanelHeight = useCallback(() => {
if (activePanel === 'none') return 0;
if (activePanel === 'mention') return 250;
return 350;
}, [activePanel]);
// 计算输入框的bottom值
const getInputBottom = useCallback(() => {
if (keyboardHeight > 0) return keyboardHeight;
if (activePanel !== 'none') return getPanelHeight();
return 0;
}, [keyboardHeight, activePanel, getPanelHeight]);
// 计算消息列表的底部padding
const getListPaddingBottom = useCallback(() => {
if (activePanel !== 'none' && keyboardHeight === 0) return getPanelHeight();
return 0;
}, [keyboardHeight, activePanel, getPanelHeight]);
// 关闭所有面板 // 关闭所有面板
const handleDismiss = useCallback(() => { const handleDismiss = useCallback(() => {
Keyboard.dismiss(); Keyboard.dismiss();
@@ -1507,7 +1589,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
currentUserId, currentUserId,
keyboardHeight, keyboardHeight,
loading, loading,
sending,
activePanel, activePanel,
sendingImage, sendingImage,
uploadingAttachments, uploadingAttachments,
@@ -1517,16 +1598,12 @@ export const useChatScreen = (props?: ChatScreenProps) => {
longPressMenuVisible, longPressMenuVisible,
selectedMessage, selectedMessage,
selectedMessageId, selectedMessageId,
setSelectedMessageId,
menuPosition, menuPosition,
isGroupChat, isGroupChat,
groupInfo, groupInfo,
groupMembers, groupMembers,
// 【改造】使用 MessageManager 的输入状态
typingUsers: groupTypingUsers,
currentUserRole, currentUserRole,
mentionQuery, mentionQuery,
selectedMentions,
isMuted, isMuted,
muteAll, muteAll,
followRestrictionHint, followRestrictionHint,
@@ -1548,9 +1625,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
shouldShowTime, shouldShowTime,
handleInputChange, handleInputChange,
handleSend, handleSend,
handlePickImage,
removePendingAttachment, removePendingAttachment,
handleTakePhoto,
handleMoreAction, handleMoreAction,
handleInsertEmoji, handleInsertEmoji,
handleSendSticker, handleSendSticker,
@@ -1569,9 +1644,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
handleMentionAll, handleMentionAll,
getSenderInfo, getSenderInfo,
getTypingHint, getTypingHint,
getPanelHeight,
getInputBottom,
getListPaddingBottom,
handleDismiss, handleDismiss,
navigateToInfo, navigateToInfo,
navigateToChatSettings, navigateToChatSettings,

View File

@@ -12,3 +12,4 @@ export { default as PrivateChatInfoScreen } from './PrivateChatInfoScreen';
export { default as JoinGroupScreen } from './JoinGroupScreen'; export { default as JoinGroupScreen } from './JoinGroupScreen';
export { default as GroupRequestDetailScreen } from './GroupRequestDetailScreen'; export { default as GroupRequestDetailScreen } from './GroupRequestDetailScreen';
export { default as GroupInviteDetailScreen } from './GroupInviteDetailScreen'; export { default as GroupInviteDetailScreen } from './GroupInviteDetailScreen';
export { MessageSearchScreen } from './MessageSearchScreen';

View File

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

View File

@@ -1,4 +1,4 @@
/** /**
* 聊天设置页 ChatSettingsScreen * 聊天设置页 ChatSettingsScreen
* 威友 - 聊天个性化设置 * 威友 - 聊天个性化设置
*/ */
@@ -9,7 +9,6 @@ import {
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
ScrollView, ScrollView,
Dimensions,
PanResponder, PanResponder,
} from 'react-native'; } from 'react-native';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
@@ -30,7 +29,6 @@ import {
CHAT_THEMES, CHAT_THEMES,
} from '../../stores/settings'; } from '../../stores/settings';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const CARD_MAX_WIDTH = 720; const CARD_MAX_WIDTH = 720;
interface SliderProps { interface SliderProps {
@@ -43,7 +41,6 @@ interface SliderProps {
showValue?: boolean; showValue?: boolean;
} }
// 自定义滑块组件(支持拖动)
const Slider: React.FC<SliderProps> = ({ const Slider: React.FC<SliderProps> = ({
value, value,
min, min,
@@ -177,7 +174,6 @@ function createStyles(colors: AppColors) {
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 0.5, letterSpacing: 0.5,
}, },
// 聊天预览样式
previewContainer: { previewContainer: {
backgroundColor: colors.primary.light + '20', backgroundColor: colors.primary.light + '20',
borderRadius: 14, borderRadius: 14,
@@ -211,7 +207,6 @@ function createStyles(colors: AppColors) {
previewText: { previewText: {
color: colors.text.primary, color: colors.text.primary,
}, },
// 主题选择样式
themeList: { themeList: {
flexDirection: 'row', flexDirection: 'row',
gap: spacing.sm, gap: spacing.sm,
@@ -254,12 +249,6 @@ function createStyles(colors: AppColors) {
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
themeName: {
fontSize: fontSizes.xs,
textAlign: 'center',
marginTop: 4,
color: colors.text.secondary,
},
sliderContainer: { sliderContainer: {
paddingVertical: 8, paddingVertical: 8,
}, },
@@ -272,7 +261,6 @@ export const ChatSettingsScreen: React.FC = () => {
const styles = useMemo(() => createStyles(colors), [colors]); const styles = useMemo(() => createStyles(colors), [colors]);
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 }); const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
// 从 store 获取状态
const fontSize = useChatSettingsStore((s) => s.fontSize); const fontSize = useChatSettingsStore((s) => s.fontSize);
const messageRadius = useChatSettingsStore((s) => s.messageRadius); const messageRadius = useChatSettingsStore((s) => s.messageRadius);
const themeIndex = useChatSettingsStore((s) => s.themeIndex); const themeIndex = useChatSettingsStore((s) => s.themeIndex);
@@ -281,7 +269,6 @@ export const ChatSettingsScreen: React.FC = () => {
const currentTheme = CHAT_THEMES[themeIndex]; const currentTheme = CHAT_THEMES[themeIndex];
// 渲染聊天预览
const renderChatPreview = () => ( const renderChatPreview = () => (
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}></Text>
@@ -316,7 +303,6 @@ export const ChatSettingsScreen: React.FC = () => {
</View> </View>
); );
// 渲染字号滑块
const renderFontSizeSlider = () => ( const renderFontSizeSlider = () => (
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}></Text>
@@ -333,7 +319,6 @@ export const ChatSettingsScreen: React.FC = () => {
</View> </View>
); );
// 渲染主题颜色选择
const renderThemeColors = () => ( const renderThemeColors = () => (
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}></Text>
@@ -366,7 +351,6 @@ export const ChatSettingsScreen: React.FC = () => {
</View> </View>
); );
// 渲染圆角滑块
const renderRadiusSlider = () => ( const renderRadiusSlider = () => (
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}></Text>

View File

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

View File

@@ -17,7 +17,7 @@ import {
Image, Image,
} from 'react-native'; } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from "expo-router/react-navigation";
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
@@ -68,7 +68,7 @@ function createEditProfileStyles(colors: AppColors) {
alignItems: 'center', alignItems: 'center',
}, },
coverOverlay: { coverOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFill,
backgroundColor: 'rgba(0, 0, 0, 0.4)', backgroundColor: 'rgba(0, 0, 0, 0.4)',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
@@ -279,7 +279,8 @@ export const EditProfileScreen: React.FC = () => {
const styles = useMemo(() => createEditProfileStyles(colors), [colors]); const styles = useMemo(() => createEditProfileStyles(colors), [colors]);
const navigation = useNavigation(); const navigation = useNavigation();
const router = useRouter(); const router = useRouter();
const { currentUser, updateUser } = useAuthStore(); const currentUser = useAuthStore((s) => s.currentUser);
const updateUser = useAuthStore((s) => s.updateUser);
const { isWideScreen, isMobile, width } = useResponsive(); const { isWideScreen, isMobile, width } = useResponsive();
const responsivePadding = useResponsiveSpacing({ xs: 0, sm: 0, md: 0, lg: 24, xl: 32 }); const responsivePadding = useResponsiveSpacing({ xs: 0, sm: 0, md: 0, lg: 24, xl: 32 });
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();

View File

@@ -32,8 +32,8 @@ const FollowListScreen: React.FC = () => {
const { userId = '', type: typeParam } = useLocalSearchParams<{ userId?: string; type?: string }>(); const { userId = '', type: typeParam } = useLocalSearchParams<{ userId?: string; type?: string }>();
const type = typeParam === 'followers' ? 'followers' : 'following'; const type = typeParam === 'followers' ? 'followers' : 'following';
const { currentUser } = useAuthStore(); const currentUser = useAuthStore((s) => s.currentUser);
const { followUser, unfollowUser } = useUserStore(); const { followUser, unfollowUser } = useUserStore.getState();
// 响应式布局 // 响应式布局
const { isWideScreen, isDesktop, width } = useResponsive(); const { isWideScreen, isDesktop, width } = useResponsive();

View File

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

View File

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

View File

@@ -218,7 +218,7 @@ function createSettingsStyles(colors: AppColors) {
export const SettingsScreen: React.FC = () => { export const SettingsScreen: React.FC = () => {
const router = useRouter(); const router = useRouter();
const { logout } = useAuthStore(); const logout = useAuthStore((s) => s.logout);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
const responsivePadding = useResponsiveSpacing({ xs: 0, sm: 0, md: 0, lg: 24, xl: 32 }); const responsivePadding = useResponsiveSpacing({ xs: 0, sm: 0, md: 0, lg: 24, xl: 32 });

View File

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

View File

@@ -5,7 +5,7 @@
* 支持桌面端双栏布局 * 支持桌面端双栏布局
*/ */
import React, { useCallback, useMemo, useRef } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react';
import { import {
View, View,
RefreshControl, RefreshControl,
@@ -16,10 +16,72 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { useAppColors } from '../../theme'; import { useAppColors } from '../../theme';
import { Post } from '../../types'; import { Post } from '../../types';
import { PostCard, TabBar, UserProfileHeader } from '../../components/business'; import { PostCard, TabBar, UserProfileHeader } from '../../components/business';
import { Loading, EmptyState, ResponsiveContainer } from '../../components/common'; import type { PostCardAction } from '../../components/business/PostCard';
import { Loading, EmptyState, ResponsiveContainer, ImageGallery, ImageGridItem } from '../../components/common';
import { useResponsive } from '../../hooks'; import { useResponsive } from '../../hooks';
import { useUserProfile, ProfileMode, TABS, TAB_ICONS, createSharedProfileStyles } from './useUserProfile'; import { useUserProfile, ProfileMode, TABS, TAB_ICONS, createSharedProfileStyles } from './useUserProfile';
// 稳定的头部组件:切换 tab 时不会导致 UserProfileHeader 重渲染
// 因为 activeTab 只影响 TabBarUserProfileHeader 的 props 不变
interface ProfileListHeaderProps {
user: any;
isCurrentUser: boolean;
isBlocked: boolean;
handleFollow: () => void;
handleSettings?: () => void;
handleEditProfile?: () => void;
handleMessage?: () => Promise<void>;
handleBlock?: () => Promise<void>;
handleFollowingPress: () => void;
handleFollowersPress: () => void;
activeTab: number;
setActiveTab: (tab: number) => void;
tabBarContainerStyle: any;
}
const ProfileListHeader = React.memo(function ProfileListHeader({
user,
isCurrentUser,
isBlocked,
handleFollow,
handleSettings,
handleEditProfile,
handleMessage,
handleBlock,
handleFollowingPress,
handleFollowersPress,
activeTab,
setActiveTab,
tabBarContainerStyle,
}: ProfileListHeaderProps) {
if (!user) return null;
return (
<>
<UserProfileHeader
user={user}
isCurrentUser={isCurrentUser}
isBlocked={isBlocked}
onFollow={handleFollow}
onSettings={handleSettings}
onEditProfile={handleEditProfile}
onMessage={handleMessage}
onBlock={handleBlock}
onFollowingPress={handleFollowingPress}
onFollowersPress={handleFollowersPress}
/>
<View style={tabBarContainerStyle}>
<TabBar
tabs={TABS}
activeIndex={activeTab}
onTabChange={setActiveTab}
variant="modern"
icons={TAB_ICONS}
/>
</View>
</>
);
});
interface UserProfileScreenProps { interface UserProfileScreenProps {
mode: ProfileMode; mode: ProfileMode;
userId?: string; userId?: string;
@@ -55,6 +117,39 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
currentUser, currentUser,
} = useUserProfile({ mode, userId, isDesktop, isTablet }); } = useUserProfile({ mode, userId, isDesktop, isTablet });
// 图片查看器状态(与 HomeScreen / PostDetailScreen 一致)
const [showImageViewer, setShowImageViewer] = useState(false);
const [postImages, setPostImages] = useState<ImageGridItem[]>([]);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const closeImageViewer = useCallback(() => setShowImageViewer(false), []);
const stableGalleryImages = useMemo(
() =>
postImages.map((img, i) => ({
id: img.id || img.url || `img-${i}`,
url: img.url || img.uri || '',
})),
[postImages]
);
// 包装 handlePostAction在 hook 通用逻辑之上处理 imagePress
const onPostAction = useCallback(
(post: Post, action: PostCardAction) => {
if (action.type === 'imagePress') {
const images = action.payload?.images;
const imageIndex = action.payload?.imageIndex;
if (images && imageIndex !== undefined) {
setPostImages(images);
setSelectedImageIndex(imageIndex);
setShowImageViewer(true);
}
return;
}
handlePostAction(post, action);
},
[handlePostAction]
);
// 当前显示的帖子列表 // 当前显示的帖子列表
const currentPosts = activeTab === 0 ? posts : favorites; const currentPosts = activeTab === 0 ? posts : favorites;
@@ -69,12 +164,12 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
]}> ]}>
<PostCard <PostCard
post={item} post={item}
onAction={(action) => handlePostAction(item, action)} onAction={(action) => onPostAction(item, action)}
isPostAuthor={isPostAuthor} isPostAuthor={isPostAuthor}
/> />
</View> </View>
); );
}, [currentUser?.id, handlePostAction, currentPosts.length]); }, [currentUser?.id, onPostAction, currentPosts.length]);
const postKeyExtractor = useCallback((item: Post) => item.id, []); const postKeyExtractor = useCallback((item: Post) => item.id, []);
@@ -110,50 +205,7 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
); );
}, [loading, activeTab, mode, isBlockedProfile]); }, [loading, activeTab, mode, isBlockedProfile]);
// 渲染用户信息头部(与 TabBar 分离,避免切换 tab 时重渲染) // 渲染帖子项/用户不存在状态
const renderUserHeader = useMemo(() => {
if (!user) return null;
return (
<UserProfileHeader
user={user}
isCurrentUser={isCurrentUser}
isBlocked={isBlocked}
onFollow={handleFollow}
onSettings={handleSettings}
onEditProfile={handleEditProfile}
onMessage={handleMessage}
onBlock={handleBlock}
onFollowingPress={handleFollowingPress}
onFollowersPress={handleFollowersPress}
/>
);
}, [user, isCurrentUser, isBlocked, handleFollow, handleSettings, handleEditProfile, handleMessage, handleBlock, handleFollowingPress, handleFollowersPress]);
// 渲染 TabBar
const renderTabBar = useMemo(() => (
<View style={sharedStyles.tabBarContainer}>
<TabBar
tabs={TABS}
activeIndex={activeTab}
onTabChange={setActiveTab}
variant="modern"
icons={TAB_ICONS}
/>
</View>
), [activeTab, setActiveTab, sharedStyles.tabBarContainer]);
// 渲染 FlashList 头部(用户信息 + TabBar
const renderListHeader = useCallback(() => {
if (!user) return null;
return (
<>
{renderUserHeader}
{renderTabBar}
</>
);
}, [user, renderUserHeader, renderTabBar]);
// 未登录/用户不存在状态
if (mode === 'self' && !currentUser) { if (mode === 'self' && !currentUser) {
return ( return (
<SafeAreaView style={sharedStyles.container} edges={hasHeader ? ['bottom'] : ['top', 'bottom']}> <SafeAreaView style={sharedStyles.container} edges={hasHeader ? ['bottom'] : ['top', 'bottom']}>
@@ -200,7 +252,21 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
/> />
} }
> >
{renderListHeader()} <ProfileListHeader
user={user}
isCurrentUser={isCurrentUser}
isBlocked={isBlocked}
handleFollow={handleFollow}
handleSettings={handleSettings}
handleEditProfile={handleEditProfile}
handleMessage={handleMessage}
handleBlock={handleBlock}
handleFollowingPress={handleFollowingPress}
handleFollowersPress={handleFollowersPress}
activeTab={activeTab}
setActiveTab={setActiveTab}
tabBarContainerStyle={sharedStyles.tabBarContainer}
/>
</ScrollView> </ScrollView>
</View> </View>
@@ -237,6 +303,15 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
</View> </View>
</View> </View>
</ResponsiveContainer> </ResponsiveContainer>
{/* 图片查看器 */}
<ImageGallery
visible={showImageViewer}
images={stableGalleryImages}
initialIndex={selectedImageIndex}
onClose={closeImageViewer}
enableSave
/>
</SafeAreaView> </SafeAreaView>
); );
} }
@@ -248,7 +323,23 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
data={currentPosts} data={currentPosts}
renderItem={renderPostItem} renderItem={renderPostItem}
keyExtractor={postKeyExtractor} keyExtractor={postKeyExtractor}
ListHeaderComponent={renderListHeader} ListHeaderComponent={
<ProfileListHeader
user={user}
isCurrentUser={isCurrentUser}
isBlocked={isBlocked}
handleFollow={handleFollow}
handleSettings={handleSettings}
handleEditProfile={handleEditProfile}
handleMessage={handleMessage}
handleBlock={handleBlock}
handleFollowingPress={handleFollowingPress}
handleFollowersPress={handleFollowersPress}
activeTab={activeTab}
setActiveTab={setActiveTab}
tabBarContainerStyle={sharedStyles.tabBarContainer}
/>
}
ListEmptyComponent={renderEmptyPosts} ListEmptyComponent={renderEmptyPosts}
contentContainerStyle={{ paddingBottom: scrollBottomInset }} contentContainerStyle={{ paddingBottom: scrollBottomInset }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
@@ -262,6 +353,15 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
} }
drawDistance={250} drawDistance={250}
/> />
{/* 图片查看器 */}
<ImageGallery
visible={showImageViewer}
images={stableGalleryImages}
initialIndex={selectedImageIndex}
onClose={closeImageViewer}
enableSave
/>
</SafeAreaView> </SafeAreaView>
); );
}; };

View File

@@ -24,7 +24,7 @@ export const UserScreen: React.FC = () => {
<UserProfileScreen <UserProfileScreen
mode={isSelfProfile ? 'self' : 'other'} mode={isSelfProfile ? 'self' : 'other'}
userId={isSelfProfile ? undefined : userId} userId={isSelfProfile ? undefined : userId}
hasHeader={false} hasHeader
/> />
</SafeAreaView> </SafeAreaView>
); );

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