106 Commits

Author SHA1 Message Date
lan
cbe708b53b feat(message): support group avatar in navigation and chat screen
All checks were successful
Frontend CI / ota-android (push) Successful in 1m50s
Frontend CI / ota-ios (push) Successful in 1m49s
Frontend CI / build-and-push-web (push) Successful in 2m55s
Frontend CI / build-android-apk (push) Successful in 1h10m58s
Pass group avatar through notification extras, navigation hrefs, and
route parameters. Update ChatScreen to resolve group information
(ID, name, and avatar) by prioritizing route parameters (e.g., from
push notifications) and falling back to conversation data from the
MessageManager.

- Update `NotificationBootstrap` to include `groupAvatar` in query params
- Update `hrefChat` to support `groupAvatar`
- Refactor `useChatScreen` to use `effectiveGroupId`, `effectiveGroupName`,
  and `effectiveGroupAvatar`
- Ensure `MessageManager` fetches conversation details if missing when
  activating a conversation
- Update `MessageListScreen` to pass `groupAvatar` during navigation
2026-05-13 00:31:44 +08:00
lan
7e0436a799 refactor(message): simplify state management and remove redundant service methods
All checks were successful
Frontend CI / ota-ios (push) Successful in 1m36s
Frontend CI / ota-android (push) Successful in 1m36s
Frontend CI / build-and-push-web (push) Successful in 5m6s
Frontend CI / build-android-apk (push) Successful in 39m18s
Refactor the message module to streamline state management by removing the `MessageStateManager` and `SubscriptionManager` layers, relying instead on Zustand's built-in selector mechanism for reactive updates.

- Remove redundant `setConversations`, `addMessage`, and `updateMessage` actions from the Zustand store to favor more specialized update methods.
- Clean up `MessageService` by removing deprecated conversation and message sending convenience methods.
- Update service interfaces to support new synchronization capabilities including `syncBySeq` and `restartSources`.
- Simplify documentation and comments across the message store and its associated services.
2026-05-12 18:26:27 +08:00
lan
48f31e6617 feat(message): implement incremental sync and atomic unread updates
Some checks failed
Frontend CI / ota-android (push) Successful in 1m38s
Frontend CI / ota-ios (push) Successful in 1m38s
Frontend CI / build-and-push-web (push) Successful in 4m28s
Frontend CI / build-android-apk (push) Has been cancelled
Refactor the messaging system to improve synchronization efficiency and state consistency.

- Implement incremental message synchronization using sequence numbers (seq) to reduce payload size.
- Add `markAllAsRead` batch API support to optimize read receipt processing.
- Introduce atomic state updates in `useMessageStore` to prevent inconsistent UI rendering during unread count changes.
- Implement notification deduplication in `WSMessageHandler` to prevent duplicate unread increments during reconnection.
- Optimize `MessageSyncService` to prioritize incremental sync over full snapshots when local data exists.
- Refactor `ReadReceiptManager` to use optimistic updates with proper rollback mechanisms.
- Fix minor UI issues in `SettingsScreen` and `PrivacySettingsScreen` related to theme picker z-index and style application.
2026-05-12 18:04:32 +08:00
lan
7c7aaf9108 build(config): update bundle identifier and add signing plugin
All checks were successful
Frontend CI / ota-ios (push) Successful in 1m33s
Frontend CI / ota-android (push) Successful in 1m40s
Frontend CI / build-and-push-web (push) Successful in 2m37s
Frontend CI / build-android-apk (push) Successful in 1h22m51s
feat(notification): improve JPush integration and message sync reliability

- Update iOS bundle identifier to `cn.qczlit.weiyou`
- Add `withSigning` Expo plugin
- Enhance `withJPush` plugin with improved Swift AppDelegate injection logic
- Update JPush default channel to `developer-default`
- Optimize `MessageSyncService` to prevent race conditions in unread count updates
- Implement promise deduplication for `fetchUnreadCount` to prevent redundant network requests
- Add `setConversationsWithUnread` to `useMessageStore` for atomic state updates of conversations and unread counts
2026-05-12 01:28:46 +08:00
lafay
1d9c312c6c build(android): update adaptive icon assets and background color
All checks were successful
Frontend CI / ota-ios (push) Successful in 1m23s
Frontend CI / ota-android (push) Successful in 1m41s
Frontend CI / build-and-push-web (push) Successful in 3m7s
Frontend CI / build-android-apk (push) Successful in 54m19s
Update app icons and splash assets to match new branding, changing the
adaptive icon background color from light blue (#E6F4FE) to brand blue
(#0570F9) across all Android icon variants.
2026-05-09 12:43:05 +08:00
lan
d42083a4aa feat(profile): enhance settings and privacy UI with icons and theme picker
All checks were successful
Frontend CI / ota-ios (push) Successful in 1m12s
Frontend CI / ota-android (push) Successful in 1m22s
Frontend CI / build-and-push-web (push) Successful in 54s
Frontend CI / build-android-apk (push) Successful in 1h14m5s
Refactor the profile settings and privacy screens to provide a more
modern and intuitive user experience.

- Add icons to visibility options and privacy settings items
- Implement a theme selection dropdown in the settings screen
- Restructure privacy settings layout into grouped containers
- Move account management items (blocked users, deletion) from general
  settings to privacy settings
- Improve visual hierarchy and spacing in settings screens
2026-05-09 00:34:09 +08:00
lan
d0ea2c5aea refactor(business): simplify text rendering in PostContentRenderer
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 1m4s
Frontend CI / ota-ios (push) Successful in 1m35s
Frontend CI / ota-android (push) Successful in 1m35s
Frontend CI / build-android-apk (push) Failing after 16m3s
Remove redundant `<Text>` wrapper when keyword highlighting is not applied
to improve rendering efficiency and consistency.
2026-05-08 23:49:42 +08:00
lan
81ee9ba75b feat(business): enhance block editor with segment conversion and data persistence
Some checks failed
Frontend CI / ota-android (push) Successful in 1m17s
Frontend CI / ota-ios (push) Successful in 1m32s
Frontend CI / build-and-push-web (push) Successful in 3m5s
Frontend CI / build-android-apk (push) Failing after 3h12m39s
- Implement `segmentsToBlocks` utility to convert message segments into editable blocks.
- Update `BlockEditor` and `useBlockEditor` to support initial block loading and improved image upload handling.
- Add logic to `CreatePostScreen` to automatically switch to long-post mode when image segments are detected and preserve data when toggling modes.
- Update `AboutScreen` to make the ICP filing number clickable.
- Refactor mapper imports to use the updated data model paths.
2026-05-08 13:19:40 +08:00
lan
d4c3e1f268 feat(business): implement block-based content editing and rich text rendering
All checks were successful
Frontend CI / ota-android (push) Successful in 1m18s
Frontend CI / ota-ios (push) Successful in 1m31s
Frontend CI / build-and-push-web (push) Successful in 2m49s
Frontend CI / build-android-apk (push) Successful in 1h18m20s
Introduce a new `BlockEditor` component and upgrade the post/message
rendering system to support rich text segments (images, @mentions,
votes, and post references).

- Implement `BlockEditor` for long-form post creation with image
  embedding support.
- Upgrade `PostContentRenderer` and `SegmentRenderer` to handle
  complex segment types including inline images and block elements.
- Refactor `PostCard` to use segment-based content and image
  signatures for optimized memoization.
- Centralize segment partitioning logic in `segmentUtils.ts` to ensure
  consistent rendering across chat and post modules.
- Update `PostRepository` and `Post` entity to support the new
  `segments` data structure.
2026-05-08 01:57:05 +08:00
lan
ea9e51b0b0 feat(ui): implement keyword highlighting and improve search experience
All checks were successful
Frontend CI / ota-ios (push) Successful in 1m33s
Frontend CI / ota-android (push) Successful in 1m34s
Frontend CI / build-and-push-web (push) Successful in 2m31s
Frontend CI / build-android-apk (push) Successful in 1h2m56s
- Add keyword highlighting in `PostCard` and `PostContentRenderer` using `HighlightText`.
- Implement smart excerpt logic in `PostCard` to show relevant context around search keywords.
- Update `SearchBar` styles for better visual feedback and consistency.
- Enhance `SearchScreen` with modern tab variants and improved tag styling.
- Add support for updating group descriptions in `GroupInfoScreen` and `GroupService`.
- Refactor various UI components for improved layout stability and typography.
2026-05-07 01:08:58 +08:00
lan
7f606e14dd fix(ui): adjust keyboard handling and prevent duplicate view recording
All checks were successful
Frontend CI / ota-android (push) Successful in 1m21s
Frontend CI / ota-ios (push) Successful in 1m28s
Frontend CI / build-and-push-web (push) Successful in 2m56s
Frontend CI / build-android-apk (push) Successful in 2h32m37s
- Fix keyboard avoidance logic in PostDetailScreen and ChatScreen by applying manual keyboard height offsets only on Android, preventing double padding on iOS.
- Prevent multiple view recording calls in TradeDetailScreen by using a ref to track if the view has already been recorded.
2026-05-06 12:53:39 +08:00
lan
64493baf7f fix(ui): improve layout stability and refactor trade detail styles
All checks were successful
Frontend CI / ota-android (push) Successful in 1m34s
Frontend CI / ota-ios (push) Successful in 1m34s
Frontend CI / build-and-push-web (push) Successful in 3m27s
Frontend CI / build-android-apk (push) Successful in 1h22m22s
- fix schedule screen layout by measuring day width on web to prevent overflow
- add overflow hidden to schedule grid cells
- refactor trade detail screen styles for better organization and readability
- update trade detail seller card and avatar components
- expand trade type and status maps in trade detail screen
2026-05-06 00:45:04 +08:00
lan
d879eea7d1 refactor(profile): adjust spacing for tab bar and post wrapper
Some checks failed
Frontend CI / build-android-apk (push) Failing after 16s
Frontend CI / ota-android (push) Successful in 2m3s
Frontend CI / ota-ios (push) Successful in 1m11s
Frontend CI / build-and-push-web (push) Successful in 2m52s
Reset `tabBarContainer` margin to 0 and apply `spacing.md` as `marginTop` to the `postWrapper` to correct layout positioning in the profile view.
2026-05-06 00:02:02 +08:00
lan
e18cac2dca style(ui): update profile tab bar container margin
Some checks failed
Frontend CI / ota-android (push) Successful in 1m34s
Frontend CI / ota-ios (push) Successful in 1m34s
Frontend CI / build-and-push-web (push) Successful in 3m32s
Frontend CI / build-android-apk (push) Has been cancelled
Adjust the `marginTop` of the `tabBarContainer` in `createSharedProfileStyles` from 0 to `spacing.md` to improve layout spacing.
2026-05-05 22:36:36 +08:00
lan
3196972596 refactor: restructure core architecture and responsive system
All checks were successful
Frontend CI / ota-android (push) Successful in 1m40s
Frontend CI / ota-ios (push) Successful in 1m39s
Frontend CI / build-and-push-web (push) Successful in 3m30s
Frontend CI / build-android-apk (push) Successful in 1h1m8s
This commit implements a major architectural refactor to improve modularity, type safety, and maintainability across the codebase.

Key changes include:
- **Responsive System Refactor**: Migrated from a monolithic `useResponsive` hook to a set of specialized, granular hooks (`useBreakpoint`, `useOrientation`, `usePlatform`, etc.) located in `src/presentation/hooks/responsive`. This reduces unnecessary re-renders and improves developer experience.
- **Core Service Restructuring**: Introduced a new directory structure for services, including dedicated folders for `datasources`, `mappers`, and domain-specific types (`message`, `post`).
- **Data Layer Improvements**:
    - Centralized JSON parsing logic in `src/database/core/jsonUtils.ts`.
    - Cleaned up repository implementations by removing redundant local utility functions.
    - Refactored `MessageMapper` to use the new centralized JSON utility.
- **Type System Cleanup**:
    - Decomposed the large `src/types/dto.ts` into a modular `src/types/dto/` directory.
    - Simplified `src/types/index.ts` and introduced backward-compatible aliases for core entities.
- **Utility Consolidation**:
    - Created a centralized `src/utils/formatTime.ts` to replace fragmented date formatting logic across various screens and components.
    - Removed deprecated responsive utility files in favor of the new hook-based system.
- **Service Logic Refinement**: Refactored `ApiClient` and `WebSocketService` to use a centralized `showVerificationModal` service, removing duplicated state management for verification prompts.
2026-05-05 19:07:33 +08:00
lan
f5f9c3a619 ci: add iOS OTA publishing and refactor gesture handling
Some checks failed
Frontend CI / ota-android (push) Successful in 1m10s
Frontend CI / ota-ios (push) Successful in 1m8s
Frontend CI / build-and-push-web (push) Successful in 6m1s
Frontend CI / build-android-apk (push) Has been cancelled
- Add `ota-ios` job to GitHub Actions workflow to support iOS OTA updates
- Refactor `ScheduleScreen` to use `GestureDetector` and `Gesture` from `react-native-gesture-handler` instead of `PanGestureHandler`
- Update `TradeCard` styles and layout spacing in `HomeScreen` and `MarketView`
- Refactor `MarketView` to use updated responsive spacing values
2026-05-05 17:46:31 +08:00
lan
c0ebebe3da feat(db): add getLastMessageByConversation and update conversation list preview
All checks were successful
Frontend CI / ota-android (push) Successful in 1m16s
Frontend CI / build-and-push-web (push) Successful in 2m53s
Frontend CI / build-android-apk (push) Successful in 34m6s
Add `getLastMessageByConversation` to `MessageRepository` to allow fetching the most recent message for a specific conversation.

Update `ConversationListRow` to use this new repository method as a fallback when message segments are missing, ensuring the conversation list preview displays the last message content correctly.
2026-05-05 02:01:05 +08:00
lan
8a271b2376 feat(ws): implement sync_required event and message acknowledgement
All checks were successful
Frontend CI / ota-android (push) Successful in 1m9s
Frontend CI / build-and-push-web (push) Successful in 3m36s
Frontend CI / build-android-apk (push) Successful in 37m10s
Add support for a new `sync_required` WebSocket message type to trigger
incremental synchronization of conversations, unread counts, and active
messages. Also implement an `ack` mechanism to acknowledge received
messages.
2026-05-04 18:31:22 +08:00
lan
4213d13b8f refactor(ui): implement custom header system and unify screen layouts
Some checks failed
Frontend CI / ota-android (push) Successful in 1m30s
Frontend CI / build-and-push-web (push) Successful in 12m29s
Frontend CI / build-android-apk (push) Failing after 3h10m38s
Refactor the navigation and header strategy by replacing native stack headers with a custom `SimpleHeader` component across most profile and detail screens. This provides a more consistent UI/UX and better control over layout behavior.

- Implement `SimpleHeader` component in `src/components/common`.
- Disable native header rendering in `app/(app)/(tabs)/profile/_layout.tsx` and `app/_layout.tsx`.
- Update profile sub-screens to use `SimpleHeader` and `StatusBar` from `expo-status-bar`.
- Refactor `PostDetailScreen` to use a custom header implementation for better integration with the post author information.
- Update `UserScreen` to wrap content in `SafeAreaView` with the new header.
- Adjust `AppBackButton` to support transparent backgrounds.
2026-05-03 22:01:43 +08:00
lafay
490a99ab3c refactor(chat): remove Unicode placeholder-based mention chip overlay system
All checks were successful
Frontend CI / ota-android (push) Successful in 1m8s
Frontend CI / build-and-push-web (push) Successful in 3m39s
Frontend CI / build-android-apk (push) Successful in 31m33s
Replaced the previous approach of tracking @mentions using private Unicode characters (U+E000-U+F8FF) with a simpler regex-based solution that extracts mention ranges directly from input text. This eliminates the complex overlay rendering logic and corresponding styles/types.
2026-04-29 11:29:59 +08:00
lafay
0f289e3182 feat(chat): implement @mention chip overlay rendering with Unicode placeholder tracking
Some checks failed
Frontend CI / ota-android (push) Successful in 1m12s
Frontend CI / build-and-push-web (push) Successful in 3m28s
Frontend CI / build-android-apk (push) Failing after 1h23m13s
Add visual overlay rendering for @mention chips in the chat input using Unicode private use area characters as invisible placeholders. The implementation includes chip data tracking via `MentionChipMap`, dynamic segment parsing to separate chip and text content for overlay display, and updated memo comparison in `MessageBubble` to include avatar press handlers for proper re-render prevention.
2026-04-28 22:18:42 +08:00
lafay
d2120a257d feat(auth): enhance login error handling with detailed error code mapping
All checks were successful
Frontend CI / ota-android (push) Successful in 1m29s
Frontend CI / build-and-push-web (push) Successful in 2m31s
Frontend CI / build-android-apk (push) Successful in 36m5s
- Add backend error_code support (INVALID_CREDENTIALS, USER_BANNED, etc.)
- Improve network error detection with code=0 fallback
- Add granular HTTP status code handling (400, 401, 403, 429, 5xx)
- Update LoginScreen to display authStore error or generic message
- Include notification service improvements:
  - Keep JPush connection alive in background via setKeepLongConnInBackground
  - Add manual sound/vibration for local notifications via preferences
- Fix SearchScreen layout by accounting for floating tab bar and safe area
2026-04-28 17:25:27 +08:00
lafay
cf9feeae68 feat(notification): enhance chat notification routing with flexible field handling
All checks were successful
Frontend CI / ota-android (push) Successful in 1m11s
Frontend CI / build-and-push-web (push) Successful in 3m22s
Frontend CI / build-android-apk (push) Successful in 1h31m46s
Support both 'chat_message' and 'chat' notification types with flexible field name handling (snake_case and camelCase variants). Add groupId and groupName parameters for group chats, and fall back to notifications screen when conversationId is missing.
2026-04-28 14:53:32 +08:00
lafay
2432f7bc1e feat(notification): add foreground notification vibration feedback and Android channel setup
All checks were successful
Frontend CI / ota-android (push) Successful in 1m33s
Frontend CI / build-and-push-web (push) Successful in 2m51s
Frontend CI / build-android-apk (push) Successful in 29m40s
- Add vibration feedback when app is in foreground and notification arrives
- Configure Android notification channel using expo-notifications
- Integrate notification handler to respect user push/sound preferences
- Add platform-specific local notification handling for Android
2026-04-28 08:53:28 +08:00
lafay
be0c304711 <think>
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 41s
Frontend CI / ota-android (push) Successful in 1m23s
Frontend CI / build-android-apk (push) Successful in 1h12m29s
Let me analyze the changes:

1. PrivacyPolicyScreen.tsx:
   - Updated LAST_UPDATED date from '2026年4月4日' to '2026年4月28日'
   - Added detailed third-party SDK disclosure section:
     - Added JPush (极光推送) with provider details, purpose, collected data, and privacy policy link
     - Added Expo推送服务 with provider details, purpose, and collected data

2. TermsOfServiceScreen.tsx:
   - Updated LAST_UPDATED date from '2026年4月4日' to '2026年4月28日'
   - Added section 6.4 about third-party SDKs (JPush) and reference to privacy policy

These changes are:
- Documentation/legal text updates
- Related to privacy and terms screens
- Adding third-party SDK disclosure (JPush)

The primary type should be: docs (documentation changes)
Scope could be: profile or privacy/terms related
Description should focus on the third-party SDK disclosure update

This is different from the previous commit which was about notification integration. This commit is about updating legal/policy documents to disclose third-party SDKs.

Let me craft a conventional commit message:

type: docs
scope: profile or privacy
description: update privacy policy and terms to disclose third-party SDKs

Body could explain:
- Added JPush SDK disclosure with provider info, data collected, privacy policy link
- Added Expo push service disclosure
- Updated terms of service to reference third-party SDKs
- Updated last update dates

Breaking changes: None

Footer: Could reference related issue if any, but none provided.

Let me write the commit message:

```
docs(privacy): update privacy policy and terms to disclose third-party SDKs

- Add JPush SDK disclosure with provider details, data collection purposes, and privacy policy link
- Add Expo push service disclosure with provider and collected data information
- Update terms of service to reference third-party SDK usage

- Update document dates to April 28, 2026
```

I need to verify this is sufficiently distinct from the previous commit. The last one was "feat(notification): integrate expo-notifications..." so using "docs(privacy)" with this specific focus on third-party SDK disclosure makes sense and clearly differs from the prior notification feature work.

</think>
docs(privacy): update privacy policy and terms to disclose third-party SDKs

- Add JPush SDK disclosure with provider details, data collection purposes, and privacy policy link
- Add Expo push service disclosure with provider and collected data information
- Update terms of service to reference third-party SDK usage per privacy policy
- Update document dates to April 28, 2026
2026-04-28 00:21:57 +08:00
lafay
b7ce9e3b9a feat(notification): integrate expo-notifications for permission handling and deep linking
Some checks failed
Frontend CI / ota-android (push) Successful in 1m27s
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
- Add expo-notifications dependency and configure with JPush plugin
- Request notification permission on app launch for iOS/Android
- Handle notification taps to navigate to chat or notifications screen
- Add UI to check and request system notification permission in settings
- Refactor background sync: WebSocket disconnects in background, JPush handles push
- Add checkNotificationPermission() and requestNotificationPermission() to jpushService
2026-04-28 00:20:07 +08:00
lafay
296e649fbb chore(release): bump app version to 0.0.2
Some checks failed
Frontend CI / ota-android (push) Successful in 1m19s
Frontend CI / build-and-push-web (push) Failing after 1m36s
Frontend CI / build-android-apk (push) Has been cancelled
2026-04-27 23:37:49 +08:00
lafay
88d9ffe9da chore(build): remove deprecated always-auth npm config from CI workflow
Some checks failed
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / build-and-push-web (push) Failing after 40s
2026-04-27 23:36:16 +08:00
lafay
9745d2209f ci: update npm registry to npmmirror.com in build workflow
Some checks failed
Frontend CI / ota-android (push) Failing after 42s
Frontend CI / build-and-push-web (push) Failing after 55s
Frontend CI / build-android-apk (push) Failing after 1m45s
2026-04-27 23:33:38 +08:00
lafay
2015a1e9dd ci: update docker image name to carrot_bbs/frontend-web in build workflow
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 2m7s
Frontend CI / ota-android (push) Failing after 6m3s
Frontend CI / build-android-apk (push) Has been cancelled
2026-04-27 23:23:17 +08:00
lafay
ba89b3dc94 feat(notification): migrate from expo-notifications to JPush push service
Some checks failed
Frontend CI / build-and-push-web (push) Has started running
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
Replace expo-notifications with JPush for push notifications across the app.
This includes adding JPush native module dependencies, configuring the JPush
plugin in app.json, implementing jpushService wrapper, and updating notification
services to use JPush APIs. Also adds device registration on token refresh for
both mobile and web platforms.

BREAKING CHANGE: expo-notifications removed in favor of JPush for push notifications
2026-04-27 23:21:08 +08:00
lafay
0e4306c3ac ci: update docker registry path from with_you to carrot_bbs
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 2m33s
Frontend CI / ota-android (push) Successful in 10m37s
Frontend CI / build-android-apk (push) Successful in 43m57s
2026-04-27 13:40:41 +08:00
lafay
f478fa3fd3 refactor(ui): enhance TradeDetailScreen with responsive design and image gallery
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 2m44s
Frontend CI / ota-android (push) Successful in 10m26s
Frontend CI / build-android-apk (push) Successful in 39m50s
- Add responsive hooks (useResponsive, useResponsiveValue, useResponsiveSpacing)
- Replace custom image handling with ImageGrid and ImageGallery components
- Update styling with modern typography and spacing adjustments
- Add image modal for fullscreen gallery view
2026-04-27 10:32:24 +08:00
lafay
e0b43ce60d build(gradle): add foojay toolchain resolver convention plugin
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 14s
Frontend CI / build-android-apk (push) Failing after 12s
Frontend CI / ota-android (push) Failing after 14s
Adds the Gradle Toolchains Foojay Resolver plugin to enable automatic JDK
downloads and toolchain management, allowing the project to specify required
JDK versions without manual installation.
2026-04-27 01:15:20 +08:00
lafay
0e2945b86b refactor(ui): modernize components with flat design and UX refinements
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 42s
Frontend CI / build-android-apk (push) Failing after 7m16s
Frontend CI / ota-android (push) Successful in 10m29s
- Redesign chat screen header with improved layout and panel styling
- Update emoji panel tab bar from emoji to icon-based navigation
- Simplify trade detail view with inline content attributes
- Add masonry layout support to market view grid
- Refactor privacy settings from card-based to list-based dropdown UI
- Improve comment item actions layout and styling
- Update trade creation flow with buy/sell specific text
- Adjust padding, gaps, and font sizes across multiple components
- Clean up unused code and imports
2026-04-27 01:02:39 +08:00
lafay
e519346261 refactor(ui): update staff identity label to "官方"
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 4m13s
Frontend CI / ota-android (push) Successful in 10m35s
Frontend CI / build-android-apk (push) Failing after 25m21s
Change staff identity display text from "职工认证" to "官方" and update
corresponding colors to gold theme in UserProfileHeader and
VerificationSettingsScreen.
2026-04-26 19:54:10 +08:00
lafay
1989cc8519 feat(ui): add verification badge to UserProfileHeader
Some checks failed
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
Add verification status display with identity-based badge icons to the user
profile header component. Also add identity and verification_status fields
to UserDTO and UserDetailDTO types.

- Add VerificationBadge component with icon display based on identity type
- Support for student, teacher, staff, alumni, and external identity types
- Add 'none' status to VerificationStatus type for unverified users
2026-04-26 19:51:26 +08:00
lafay
6cbd2092ac refactor(ui): redesign SearchBar with compact mode and add home screen tabs
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 3m13s
Frontend CI / build-android-apk (push) Failing after 9m39s
Frontend CI / ota-android (push) Successful in 10m31s
Refactor SearchBar component to support compact mode with transparent styling,
reduced padding, and smaller icons. Add home screen tab navigation for switching
between home feed and market view with animated tab indicator. Also adjust
PostDetailScreen action button sizing and CommentItem margins for better spacing.
2026-04-26 17:13:43 +08:00
lafay
5815f1a5f7 refactor(home): redesign home screen with sort bar and add recommendation feed
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 2m49s
Frontend CI / ota-android (push) Successful in 10m29s
Frontend CI / build-android-apk (push) Successful in 40m32s
- Replace PagerView-based tabs with modern sort bar navigation
- Add "推荐" (recommended) feed option with "hot" sort type
- Update tab icons and labels to match new sort paradigm
- Optimize PostCard, MessageBubble conditional styles

perf(chat): migrate FlatList to FlashList for emoji/sticker panels

- Improve virtualized rendering performance for emoji and sticker grids
- Fix jump-to-latest scroll behavior for inverted FlashList

perf(profile): extract user header and tab bar into separate memoized renders

- Prevent unnecessary re-renders when switching tabs

feat(api): add channel_id filter support for post queries

- Include channel_id in cursor pagination requests
- Update CursorPaginationRequest type definition

style(chat-info): redesign group and private chat info screens with flat layout

- Remove card borders and shadows for cleaner appearance
- Adjust avatar sizes and spacing for consistency
2026-04-26 12:04:27 +08:00
lafay
e9d7098ad0 fix(auth): make verification code input responsive to screen width
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 2m36s
Frontend CI / ota-android (push) Successful in 10m38s
Frontend CI / build-android-apk (push) Successful in 42m53s
Adjust cell width and font size based on device screen dimensions to prevent overflow on smaller screens. Calculate available width accounting for container padding and margins, with minimum cell width of 32px. Font size scales down from 28px to 24px or 20px as cell width decreases.
2026-04-26 01:15:24 +08:00
lafay
2dc6936aac feat(post): add post reference functionality with inline cards and suggestion search
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 2m42s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
Add support for referencing/quoting other posts within post content. Includes:
- New `post_ref` segment type with `PostRefSegmentData` interface for post references
- New `PostRefCard` component to display referenced posts inline
- Post suggestion search triggered by `#` in `PostMentionInput`
- Updated `PostContentRenderer` to render post reference segments
- Add `suggestPosts` and `recordRefClick` API methods in postService

Additional improvements:
- Add image gallery preview for user profile covers and avatars
- Make text selectable across CommentItem, PostContentRenderer, and PostDetailScreen
- Migrate FlatList to FlashList in PostDetailScreen and UserProfileScreen
- Add nestedScrollEnabled to CreatePostScreen
- Add clickable member avatars in GroupMembersScreen
2026-04-26 01:11:30 +08:00
lafay
5fa5403d6a feat(message): add conversation notification mute feature
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 57s
Frontend CI / ota-android (push) Successful in 10m53s
Frontend CI / build-android-apk (push) Failing after 1h12m12s
Add notification mute functionality for conversations with the following changes:

- Add notification_muted field to ConversationResponse and related DTOs
- Add notificationMutedMap to message store for tracking mute state
- Add setConversationNotificationMuted API method
- Integrate mute toggle in GroupInfoScreen and PrivateChatInfoScreen
- Skip system notifications for muted conversations
- Suppress vibration for muted conversations in WS handler
- Clean up selected mentions when @mentions are removed from text

Chore changes:
- Update PostCard bookmark icon from 'bookmark' to 'star'
- Refine card styles across AppsScreen, MaterialsScreen, and SubjectMaterialsScreen
- Improve week selector scrolling to center selected week in ScheduleScreen
2026-04-25 21:23:22 +08:00
lafay
de0afa93a1 fix(ws): only process unread notifications and handle multiple payload formats
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 3m37s
Frontend CI / ota-android (push) Successful in 10m50s
Frontend CI / build-android-apk (push) Failing after 17m44s
Previously, the notification handler would process all notifications regardless of read status, and only accepted `extra_data` from the payload. Now it:
- Only calls systemNotificationService and increments unread count for unread notifications
- Falls back to `payload.extra` or `payload.data` when `extra_data` is not present
2026-04-25 15:59:59 +08:00
lafay
eccfd67550 fix(android): handle hardware back button and navigation with active panels/keyboard
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 3m20s
Frontend CI / ota-android (push) Successful in 10m27s
Frontend CI / build-android-apk (push) Has been cancelled
Improve back navigation UX by checking for open panels or keyboard before leaving the chat screen. When back is triggered, dismiss keyboard or close panel first instead of immediately navigating away. Apply this logic to both the navigation gesture listener and Android hardware back button for consistent behavior.
2026-04-25 15:28:13 +08:00
lafay
6b91a7ead1 fix(message): add robust NaN validation for date handling across components
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 53s
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
Add comprehensive date validation using Number.isNaN() checks to prevent crashes when timestamps are invalid or empty. Apply defensive formatting across CommentItem, SystemMessageItem, MessageBubble, LongPressMenu, and store utilities.

Refine message bubble styling: move sender name display to both sides in group chat with distinct `mySenderName` styling, adjust bubble corner radius to top-right for consistent arrow placement, and align message rows to flex-start for improved layout. Simplify group avatar rendering in ConversationListRow.
2026-04-25 15:25:42 +08:00
lafay
a6a4198ac5 feat(performance): migrate FlatList to FlashList and add animations
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 3m9s
Frontend CI / ota-android (push) Successful in 10m37s
Frontend CI / build-android-apk (push) Has been cancelled
Replace FlatList with FlashList across all message screens (ChatScreen, MessageListScreen, NotificationsScreen, HomeScreen) for improved list virtualization performance. Use `drawDistance={250}` instead of manual pagination. Simplify React.memo comparisons in MessageBubble and SegmentRenderer by removing function prop checks to prevent unnecessary re-renders.

Add AsyncStorage persistence for lastSystemMessageAt to avoid showing current time on first render. Include enter animations (fade and slide) for CreateGroupScreen and modernize UI styling to flat design.

BREAKING CHANGE: Upgrade @shopify/flash-list from 2.0.2 to ^2.3.1
2026-04-25 15:09:00 +08:00
lafay
19054d64b3 feat(message): add pinned conversations sorting and unified timeline
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 3m34s
Frontend CI / ota-android (push) Successful in 10m39s
Frontend CI / build-android-apk (push) Successful in 39m33s
Sort conversation list with pinned items first, then by updated_at timestamp.
Also fixes timestamp text alignment by adding lineHeight, textAlignVertical,
and includeFontPadding properties for consistent vertical centering.
2026-04-25 13:01:09 +08:00
lafay
f16f001f6c feat(pagination): add updateItem for in-place list updates and optimize notifications
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 2m48s
Frontend CI / ota-android (push) Successful in 10m30s
Frontend CI / build-android-apk (push) Successful in 39m3s
Add `updateItem` function to cursor pagination hook for updating single items
without full list refresh. Use this in NotificationsScreen to mark messages
as read locally instead of refetching. Also add `is_blocked` detection in
postService and userProfile to show blocked state in profile screen.
2026-04-25 11:26:13 +08:00
lafay
ad19bc2af7 feat(ui): unify design system to Twitter/X style across screens and improve message handling
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 3m9s
Frontend CI / ota-android (push) Successful in 10m29s
Frontend CI / build-android-apk (push) Successful in 43m4s
Redesign multiple screens and components from card-based responsive design to Twitter/X flat design:
- UserProfileHeader: adopt Twitter/X style with larger icons, simplified buttons, and block functionality
- AppBackButton: update to chevron-left icon with larger size, remove background styling
- Update SettingsScreen, EditProfileScreen, and UserProfileScreen with flat layout approach
- ConversationListRow: convert from bordered cards to flat list rows with updated typography

Improve message system with WebSocket notification handling:
- Add real-time system unread count updates via WebSocket
- Track lastSystemMessageAt for accurate system message timestamps
- Add notification deduplication and read-status filtering
- Combine system and conversation unread counts in background sync
- Clear system notifications from notification center on mark as read
2026-04-25 00:39:40 +08:00
lafay
e0d28535f4 fix(mentions): render actual user nicknames in @mention display
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 2m36s
Frontend CI / ota-android (push) Successful in 10m53s
Frontend CI / build-android-apk (push) Successful in 40m40s
- Add displayName variable using atData.nickname with fallback to generic text
- Replace hardcoded '@所有人 ' and '@某人 ' with dynamic '@{displayName} ' format
2026-04-24 17:44:28 +08:00
lafay
738e75a79d feat(ui): add emoji picker with FlatList virtualization and improve input components
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 2m43s
Frontend CI / ota-android (push) Successful in 10m32s
Frontend CI / build-android-apk (push) Successful in 47m43s
- Add full emoji picker with virtualized FlatList to CreatePostScreen and PostDetailScreen
- Add autoExpand prop to PostMentionInput for XHS-style content area
- Improve mention item styling with @ icon and hint text
- Fix HomeScreen tab switching race condition with requestAnimationFrame
- Fix PostRepository pagination to prefer cursor over page parameter
- Fix user store imports to use explicit UserManager path
- Refactor useCurrentUser hook to use sessionStore directly
2026-04-24 16:44:01 +08:00
lafay
b2c3d5e54e feat(content): add rich content rendering with @mentions and vote segments
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 8m30s
Frontend CI / ota-android (push) Successful in 10m43s
Frontend CI / build-android-apk (push) Successful in 1h24m38s
- Add PostContentRenderer component for rendering posts/comments with segments
- Add PostMentionInput component for @mention support in post/comment creation
- Add VoteSegmentData and vote segment type for unified post creation API
- Update post and comment services to support segments in API requests
- Fix auth service to clear tokens before login/register
- Improve database initialization with proper close/retry logic
- Add verification check before allowing post creation
- Add metro web shims for react-native-webrtc full package and requireNativeComponent
- Update build config with git hash based version numbers
2026-04-23 22:29:58 +08:00
lafay
eb4e1c080d ci(build): add Tencent Gradle mirror and reorder Maven mirrors for China network
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 1m43s
Frontend CI / ota-android (push) Successful in 10m51s
Frontend CI / build-android-apk (push) Successful in 1h7m45s
- Add new step to switch Gradle distribution URL to Tencent mirror (mirrors.cloud.tencent.com/gradle/)
- Reorder Maven repository configuration to prioritize Aliyun mirrors before google() and mavenCentral()
- Add gradle-plugin to Aliyun mirror list for improved China network compatibility
2026-04-22 20:46:59 +08:00
lafay
dcae47218f build(metro): add web shims for native React Native API compatibility
Some checks failed
Frontend CI / ota-android (push) Failing after 11s
Frontend CI / build-and-push-web (push) Failing after 1m14s
Frontend CI / build-android-apk (push) Has been cancelled
Add Metro configuration to resolve native-only React Native APIs with web-compatible shims. This enables packages like react-native-pager-view to work on the web platform by providing shim implementations for codegenNativeComponent, codegenNativeCommands, and CodegenTypes that are unavailable in react-native-web.

Also simplify Android signing configuration in CI by using standard Gradle property names (MYAPP_UPLOAD_*) and remove the build.gradle patching step.
2026-04-22 20:17:38 +08:00
lafay
437dab3b6e Rebrand the entire application from "胡萝卜社区" (Carrot BBS) to "威友" (WithYou), including:
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 2m51s
Frontend CI / ota-android (push) Successful in 11m16s
Frontend CI / build-android-apk (push) Failing after 12m54s
- Update app name, package name (skin.carrot.bbs → cn.qczlit.withyou), and bundle identifier
- Update API endpoints (bbs.littlelan.cn → withyou.littlelan.cn)
- Update URL scheme (carrotbbs:// → withyou://)
- Rename database from carrot_bbs to with_you
- Update CI/CD pipeline image names and artifact naming
- Add Android signing configuration for release builds
- Update all UI text and comments throughout the codebase
- Refactor registerStore to use in-memory state instead of AsyncStorage persistence
- Improve WebRTC track handling and call stream management
- Add metro platform resolution for native platform support

BREAKING app package, database, and URLs are no longer valid
2026-04-22 16:54:51 +08:00
lafay
1c3797ea7d refactor(ui): redesign TabBar to border-bottom indicator style
Some checks failed
Frontend CI / ota-android (push) Failing after 13s
Frontend CI / build-and-push-web (push) Failing after 14s
Frontend CI / build-android-apk (push) Failing after 13m23s
- Remove pill container styling with shadows and rounded corners
- Simplify tab styling and reduce font weights
- Change active indicator from 20px to span 50% width
- Add channel tag display to PostDetailScreen
- Simplify SafeAreaView edge handling in embedded screens
2026-04-21 22:31:06 +08:00
lafay
30c493c88f feat(search): add keyword highlighting to PostCard and improve QRCodeScanner
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 4m39s
Frontend CI / ota-android (push) Successful in 11m22s
Frontend CI / build-android-apk (push) Failing after 14m58s
Add HighlightText component for search result highlighting in PostCard.
Refactor QRCodeScanner to use async permission methods with better error
handling. Improve SearchScreen with stable function refs and pass
highlightKeyword to PostCard. Modernize MessageListScreen search UI with
SearchBar and TabBar components. Add emoji virtualization to EmojiPanel
and auto-focus input after inserting emoji.

BREAKING CHANGE: EmojiPanel onFocusInput prop added for focus management
2026-04-21 12:31:51 +08:00
lafay
b16759a147 feat(ui): add ShareSheet component for multi-channel post sharing
Some checks failed
Frontend CI / ota-android (push) Successful in 10m39s
Frontend CI / build-and-push-web (push) Failing after 1m29s
Frontend CI / build-android-apk (push) Has been cancelled
Add ShareSheet component that provides native sharing options (WeChat, Moments, copy link, etc.) replacing the previous clipboard-only approach. Also change like icon from heart to thumb-up to better match the share functionality.
2026-04-19 23:58:26 +08:00
lafay
6910fdec70 refactor(ui): remove QQ-style action bar from PostDetailScreen
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 52s
Frontend CI / build-android-apk (push) Failing after 15m45s
Frontend CI / ota-android (push) Successful in 10m31s
Replace the bottom action bar (like, comment, share, favorite buttons) with a simplified comment section header showing only the comment count. Also add `setIsComposerVisible(false)` after comment submission to hide the composer.
2026-04-16 11:39:58 +08:00
lafay
c00b915e5f chore: remove IDE config files and improve web platform compatibility
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m31s
Frontend CI / ota-android (push) Successful in 10m26s
Frontend CI / build-android-apk (push) Successful in 40m43s
- Remove `.idea/` IntelliJ configuration files from version control
- Add web-specific touch handling for swipeable message bubbles and schedule screen
- Fix CSS touch-action rules for better web scrolling behavior
- Add nestedScrollEnabled to ScrollViews for proper gesture handling
- Improve null safety checks in profile screens
- Add horizontal ScrollView wrapper for notification filter tags
- Add hasHeader prop support for embedded profile screens
2026-04-14 02:12:53 +08:00
lafay
57d7c7405c refactor(stores): reorganize flat store files into modular directory structure
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m29s
Frontend CI / ota-android (push) Successful in 10m35s
Frontend CI / build-android-apk (push) Successful in 37m48s
Migrate from flat file organization to modular directory structure under src/stores/:

- auth/: authStore, registerStore, verificationStore, sessionStore
- call/: callStore
- settings/: chatSettingsStore, themeStore
- ui/: homeTabBarVisibilityStore, homeTabPressStore
- utils/: routePayloadCache
- group/sources.ts, group/profileResolver.ts
- message/sources.ts
- post/sources.ts

Update all import paths across components and screens to use new module paths. Maintain backward compatibility through deprecated re-export files for gradual migration.
2026-04-13 04:56:58 +08:00
lafay
4f31926eb5 feat(message): add embedded mode to ChatScreen for desktop dual-column layout
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m37s
Frontend CI / ota-android (push) Successful in 10m43s
Frontend CI / build-android-apk (push) Successful in 40m4s
Replace EmbeddedChat with unified ChatScreen supporting embedded mode through props. Pass conversation data, layout constraints, and navigation callbacks directly to ChatScreen instead of using separate embedded component. Consolidates dual-column layout logic into single component with conditional rendering based on isEmbedded prop.
2026-04-13 01:52:43 +08:00
lafay
fe6a03da5d feat: 优化架构
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 2m39s
Frontend CI / ota-android (push) Successful in 10m22s
Frontend CI / build-android-apk (push) Has been cancelled
2026-04-13 01:30:37 +08:00
lafay
2adc9360a5 refactor(post): consolidate post sync to PostSyncService and remove ProcessPostUseCase
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 3m28s
Frontend CI / ota-android (push) Successful in 10m25s
Frontend CI / build-android-apk (push) Successful in 55m22s
- Replace ProcessPostUseCase with new PostSyncService in src/services/post/
- Add postListStore for state management in src/stores/post/
- Remove deprecated ProcessPostUseCase and ProcessMessageUseCase files
- Delete architecture documentation files (ARCHITECTURE_REFACTOR_PLAN.md, architecture-comparison-report.md)
- Update all consumers (HomeScreen, PostDetailScreen, SearchScreen, useUserProfile, useDifferentialPosts)
- Simplify postService to only contain API layer methods
- Remove unused type exports from message stores

BREAKING CHANGE: ProcessPostUseCase and ProcessMessageUseCase have been removed.
Use postSyncService for post operations instead.
2026-04-13 00:26:05 +08:00
lafay
6610d2f173 refactor(core): introduce EventBus and refactor store infrastructure
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m46s
Frontend CI / ota-android (push) Successful in 10m34s
Frontend CI / build-android-apk (push) Successful in 1h15m8s
- Add EventBus for decoupled event-driven communication between services
- Add EventSubscriber component for centralized event handling
- Add requestDedupe utility for shared request deduplication
- Refactor api/wsService to use eventBus instead of direct router navigation
- Extract MessageMapper.toCachedMessages for consistent message mapping
- Remove deprecated BaseManager and CacheBus classes
- Improve @mention rendering with memberMap support in segment rendering
- Update hooks to use useRef instead of useState for fetch tracking
2026-04-12 18:14:29 +08:00
lafay
4b5ce1ba21 refactor(platform): extract blurActiveElement to infrastructure module
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 4m47s
Frontend CI / ota-android (push) Successful in 10m48s
Frontend CI / build-android-apk (push) Successful in 1h36m37s
- Centralize duplicated Platform.OS === 'web' blur logic across 16 components
- Add blurActiveElement utility to src/infrastructure/platform module
- Optimize MessageBubble and ImageSegment with React.memo custom comparators
- Add useMemo for computed values in MessageSegmentsRenderer
- Update DTO types with is_system_notice and notice_content fields
- Fix keyboard dismissal order in useChatScreen handleDismiss
- Simplify userStore follow/unfollow state updates
- Remove unnecessary TypeScript type assertions throughout codebase
2026-04-11 22:35:11 +08:00
lafay
6f84e17772 feat(service): add foreground service for background message sync
Implement Android foreground service to keep app alive in background for real-time message sync:
- Add withForegroundService expo config plugin for notification channel setup
- Add BackgroundSyncManager with REALTIME, BATTERY_SAVER, and DISABLED modes
- Add ForegroundServiceModule for native Android foreground service binding
- Refactor backgroundService to integrate foreground service and expo-background-task

Also refactor auth and profile screens to use dynamic theme colors:
- Replace hardcoded THEME_COLORS with colors.primary.main, colors.background.paper, etc.
- Add useResolvedColorScheme for dynamic StatusBar styling
- Update NotificationSettingsScreen with sync mode selection UI

BREAKING CHANGE: Switched from expo-background-fetch to expo-background-task, minimum background sync interval changed from 900s to 15min in config
2026-04-11 04:27:22 +08:00
lafay
9bbed8cf5e feat(auth): upload proof materials image before verification submission
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 3m0s
Frontend CI / ota-android (push) Successful in 10m30s
Frontend CI / build-android-apk (push) Successful in 50m44s
Add image upload functionality to the verification form that uploads local proof material images (file://, content://, blob: URIs) to the server and uses the returned URL for verification submission. Also add proper upload state tracking to disable form inputs during upload.
2026-04-10 01:10:55 +08:00
lafay
be8f6de8cf <think>Let me analyze the staged changes to generate a proper conventional commit message.
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 3m16s
Frontend CI / ota-android (push) Successful in 11m42s
Frontend CI / build-android-apk (push) Successful in 59m5s
Looking at the changes:

1. **New files**:
   - `app/(app)/(tabs)/profile/account-deletion.tsx` - account deletion screen
   - `app/(app)/(tabs)/profile/privacy-settings.tsx` - privacy settings screen
   - `src/screens/profile/AccountDeletionScreen.tsx` - account deletion implementation
   - `src/screens/profile/PrivacySettingsScreen.tsx` - privacy settings implementation

2. **Modified files**:
   - Database changes: `LocalDataSource.ts`, `DatabaseManager.ts`, `DatabaseConfig.ts`, `MigrationManager.ts` - refactoring database layer (removing LockManager, simplified initialization, added retry logic)
   - Auth store: `authStore.ts` - changed `initDatabase` to `switchDatabase`
   - Auth service: `authService.ts` - added privacy settings and account deletion methods
   - DTOs: `dto.ts` - added PrivacySettingsDTO, DeletionStatusDTO, VoteOptionDTO, VoteResultDTO types
   - Navigation: `hrefs.ts` - added new href functions for privacy settings and account deletion
   - Profile screens: `SettingsScreen.tsx`, `index.ts`, `_layout.tsx` - added new menu items and routes
   - Various message screens - added keyboard handling props
   - `app/_layout.tsx` - added CSS for mobile touch handling
   - `CreatePostScreen.tsx` - fixed vote_options format
   - `PostDetailScreen.tsx` - added type annotations for vote handling
   - `MediaCacheManager.ts` - updated to use new expo-file-system API
   - `VerificationSettingsScreen.tsx` - fixed type safety
   - `AccountSecurityScreen.tsx` - styling refactoring

This is a multi-faceted change involving:

Database refactoring, new privacy/account deletion features, bug fixes, and UI improvements. The database changes appear to be refactoring without new functionality. The most significant changes are the privacy settings and account deletion features, plus bug fixes like the vote options format and mobile touch handling.
</think>

feat(profile): add privacy settings and account deletion screens

Add privacy settings and account deletion functionality with new screens and APIs. Refactor database initialization to use switchDatabase for user switching. Fix vote options format in post creation and add type safety to vote handling. Improve mobile touch handling in layout and message screens. Update media cache to use new expo-file-system API.

BREAKING CHANGE: Database initialization now uses switchDatabase instead of initDatabase for user switching
2026-04-08 16:48:19 +08:00
lafay
96a5207cf8 feat(home): integrate PagerView for tab navigation and add verification redirect
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 9m20s
Frontend CI / ota-android (push) Successful in 14m32s
Frontend CI / build-android-apk (push) Successful in 1h17m23s
Replace custom gesture handling with PagerView component for smoother tab
switching on home screen. Add verification required redirects in API and
WebSocket services that automatically navigate users to the verification
guide when receiving VERIFICATION_REQUIRED error codes.

BREAKING CHANGE: API and WS services now require router to be set via
setExpoRouter/setRouter for navigation to work properly
2026-04-08 02:38:48 +08:00
lafay
25194313ae feat(profile): add data storage settings screen with cache management
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 15m38s
Frontend CI / ota-android (push) Successful in 16m54s
Frontend CI / build-android-apk (push) Failing after 8m29s
Add DataStorageScreen for managing media cache and storage:
- Display cache statistics (image/video/audio counts and sizes)
- Show AsyncStorage usage information
- Add clear all cache functionality
- Add cleanup expired cache option (7 days threshold)
- Add storage usage breakdown by category
- Add "Last cleaned" timestamp display

Also fix MediaCacheManager to skip native cache operations on web platform:
- Return original URI directly on web without caching
- Skip directory creation on web
- Skip file existence checks on web
- Skip startup and periodic cleanup on web
2026-04-07 16:27:02 +08:00
lafay
445c1c5561 feat(auth): add user identity verification system with new screens
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 4m54s
Frontend CI / ota-android (push) Failing after 7m28s
Frontend CI / build-android-apk (push) Has been cancelled
Add comprehensive identity verification flow including:
- New verification guide screen at /verification-guide for selecting identity type
- New verification form screen at /verification-form for submitting verification documents
- Verification settings screen at /profile/verification for managing verification status
- Move terms and privacy policy to root-level routes (/terms, /privacy)
- Update login to require active terms agreement checkbox
- Database: add in-memory fallback when OPFS is unavailable on web

BREAKING CHANGE: Terms and privacy policy routes moved from /profile/terms and /profile/privacy to /terms and /privacy
2026-04-07 15:58:51 +08:00
lafay
accf7c04e8 feat(auth): enhance password policy with stronger requirements
Some checks failed
Frontend CI / build-android-apk (push) Failing after 8m40s
Frontend CI / ota-android (push) Successful in 12m13s
Frontend CI / build-and-push-web (push) Successful in 2m47s
Update password validation across authentication screens to require minimum 8 characters with uppercase letters, lowercase letters, and numbers. Changes applied to ForgotPasswordScreen, RegisterStep3Profile, and AccountSecurityScreen. LoginScreen password validation was removed as it occurs post-authentication.
2026-04-07 00:12:03 +08:00
lafay
542d385d08 feat(verification): add user identity verification system
Some checks failed
Frontend CI / build-android-apk (push) Failing after 9m29s
Frontend CI / build-and-push-web (push) Failing after 1m12s
Frontend CI / ota-android (push) Failing after 7m29s
Add comprehensive user verification feature including:
- VerificationGuideScreen: Guide screen explaining the verification process
- VerificationFormScreen: Form for submitting verification applications
- VerificationSettingsScreen: Settings page to view verification status
- verificationService: API service for verification operations
- verificationStore: State management for verification state
- DTOs: Types for verification records, status, and admin operations

The system supports multiple identity types (student, teacher, staff, alumni, external)
and includes both user-facing and admin-facing functionality.
2026-04-05 20:26:51 +08:00
lafay
82c2970a85 refactor(database): migrate to new modular database layer and unify data access
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m44s
Frontend CI / ota-android (push) Successful in 12m51s
Frontend CI / build-android-apk (push) Successful in 1h1m26s
- Remove legacy database.ts, LocalDataSource.ts, and MessageRepository.ts
- Create new src/database/ module with messageRepository, userCacheRepository, conversationRepository, and groupCacheRepository
- Update all consumers to import from @/database instead of services/database
- Add web platform blur handling for modal components to fix focus issues
- Flatten SystemMessageItem and NotificationsScreen styles for consistent design
- Add draggable slider in ChatSettingsScreen and dynamic font size support
- Introduce 9 new chat color themes
- Add profile screens for about, terms, and privacy policy with navigation routes
- Add policy links to login and registration screens
- Fix post share URL format from /posts/ to /post/
2026-04-04 08:01:45 +08:00
lafay
189b977fac fix(database): improve web environment database connection handling
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m46s
Frontend CI / ota-android (push) Successful in 12m40s
Frontend CI / build-android-apk (push) Successful in 1h2m43s
- Add pre-open check for web to release potentially stale OPFS handles
- Unify close logic across platforms instead of skipping for web
- Add proper logging for connection lifecycle operations
- Remove obsolete error check for "cannot create file" during retries
2026-04-04 02:38:56 +08:00
lafay
69717ea407 build(deploy): add nginx configuration for web application serving
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 4m28s
Frontend CI / ota-android (push) Successful in 11m33s
Frontend CI / build-android-apk (push) Successful in 1h3m22s
Add nginx.conf to serve the Expo web build and update Dockerfile.web to copy the configuration into the nginx container. This enables proper static file serving with appropriate MIME types and default caching headers for the production web deployment.
2026-04-04 00:15:51 +08:00
lafay
775deeb9c4 refactor(database): unify database connections and add retry logic
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 3m18s
Frontend CI / ota-android (push) Successful in 12m16s
Frontend CI / build-android-apk (push) Has been cancelled
Refactor LocalDataSource to reuse the shared database connection from database.ts, avoiding OPFS file handle conflicts. Add retry logic with exponential backoff for database open operations, particularly important for Web/OPFS environments. Also add dynamic theme support to chat input and message bubble styles, using theme colors instead of hardcoded values for better light/dark mode support.

BREAKING CHANGE: Database initialization now requires userId to be passed explicitly for user-specific databases
2026-04-04 00:02:22 +08:00
lafay
e8651215f7 feat(chat): implement dynamic theme support in chat components
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 7m31s
Frontend CI / ota-android (push) Successful in 13m19s
Frontend CI / build-android-apk (push) Successful in 1h10m3s
- Enhanced ChatHeader and ChatInput components to support dynamic theme colors, improving visual consistency across different themes.
- Updated styles in ChatScreen to utilize new dynamic styles for better responsiveness and user experience.
- Refactored styles in various profile screens to adopt a unified background color scheme and improved spacing for a cleaner layout.

This update significantly enhances the chat interface and profile settings by allowing for dynamic theming and improved visual elements.
2026-04-02 17:55:56 +08:00
lafay
72842352d9 feat(chat): enhance chat settings and message bubble styles
All checks were successful
Frontend CI / ota-android (push) Successful in 12m37s
Frontend CI / build-and-push-web (push) Successful in 22m42s
Frontend CI / build-android-apk (push) Successful in 57m42s
- Introduced dynamic chat settings for font size and message bubble radius, improving user customization.
- Updated ChatScreen styles to support dynamic themes and responsive layouts.
- Refactored bubble styles to utilize dynamic properties for better visual consistency.
- Simplified chat settings management by integrating Zustand store for state management.

This update significantly enhances the chat interface and user experience by allowing personalized settings and improved visual elements.
2026-04-01 16:30:44 +08:00
lafay
c771bd9755 feat(auth): implement multi-step registration process and enhance background services
All checks were successful
Frontend CI / ota-android (push) Successful in 13m20s
Frontend CI / build-and-push-web (push) Successful in 14m23s
Frontend CI / build-android-apk (push) Successful in 59m10s
- Updated RegisterScreen to support a multi-step registration flow with components for email input, verification, and profile setup.
- Added new components: VerificationCodeInput, StepIndicator, and corresponding types for step management.
- Enhanced background service to check authentication status before executing tasks, improving app reliability.
- Updated TypeScript configuration to include module resolution and JSON module support.

This update significantly improves the user registration experience and ensures background tasks are only executed for authenticated users.
2026-04-01 04:48:38 +08:00
lafay
5614b4078a feat(profile): add chat settings and language options to profile settings
All checks were successful
Frontend CI / ota-android (push) Successful in 13m7s
Frontend CI / build-and-push-web (push) Successful in 16m58s
Frontend CI / build-android-apk (push) Successful in 1h4m18s
- Introduced hrefProfileChatSettings() for navigation to chat settings.
- Updated SettingsScreen to include new settings items for chat settings, language selection, data storage, terms, and privacy policy.
- Enhanced user experience by providing alerts for language and data storage options.

This update improves the profile settings interface by adding more customization options for users.
2026-04-01 02:17:36 +08:00
lafay
20e9d69540 feat(auth): redesign auth screens with clean form style and add welcome page
All checks were successful
Frontend CI / ota-android (push) Successful in 11m41s
Frontend CI / build-and-push-web (push) Successful in 21m57s
Frontend CI / build-android-apk (push) Successful in 1h0m58s
- Replace split/responsive layout with unified clean form design
- Update LoginScreen, RegisterScreen, and ForgotPasswordScreen styles
- Add WelcomeScreen as new entry point for unauthenticated users
- Redirect unauthenticated users to /welcome instead of /login
- Add hrefAuthWelcome() navigation helper

BREAKING CHANGE: Auth screens no longer support split layout, unified mobile-first design
2026-04-01 00:50:38 +08:00
lafay
259de04f3e refactor(message): replace SubscriptionManager with Zustand selectors
All checks were successful
Frontend CI / ota-android (push) Successful in 11m17s
Frontend CI / build-and-push-web (push) Successful in 25m8s
Frontend CI / build-android-apk (push) Successful in 59m7s
Remove custom SubscriptionManager class and adopt Zustand's built-in
selector mechanism for reactive state updates. This simplifies the
architecture by eliminating manual subscription management and relying
on Zustand's automatic dependency tracking for component re-renders.

Key changes:
- Remove SubscriptionManager class from store.ts
- Replace all notifySubscribers calls with direct store updates
- Update hooks to use Zustand selectors with useShallow for complex data
- Remove subscribe/unsubscribe patterns from MessageManager
- Simplify EmbeddedChat to use selector instead of local state
- Rename requestConversationListRefresh to refreshConversations
2026-03-31 19:15:13 +08:00
lafay
94c11062f0 refactor(message): replace MessageStateManager with zustand store
Some checks failed
Frontend CI / ota-android (push) Successful in 11m15s
Frontend CI / build-and-push-web (push) Successful in 29m13s
Frontend CI / build-android-apk (push) Has been cancelled
- Remove MessageStateManager wrapper layer in favor of direct zustand store
- Move service modules to services/ subdirectory (ConversationOperations, MessageDeduplication, MessageSendService, MessageSyncService, ReadReceiptManager, UserCacheService, WSMessageHandler)
- Add new zustand-based store with subscriptionManager for event handling
- Introduce React hooks for message state (useConversations, useMessages, useUnreadCount, etc.)
- Update exports in index.ts to reflect new module structure
- Deprecate messageManager.ts entry point in favor of message/index.ts
- Maintain backward compatibility with existing MessageManager API
2026-03-31 18:22:24 +08:00
lafay
1bee7ea551 refactor(message): modularize MessageManager architecture
All checks were successful
Frontend CI / ota-android (push) Successful in 11m20s
Frontend CI / build-and-push-web (push) Successful in 34m19s
Frontend CI / build-android-apk (push) Successful in 57m45s
重构消息管理模块,将单体 MessageManager 拆分为职责单一的子模块:

- MessageStateManager: 纯状态管理
- MessageDeduplication: 消息去重服务
- UserCacheService: 用户缓存服务
- ReadReceiptManager: 已读回执管理
- ConversationOperations: 会话操作
- MessageSendService: 消息发送
- MessageSyncService: 消息同步
- WSMessageHandler: WebSocket 消息处理

新增类型定义和常量文件,保持原有 API 向后兼容。修复 CallScreen 状态值错误,补充 PostDetailScreen 缺失样式,优化 GroupInfoPanel 导入。
2026-03-31 16:13:24 +08:00
lafay
2ef267a897 refactor(TabBar, HomeScreen): update styles for improved UI consistency
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 3m2s
Frontend CI / ota-android (push) Successful in 15m35s
Frontend CI / build-android-apk (push) Successful in 1h19m59s
- Adjusted TabBar styles for better shadow effects and padding.
- Modified HomeScreen layout margins and padding for enhanced spacing.
- Updated font weight for modernTabText to improve text visibility.
- Changed active opacity for tab interactions to enhance user experience.
2026-03-30 18:01:32 +08:00
lafay
774b5c4b47 feat(CommentItem, PostDetailScreen, ReportDialog): integrate report functionality
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 2m54s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
- Added report button to CommentItem for reporting comments and replies.
- Integrated ReportDialog into PostDetailScreen for handling report submissions.
- Enhanced ReportDialog with detailed report reasons and improved UI interactions.
- Updated styles across various components for a more modern look and feel.

Made-with: Cursor
2026-03-30 17:53:30 +08:00
lafay
e0ee29caf8 feat: 添加举报功能前端支持
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m45s
Frontend CI / ota-android (push) Successful in 12m44s
Frontend CI / build-android-apk (push) Successful in 52m27s
- 新增 reportService API 服务
- 新增 ReportDialog 举报对话框组件
- 集成举报入口到 PostCard、CommentItem、LongPressMenu

Made-with: Cursor
2026-03-29 20:18:49 +08:00
lafay
584d98307c feat(ChatScreen): 优化聊天界面 UI 和交互体验
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m40s
Frontend CI / ota-android (push) Successful in 13m12s
Frontend CI / build-android-apk (push) Successful in 55m45s
Made-with: Cursor
2026-03-29 03:04:54 +08:00
e969e5bad4 Merge pull request '双端适配,web端的修改父容器和接口' (#7) from adapt into dev
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 4m10s
Frontend CI / ota-android (push) Successful in 12m56s
Frontend CI / build-android-apk (push) Has been cancelled
Reviewed-on: #7
2026-03-29 02:39:05 +08:00
ebb0c003e3 双端适配,web端的修改父容器和接口 2026-03-29 02:34:13 +08:00
lafay
3c071957ce feat(CallScreen, FloatingCallWindow, callStore, wsService): enhance call status handling and UI updates
All checks were successful
Frontend CI / ota-android (push) Successful in 11m58s
Frontend CI / build-and-push-web (push) Successful in 15m49s
Frontend CI / build-android-apk (push) Successful in 1h27m7s
- Updated call status messages to include 'calling', 'reconnecting', and 'failed' states for better user feedback.
- Improved video handling logic in CallScreen to use currentCall.isVideoEnabled for local video display.
- Enhanced callStore to manage new call statuses and ensure proper timeout handling during 'calling' state.
- Added a method in wsService to retrieve the active call state after WebSocket reconnections.
- Refactored FloatingCallWindow to reflect updated call status messages for consistency across components.
2026-03-28 04:35:05 +08:00
lafay
fa10ef5116 fix(WebRTCManager, callStore): improve signaling state handling and media track management
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m53s
Frontend CI / ota-android (push) Successful in 11m0s
Frontend CI / build-android-apk (push) Successful in 1h27m45s
- Enhanced ontrack event handling to construct remote streams and avoid duplicate tracks during renegotiation.
- Updated negotiation logic to ensure it only occurs when the signaling state is stable and the instance is the initiator.
- Added diagnostic logging for offer and answer SDP to assist in debugging video track handling.
- Improved rollback logic in callStore to handle signaling state checks more robustly after rollback attempts.
2026-03-28 01:41:26 +08:00
lafay
7c33409624 feat(wsService, WebRTCManager): enhance call handling with media type support and negotiation improvements
Some checks failed
Frontend CI / ota-android (push) Successful in 11m25s
Frontend CI / build-and-push-web (push) Successful in 11m30s
Frontend CI / build-android-apk (push) Has been cancelled
- Added optional media_type to WSCallIncomingMessage for better call type differentiation.
- Updated call_type assignment in WebSocketService to prioritize media_type if available.
- Removed initialOfferCreated flag in WebRTCManager to simplify negotiation logic.
- Improved negotiation handling by releasing the negotiation lock after offer creation, ensuring smoother call setup.
2026-03-28 01:06:51 +08:00
lafay
f6176c945b feat(CallScreen): enhance video call functionality and UI updates
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 3m13s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
- Integrated video call support by adding local and remote video stream handling.
- Implemented state management for video track detection in both local and peer streams.
- Updated UI to conditionally render video components based on the presence of video tracks.
- Added video toggle functionality to enable or disable local video during calls.
- Enhanced incoming call modal to display call type (voice or video).
- Refactored call store to manage call types and video state more effectively.
2026-03-28 00:56:52 +08:00
lafay
b19a2ced6f feat(CallFeature): implement call functionality and integrate WebSocket signaling
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 5m2s
Frontend CI / ota-android (push) Successful in 11m28s
Frontend CI / build-android-apk (push) Successful in 1h0m58s
- Updated app.json to include microphone permissions and background audio support.
- Added call-related dependencies in package.json and package-lock.json.
- Enhanced ChatScreen to initiate calls and handle call actions.
- Introduced call components in the layout for incoming call handling.
- Expanded WebSocket service to manage call signaling messages and events.
- Refactored authStore to initialize call state on user authentication.
2026-03-27 17:12:19 +08:00
lafay
db7885086f refactor(wsService): improve WebSocket connection handling and logging
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 5m51s
Frontend CI / ota-android (push) Successful in 16m58s
Frontend CI / build-android-apk (push) Successful in 1h20m19s
- Updated getWSUrl method to accept a token parameter for better flexibility.
- Enhanced connection logic with additional logging for connection status and errors.
- Improved error handling during connection attempts and disconnections.
- Added checks to prevent duplicate handling of disconnections.
- Refactored reconnect logic to provide clearer feedback on reconnection attempts.
2026-03-26 22:05:07 +08:00
lafay
ba99900624 refactor(WebSocket): migrate from SSE to WebSocket for real-time messaging
- Replaced SSEClient with WSClient for handling real-time messaging.
- Updated ProcessMessageUseCase to initialize WebSocket listeners.
- Refactored messageService to prioritize WebSocket for sending messages, with fallback to HTTP.
- Removed sseService and adjusted imports across the application to utilize wsService.
- Enhanced message handling and connection management for improved performance and reliability.
2026-03-26 22:04:46 +08:00
lan
4b89b50006 refactor(stores): unify data sources pattern and extract BaseManager base class
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 3m4s
Frontend CI / ota-android (push) Successful in 11m9s
Frontend CI / build-android-apk (push) Successful in 1h23m57s
- Add BaseManager/CachedManager base classes to eliminate duplicate cache logic
- Add postListSources.ts with IPostListPagedSource interface
- Add groupListSources.ts with IGroupListPagedSource interface
- Refactor postManager to use CachedManager and Sources pattern
- Refactor groupManager to use CachedManager and Sources pattern
- Clean up duplicate methods in groupService.ts
- Fix TypeScript errors and remove mock data
2026-03-26 16:46:22 +08:00
lafay
b6583e07c8 feat(TextComponent): enhance text styling with dynamic font weights and improved layout
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 3m7s
Frontend CI / ota-android (push) Successful in 13m37s
Frontend CI / build-android-apk (push) Failing after 54m7s
- Updated Text component to support dynamic font weights, allowing for more flexible text styling.
- Refactored variant styles to utilize new font weight constants for better consistency across text variants.
- Introduced letter spacing adjustments for improved readability and visual appeal.
- Added font family configuration for iOS and Android to optimize typography across platforms.
2026-03-26 03:58:09 +08:00
lafay
6d1514b2d1 refactor(ChatScreen): update text colors for dark mode compatibility
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 3m15s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
- Modified text color styles in SegmentRenderer and styles.ts to utilize theme colors for better adaptability in dark mode.
- Updated bubble colors in palettes.ts to enhance visual consistency across chat components.
2026-03-26 03:51:26 +08:00
lafay
d280ad1656 feat(APKUpdate): conditionally import native modules for APK updates on Android
Some checks failed
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
- Implemented conditional imports for `expo-file-system` and `expo-intent-launcher` to ensure compatibility with non-Android environments.
- Added error handling to alert users when native modules are unavailable, directing them to download APKs via the browser.
- Enhanced the `installAPK` function to check for the availability of native modules before proceeding with installation.
2026-03-26 03:45:39 +08:00
lafay
c903990aaf feat(APKUpdate): implement APK update check and enhance build workflow
Some checks failed
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
- Added `expo-intent-launcher` dependency to support APK launching functionality.
- Introduced `APKUpdateBootstrap` component to check for APK updates during app initialization.
- Enhanced build workflow in `build.yml` to resolve runtime version and upload APK to the updates server.
- Updated `HomeScreen` and `TabsLayout` to manage tab visibility and scroll behavior based on user interactions.
- Refactored message bubble styles for improved UI consistency and user experience.
2026-03-26 03:41:43 +08:00
lafay
405cd271db feat(Comment): enhance like functionality for comments and replies
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 1m54s
Frontend CI / ota-android (push) Failing after 5m36s
Frontend CI / build-android-apk (push) Failing after 9m3s
- Updated CommentItem component to accept the comment object in the onLike callback, allowing for more detailed like handling.
- Added a like button for replies, improving user interaction with nested comments.
- Refactored handleLikeComment function in PostDetailScreen for optimized state management and error handling during like operations.
- Enhanced SettingsScreen to provide debug information for theme preferences, improving user experience in theme selection.
- Updated themeStore to dynamically manage system theme changes and improve overall theme handling.
2026-03-26 01:25:42 +08:00
lafay
9529ea39c4 feat(Materials): add new materials navigation and href functions
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m59s
Frontend CI / ota-android (push) Successful in 18m11s
Frontend CI / build-android-apk (push) Successful in 1h21m54s
- Introduced href functions for accessing materials, including hrefMaterials, hrefMaterialSubject, and hrefMaterialDetail.
- Updated AppsScreen to include a new entry for materials, enhancing the app's educational resources section.
- Exported material types in index.ts to support the new materials functionality.
2026-03-25 21:17:17 +08:00
396 changed files with 42897 additions and 20984 deletions

View File

@@ -21,7 +21,8 @@ on:
env:
REGISTRY: code.littlelan.cn
IMAGE_NAME: carrot_bbs/frontend-web
OTA_PLATFORM: android
OTA_PLATFORM_ANDROID: android
OTA_PLATFORM_IOS: ios
OTA_PUBLISH_URL: https://updates.littlelan.cn/admin/publish
OTA_MANIFEST_URL: https://updates.littlelan.cn/api/manifest
@@ -39,7 +40,13 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '25.6.1'
cache: 'npm'
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
@@ -76,7 +83,7 @@ jobs:
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}" \
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${OTA_PLATFORM_ANDROID}" \
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
-H "Content-Type: application/zip" \
--data-binary @"dist-android-update.zip"
@@ -84,7 +91,7 @@ jobs:
- name: Verify OTA manifest
run: |
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
-H "expo-platform: ${OTA_PLATFORM}" \
-H "expo-platform: ${OTA_PLATFORM_ANDROID}" \
-H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
-H "expo-protocol-version: 1" \
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\\\"id\\\":.*\\\"extra\\\":\\{.*\\}\\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
@@ -93,6 +100,79 @@ jobs:
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-protocol-version: 1" \
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\"id\":.*\"extra\":\{.*\}\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
LOCAL="$(python -c "import json; m=json.load(open('dist-ios/metadata.json')); print(m['fileMetadata']['ios']['bundle'].split('/')[-1])")"
echo "Remote bundle: ${REMOTE}"
echo "Local bundle: ${LOCAL}"
test "${REMOTE}" = "${LOCAL}"
build-android-apk:
runs-on: android-builder
container:
@@ -121,7 +201,13 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '25.6.1'
cache: 'npm'
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 Gradle
uses: actions/cache@v4
@@ -150,21 +236,37 @@ jobs:
- name: Generate Android native project
run: npx expo prebuild --platform android
- name: Configure Gradle with Aliyun Maven mirror
- name: Switch Gradle distribution to Tencent mirror
run: |
PROPS="android/gradle/wrapper/gradle-wrapper.properties"
if [ -f "$PROPS" ]; then
sed -i 's|distributionUrl=https\\://services.gradle.org/distributions/|distributionUrl=https\\://mirrors.cloud.tencent.com/gradle/|' "$PROPS"
echo "Updated gradle-wrapper.properties:"
cat "$PROPS"
else
echo "gradle-wrapper.properties not found"
exit 1
fi
- name: Decode Android signing keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/app/withyou-release-key.keystore
- name: Configure Gradle with China Maven mirrors
run: |
cd android
# Update settings.gradle with Aliyun Maven mirror
# Update settings.gradle with China Maven mirrors
cat > settings.gradle << 'SETTINGS_EOF'
pluginManagement {
repositories {
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/central' }
google()
mavenCentral()
gradlePluginPortal()
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
}
def reactNativeGradlePlugin = new File(
providers.exec {
@@ -187,6 +289,7 @@ jobs:
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
}
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
@@ -198,7 +301,7 @@ jobs:
}
expoAutolinking.useExpoModules()
rootProject.name = '萝卜社区'
rootProject.name = '威友'
expoAutolinking.useExpoVersionCatalog()
@@ -206,18 +309,18 @@ jobs:
includeBuild(expoAutolinking.reactNativeGradlePlugin)
SETTINGS_EOF
# Update build.gradle with Aliyun Maven mirror
# Update build.gradle with China Maven mirrors
cat > build.gradle << 'BUILD_EOF'
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/central' }
google()
mavenCentral()
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
}
dependencies {
classpath('com.android.tools.build:gradle')
@@ -228,12 +331,12 @@ jobs:
allprojects {
repositories {
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://www.jitpack.io' }
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/central' }
}
}
@@ -260,6 +363,16 @@ jobs:
expo.useLegacyPackaging=false
PROPS_EOF
- name: Configure Gradle signing properties
run: |
cd android
cat >> gradle.properties << SIGNING_PROPS
MYAPP_UPLOAD_STORE_FILE=withyou-release-key.keystore
MYAPP_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
MYAPP_UPLOAD_KEY_ALIAS=withyou-key
MYAPP_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}
SIGNING_PROPS
- name: Build Android release APK (arm64 only)
run: |
cd android
@@ -269,9 +382,45 @@ jobs:
- name: Upload APK artifact
uses: actions/upload-artifact@v3
with:
name: carrot-bbs-android-release-apk
name: withyou-android-release-apk
path: android/app/build/outputs/apk/release/app-release.apk
- 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: Upload APK to Updates Server
env:
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
UPDATES_SERVER_URL: https://updates.littlelan.cn
run: |
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
APK_PATH="android/app/build/outputs/apk/release/app-release.apk"
RUNTIME_VERSION="${{ steps.runtime.outputs.runtime_version }}"
echo "Uploading APK for runtime version: ${RUNTIME_VERSION}"
HTTP_CODE=$(curl -s -o response.json -w "%{http_code}" \
-X POST \
"${UPDATES_SERVER_URL}/admin/apk/upload?runtimeVersion=${RUNTIME_VERSION}" \
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
-H "Content-Type: application/vnd.android.package-archive" \
--data-binary @"${APK_PATH}")
echo "HTTP Response Code: ${HTTP_CODE}"
cat response.json
if [ "${HTTP_CODE}" -ne 200 ]; then
echo "Failed to upload APK"
exit 1
fi
echo "APK uploaded successfully!"
build-and-push-web:
runs-on: ubuntu-latest
permissions:

10
.idea/.gitignore generated vendored
View File

@@ -1,10 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Screenshots
screenshots/

9
.idea/frontend.iml generated
View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

448
.idea/gradle.xml generated
View File

@@ -1,448 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@expo/dom-webview/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/@expo/dom-webview/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@expo/log-box/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/@expo/log-box/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@react-native-async-storage/async-storage/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/@react-native-async-storage/async-storage/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@react-native/gradle-plugin" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/@react-native/gradle-plugin" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-application/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-application/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-background-fetch/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-background-fetch/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-constants/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-constants/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-constants/scripts" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-constants/scripts" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-client/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-dev-client/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-launcher/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-dev-launcher/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-launcher/expo-dev-launcher-gradle-plugin" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-dev-launcher/expo-dev-launcher-gradle-plugin" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-menu-interface/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-dev-menu-interface/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-menu/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-dev-menu/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-eas-client/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-eas-client/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-file-system/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-file-system/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-font/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-font/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-haptics/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-haptics/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image-loader/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-image-loader/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image-picker/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-image-picker/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-image/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-json-utils/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-json-utils/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-linear-gradient/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-linear-gradient/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-linking/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-linking/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-manifests/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-manifests/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-media-library/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-media-library/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-autolinking/android/expo-gradle-plugin" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-modules-autolinking/android/expo-gradle-plugin" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-core/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-modules-core/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-core/expo-module-gradle-plugin" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-modules-core/expo-module-gradle-plugin" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-notifications/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-notifications/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-router/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-router/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-sqlite/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-sqlite/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-structured-headers/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-structured-headers/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-system-ui/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-system-ui/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-task-manager/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-task-manager/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates-interface/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-updates-interface/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-updates/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates/expo-updates-gradle-plugin" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-updates/expo-updates-gradle-plugin" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-video/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-video/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-asset/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-asset/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-keep-awake/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-keep-awake/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/react-native" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-gesture-handler/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/react-native-gesture-handler/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-pager-view/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/react-native-pager-view/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-reanimated/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/react-native-reanimated/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-safe-area-context/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/react-native-safe-area-context/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-screens/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/react-native-screens/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-worklets/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/react-native-worklets/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/unimodules-app-loader/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/unimodules-app-loader/android" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

7
.idea/misc.xml generated
View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/frontend.iml" filepath="$PROJECT_DIR$/.idea/frontend.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

376
.kilo/package-lock.json generated Normal file
View File

@@ -0,0 +1,376 @@
{
"name": ".kilo",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@kilocode/plugin": "7.2.31"
}
},
"node_modules/@kilocode/plugin": {
"version": "7.2.31",
"resolved": "https://registry.npmmirror.com/@kilocode/plugin/-/plugin-7.2.31.tgz",
"integrity": "sha512-KmKTTIly7hRlJdXKhqZ/j/brvTPh0z0UTjWSjJWq5fqf4pATgYGn7G0g3ZjILnN7MUkkZXuljgqExTEeQJHGkQ==",
"license": "MIT",
"dependencies": {
"@kilocode/sdk": "7.2.31",
"effect": "4.0.0-beta.48",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.1.100",
"@opentui/solid": ">=0.1.100"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"node_modules/@kilocode/sdk": {
"version": "7.2.31",
"resolved": "https://registry.npmmirror.com/@kilocode/sdk/-/sdk-7.2.31.tgz",
"integrity": "sha512-Sx05yv+3TIlc6M4Ze+YGgKCLoIg8B0WRE15JXDriVncT+wz7M6+e+4mjNWkwfsuywdeOTYPfeFViqX8iO7ckKA==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/effect": {
"version": "4.0.0-beta.48",
"resolved": "https://registry.npmmirror.com/effect/-/effect-4.0.0-beta.48.tgz",
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"fast-check": "^4.6.0",
"find-my-way-ts": "^0.1.6",
"ini": "^6.0.0",
"kubernetes-types": "^1.30.0",
"msgpackr": "^1.11.9",
"multipasta": "^0.2.7",
"toml": "^4.1.1",
"uuid": "^13.0.0",
"yaml": "^2.8.3"
}
},
"node_modules/fast-check": {
"version": "4.7.0",
"resolved": "https://registry.npmmirror.com/fast-check/-/fast-check-4.7.0.tgz",
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/find-my-way-ts": {
"version": "0.1.6",
"resolved": "https://registry.npmmirror.com/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
"license": "MIT"
},
"node_modules/ini": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/ini/-/ini-6.0.0.tgz",
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/kubernetes-types": {
"version": "1.30.0",
"resolved": "https://registry.npmmirror.com/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
"license": "Apache-2.0"
},
"node_modules/msgpackr": {
"version": "1.11.12",
"resolved": "https://registry.npmmirror.com/msgpackr/-/msgpackr-1.11.12.tgz",
"integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
}
},
"node_modules/multipasta": {
"version": "0.2.7",
"resolved": "https://registry.npmmirror.com/multipasta/-/multipasta-0.2.7.tgz",
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
"license": "MIT"
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmmirror.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pure-rand": {
"version": "8.4.0",
"resolved": "https://registry.npmmirror.com/pure-rand/-/pure-rand-8.4.0.tgz",
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/toml": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/toml/-/toml-4.1.1.tgz",
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/uuid": {
"version": "13.0.1",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-13.0.1.tgz",
"integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/yaml": {
"version": "2.8.4",
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.4.tgz",
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -1,231 +0,0 @@
# 架构修复方案文档
## 1. 重构目标
### 1.1 核心原则
- **单一职责**:每个模块只负责一个明确的功能
- **依赖倒置**:高层模块不依赖低层模块,都依赖抽象
- **开闭原则**:对扩展开放,对修改关闭
### 1.2 目标架构
```
src/
├── core/ # 核心业务逻辑(框架无关)
│ ├── entities/ # 业务实体
│ ├── repositories/ # 仓库接口
│ └── usecases/ # 业务用例
├── data/ # 数据层
│ ├── datasources/ # 数据源实现API、DB、Cache
│ ├── repositories/ # 仓库实现
│ └── mappers/ # 数据映射
├── presentation/ # 表现层
│ ├── components/ # UI组件
│ ├── screens/ # 屏幕组件
│ ├── stores/ # 状态管理
│ └── hooks/ # 自定义hooks
└── infrastructure/ # 基础设施
├── navigation/ # 导航
├── theme/ # 主题
└── services/ # 外部服务
```
## 2. 具体修复方案
### 2.1 messageManager.ts 重构方案
#### 现状问题
- 1500+行,上帝对象
- 混合了 WebSocket、数据库、状态管理、UI通知
- 与SQLite深度耦合
#### 目标结构
```
src/data/
├── datasources/
│ ├── WebSocketClient.ts # WebSocket连接管理
│ └── MessageDataSource.ts # 消息数据源
├── repositories/
│ └── MessageRepository.ts # 消息仓库实现
└── mappers/
└── MessageMapper.ts # 消息数据映射
src/core/
├── entities/
│ └── Message.ts # 消息实体
├── repositories/
│ └── IMessageRepository.ts # 仓库接口
└── usecases/
├── ProcessMessageUseCase.ts
├── SyncMessagesUseCase.ts
└── UpdateReadReceiptUseCase.ts
src/presentation/stores/
└── messageStore.ts # 简化的消息状态管理
```
#### 重构步骤
1. 提取 WebSocketClient - 纯连接管理
2. 创建 MessageRepository - 数据访问抽象
3. 创建 UseCases - 业务逻辑
4. 重写 messageStore - 仅负责UI状态
5. 删除旧的 messageManager.ts
### 2.2 乐观更新工具函数方案
#### 现状问题
- userStore.ts 中重复6+次相同模式
- 代码冗余,难以维护
#### 目标API
```typescript
// utils/optimisticUpdate.ts
export interface OptimisticUpdateOptions<T, R> {
store: StoreApi<any>;
getCurrentState: () => T;
optimisticUpdate: (current: T) => T;
apiCall: () => Promise<R>;
onSuccess?: (result: R, current: T) => T;
onError?: (error: any, original: T) => void;
}
export async function optimisticUpdate<T, R>(
options: OptimisticUpdateOptions<T, R>
): Promise<R>
```
#### 使用示例
```typescript
// 重构前(重复代码)
set(state => ({ posts: updatedPosts }));
try {
const result = await postService.like(postId);
// ...
} catch (error) {
set(state => ({ posts: originalPosts }));
}
// 重构后(简洁)
await optimisticUpdate({
store,
getCurrentState: () => store.getState().posts,
optimisticUpdate: (posts) => updatePostLikes(posts, postId, true),
apiCall: () => postService.like(postId),
onSuccess: (result, posts) => updatePostWithServerData(posts, result),
});
```
### 2.3 useResponsive.ts 拆分方案
#### 现状问题
- 485行43个导出
- 功能混杂:断点检测、响应式值计算、平台检测
#### 目标结构
```
src/presentation/hooks/responsive/
├── useBreakpoint.ts # 断点检测
├── useResponsiveValue.ts # 响应式值映射
├── useOrientation.ts # 方向检测
├── usePlatform.ts # 平台检测
├── useScreenSize.ts # 屏幕尺寸
└── index.ts # 统一导出
```
#### 各Hook职责
- `useBreakpoint`: 返回当前断点 (mobile/tablet/desktop/wide)
- `useResponsiveValue`: 根据断点返回不同值
- `useOrientation`: 返回 portrait/landscape
- `usePlatform`: 返回 ios/android/web
- `useScreenSize`: 返回 width/height
### 2.4 MainNavigator 解耦方案
#### 现状问题
- 1118行直接依赖store
- 混合了导航配置和业务逻辑
#### 目标结构
```
src/infrastructure/navigation/
├── MainNavigator.tsx # 简化后的导航器
├── AuthNavigator.tsx # 认证导航
├── TabNavigator.tsx # 标签导航
├── navigationService.ts # 导航服务(解耦层)
├── navigationTypes.ts # 类型定义
└── hooks/
└── useNavigationState.ts # 导航状态hook
```
#### 解耦策略
1. 创建 NavigationService - 提供导航操作的抽象
2. Store通过NavigationService触发导航而非直接操作
3. MainNavigator只负责渲染不处理业务逻辑
### 2.5 Repository 层创建方案
#### 现状问题
- 服务层直接操作数据库
- 没有明确的数据访问边界
#### 目标结构
```
src/data/repositories/
├── MessageRepository.ts # 消息数据访问
├── PostRepository.ts # 帖子数据访问
├── UserRepository.ts # 用户数据访问
└── interfaces/
├── IMessageRepository.ts
├── IPostRepository.ts
└── IUserRepository.ts
```
#### 职责划分
- **Repository**: 数据访问抽象,定义接口
- **DataSource**: 具体数据源实现API、SQLite、Cache
- **Service**: 业务逻辑编排使用Repository
## 3. 实施计划
### 阶段1基础设施第1-2天
- [ ] 创建目录结构
- [ ] 实现乐观更新工具函数
- [ ] 拆分 useResponsive hooks
### 阶段2数据层重构第3-5天
- [ ] 创建 Repository 接口和实现
- [ ] 创建 DataSource 层
- [ ] 迁移数据库操作到 Repository
### 阶段3核心业务重构第6-10天
- [ ] 重构 messageManager → UseCases + Store
- [ ] 创建 WebSocketClient
- [ ] 实现消息同步逻辑
### 阶段4表现层重构第11-12天
- [ ] 解耦 MainNavigator
- [ ] 重构 userStore 使用乐观更新工具
- [ ] 更新所有屏幕组件
### 阶段5测试与优化第13-14天
- [ ] 单元测试
- [ ] 集成测试
- [ ] 性能优化
## 4. 风险与应对
| 风险 | 影响 | 应对措施 |
|-----|------|---------|
| 重构引入bug | 高 | 每个模块重构后必须测试保持API兼容 |
| 开发时间超期 | 中 | 分阶段交付,优先核心功能 |
| 团队成员不熟悉新架构 | 中 | 编写详细文档,代码审查 |
| 性能下降 | 低 | 添加性能监控,及时优化 |
## 5. 验收标准
- [ ] messageManager.ts 被删除,功能拆分到多个小模块
- [ ] userStore.ts 中无重复乐观更新代码
- [ ] useResponsive.ts 拆分为5+个专注hook
- [ ] MainNavigator.tsx 行数 < 300
- [ ] 所有单元测试通过
- [ ] 无TypeScript错误
- [ ] 性能不劣化(启动时间、内存占用)

View File

@@ -10,6 +10,7 @@ RUN npx expo export --platform web --output-dir dist-web
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist-web /usr/share/nginx/html
EXPOSE 80

View File

@@ -1,10 +1,21 @@
const appJson = require('./app.json');
const { execSync } = require('child_process');
const isDevVariant = process.env.APP_VARIANT === 'dev';
const releaseApiBaseUrl = 'https://bbs.littlelan.cn/api/v1';
const releaseApiBaseUrl = 'https://withyou.littlelan.cn/api/v1';
const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1';
function getGitShortHash() {
try {
return execSync('git rev-parse --short=4 HEAD', { encoding: 'utf-8' }).trim();
} catch {
return '0000';
}
}
const gitBuildSuffix = getGitShortHash();
function toManifestUrl(baseUrl, portOverride) {
const parsed = new URL(baseUrl);
if (portOverride != null) {
@@ -23,6 +34,29 @@ const devUpdatesUrl =
const expo = appJson.expo;
// 检测是否为 Web 平台
const isWeb = process.env.EXPO_TARGET === 'web' || process.env.PLATFORM === 'web';
// 原生平台特有的插件(在 Web 端会报错)
const nativeOnlyPlugins = [
'expo-camera',
'expo-notifications',
'expo-background-fetch',
'expo-media-library',
'expo-image-picker',
'expo-video',
'expo-sqlite',
'./plugins/withMainActivityConfigChange',
];
// 过滤插件Web 端排除原生插件
const filteredPlugins = isWeb
? expo.plugins.filter((plugin) => {
const pluginName = Array.isArray(plugin) ? plugin[0] : plugin;
return !nativeOnlyPlugins.includes(pluginName);
})
: expo.plugins;
module.exports = {
...expo,
name: isDevVariant ? `${expo.name} Dev` : expo.name,
@@ -35,15 +69,36 @@ module.exports = {
checkAutomatically: 'ON_LOAD',
fallbackToCacheTimeout: 0,
},
ios: expo.ios,
ios: {
...expo.ios,
buildNumber: gitBuildSuffix,
},
android: {
...expo.android,
package: 'skin.carrot.bbs',
package: 'cn.qczlit.withyou',
versionCode: parseInt(gitBuildSuffix, 16),
},
// Web 端使用过滤后的插件
plugins: filteredPlugins,
extra: {
...(expo.extra || {}),
appVariant: isDevVariant ? 'dev' : 'release',
apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl,
updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
},
// Web 端配置
...(isWeb && {
web: {
...expo.web,
build: {
...expo.web?.build,
// 配置 Web 端别名,避免加载原生模块
babel: {
dangerouslyAddModulePathsToTranspile: [
'react-native-webrtc',
],
},
},
},
}),
};

View File

@@ -1,12 +1,12 @@
{
"expo": {
"name": "萝卜社区",
"name": "威友",
"slug": "qojo",
"version": "1.0.11",
"version": "0.0.2",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"scheme": "carrotbbs",
"scheme": "withyou",
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
@@ -15,28 +15,32 @@
"ios": {
"supportsTablet": true,
"infoPlist": {
"NSMicrophoneUsageDescription": "允许威友访问您的麦克风以进行语音通话",
"UIBackgroundModes": [
"fetch",
"remote-notification",
"fetch",
"remote-notification"
"remote-notification",
"audio"
],
"ITSAppUsesNonExemptEncryption": false
},
"bundleIdentifier": "skin.carrot.bbs"
"bundleIdentifier": "cn.qczlit.weiyou"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"backgroundColor": "#0570F9",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false,
"package": "skin.carrot.bbs",
"versionCode": 6,
"jsEngine": "hermes",
"package": "cn.qczlit.withyou",
"versionCode": 7,
"permissions": [
"VIBRATE",
"RECORD_AUDIO",
"RECEIVE_BOOT_COMPLETED",
"WAKE_LOCK",
"READ_EXTERNAL_STORAGE",
@@ -52,13 +56,28 @@
"android.permission.ACCESS_MEDIA_LOCATION",
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO",
"android.permission.READ_MEDIA_AUDIO"
"android.permission.READ_MEDIA_AUDIO",
"android.permission.ACCESS_NETWORK_STATE",
"android.permission.ACCESS_WIFI_STATE",
"android.permission.POST_NOTIFICATIONS"
]
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"./plugins/withSigning",
"./plugins/withMainActivityConfigChange",
[
"./plugins/withForegroundService",
{
"notificationTitle": "威友",
"notificationBody": "正在后台同步消息",
"notificationIcon": "ic_notification",
"channelId": "withyou_sync_foreground",
"channelName": "消息同步"
}
],
[
"expo-router",
{
@@ -72,34 +91,28 @@
[
"expo-camera",
{
"cameraPermission": "允许萝卜社区访问您的相机以扫描二维码"
"cameraPermission": "允许威友访问您的相机以扫描二维码"
}
],
[
"expo-notifications",
"expo-background-task",
{
"sounds": []
}
],
[
"expo-background-fetch",
{
"minimumInterval": 900
"minimumInterval": 15
}
],
[
"expo-media-library",
{
"photosPermission": "允许萝卜社区访问您的照片以发布内容",
"savePhotosPermission": "允许萝卜社区保存照片到您的相册",
"photosPermission": "允许威友访问您的照片以发布内容",
"savePhotosPermission": "允许威友保存照片到您的相册",
"isAccessMediaLocationEnabled": true
}
],
[
[
"expo-image-picker",
{
"photosPermission": "允许萝卜社区访问您的照片以选择图片",
"cameraPermission": "允许萝卜社区访问您的相机以拍摄图片",
"photosPermission": "允许威友访问您的照片以选择图片",
"cameraPermission": "允许威友访问您的相机以拍摄图片",
"colors": {
"cropToolbarColor": "#111827",
"cropToolbarIconColor": "#ffffff",
@@ -124,13 +137,29 @@
"proguardRules": "-keep class com.google.android.exoplayer2.** { *; }"
}
],
"expo-font"
"expo-font",
[
"expo-notifications",
{
"color": "#E6F4FE",
"defaultChannel": "default",
"enableBackgroundRemoteNotifications": true
}
],
[
"./plugins/withJPush",
{
"appKey": "2cbe4da12e2c24aa3dca4610",
"channel": "developer-default"
}
]
],
"jsEngine": "hermes",
"extra": {
"eas": {
"projectId": "65540196-d37d-437b-8496-227df0317069"
}
},
"jpushAppKey": "2cbe4da12e2c24aa3dca4610",
"jpushChannel": "developer-default"
},
"owner": "qojo"
}

View File

@@ -1,12 +1,12 @@
import { useMemo } from 'react';
import { useMemo, useCallback } from 'react';
import { Platform, useWindowDimensions } from 'react-native';
import { Tabs, usePathname } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useAppColors, shadows } from '../../../src/theme';
import { BREAKPOINTS } from '../../../src/hooks/useResponsive';
import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores';
import { BREAKPOINTS } from '../../../src/hooks';
import { useHomeTabBarVisibilityStore, useTotalUnreadCount, useHomeTabPressStore } from '../../../src/stores';
const TAB_BAR_HEIGHT = 56;
const TAB_BAR_FLOATING_MARGIN = 12;
@@ -19,10 +19,17 @@ export default function TabsLayout() {
const pathname = usePathname();
const unreadCount = useTotalUnreadCount();
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
const triggerHomeTabPress = useHomeTabPressStore((s) => s.triggerPress);
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
const handleHomeTabPress = useCallback(() => {
if (pathname === '/home') {
triggerHomeTabPress();
}
}, [pathname, triggerHomeTabPress]);
const tabBarStyle = useMemo(() => {
if (hideTabBar) {
return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
@@ -81,6 +88,9 @@ export default function TabsLayout() {
/>
),
}}
listeners={{
tabPress: handleHomeTabPress,
}}
/>
<Tabs.Screen
name="apps"

View File

@@ -0,0 +1,11 @@
import { Stack } from 'expo-router';
export default function MaterialsStackLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="subject" />
<Stack.Screen name="detail" />
</Stack>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,41 +1,28 @@
import { useMemo } from 'react';
import { Stack } from 'expo-router';
import { useRouter } from 'expo-router';
import { AppBackButton } from '../../../../src/components/common';
import { useAppColors } from '../../../../src/theme';
export default function ProfileStackLayout() {
const router = useRouter();
const colors = useAppColors();
const headerOptions = useMemo(
() => ({
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
headerTitleStyle: { fontWeight: '600' as const },
headerShadowVisible: false,
headerBackTitle: '',
}),
[colors]
);
return (
<Stack
screenOptions={{
...headerOptions,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
headerShown: false,
}}
>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: true, title: '设置' }} />
<Stack.Screen name="edit-profile" options={{ title: '编辑资料' }} />
<Stack.Screen name="account-security" options={{ title: '账号安全' }} />
<Stack.Screen name="my-posts" options={{ title: '我的帖子' }} />
<Stack.Screen name="bookmarks" options={{ title: '收藏' }} />
<Stack.Screen name="notification-settings" options={{ title: '通知设置' }} />
<Stack.Screen name="blocked-users" options={{ title: '黑名单' }} />
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="edit-profile" />
<Stack.Screen name="account-security" />
<Stack.Screen name="my-posts" />
<Stack.Screen name="bookmarks" />
<Stack.Screen name="notification-settings" />
<Stack.Screen name="blocked-users" />
<Stack.Screen name="chat-settings" />
<Stack.Screen name="about" />
<Stack.Screen name="terms" />
<Stack.Screen name="privacy" />
<Stack.Screen name="verification" />
<Stack.Screen name="data-storage" />
<Stack.Screen name="privacy-settings" />
<Stack.Screen name="account-deletion" />
</Stack>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,83 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { Platform } from 'react-native';
import { Redirect } from 'expo-router';
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
import { messageManager, useAuthStore } from '../../src/stores';
import { jpushService } from '../../src/services/notification/jpushService';
function getDeviceType(): 'ios' | 'android' | 'web' {
switch (Platform.OS) {
case 'ios': return 'ios';
case 'android': return 'android';
default: return 'web';
}
}
async function getOrCreateDeviceID(): Promise<string> {
try {
const AsyncStorage = require('@react-native-async-storage/async-storage').default;
const DEVICE_ID_KEY = 'withyou_device_id';
let deviceId = await AsyncStorage.getItem(DEVICE_ID_KEY);
if (deviceId) return deviceId;
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 8);
deviceId = `${Platform.OS}_${timestamp}_${random}`;
await AsyncStorage.setItem(DEVICE_ID_KEY, deviceId);
return deviceId;
} catch {
return `${Platform.OS}_${Date.now()}`;
}
}
export default function AppLayout() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const userID = useAuthStore((s) => s.currentUser?.id);
const deviceRegistered = useRef(false);
useEffect(() => {
messageManager.initialize();
}, []);
useEffect(() => {
if (!isAuthenticated || !userID) return;
if (!deviceRegistered.current) {
deviceRegistered.current = true;
if (Platform.OS === 'web') {
// Web: register device without push_token (JPush not available on web)
getOrCreateDeviceID().then((deviceId) => {
const { pushService } = require('../../src/services/notification/pushService');
pushService.registerDevice({
device_id: deviceId,
device_type: 'web',
device_name: 'Web Browser',
}).catch((err: any) => {
console.warn('[Push] web device register failed:', err?.message);
});
});
} else {
// Mobile: JPush handles registration internally in initialize()
jpushService.initialize().then((ok) => {
if (ok && userID) {
jpushService.setAliasForUser(userID);
}
});
}
}
return () => {
if (!isAuthenticated) {
deviceRegistered.current = false;
jpushService.cleanup();
}
};
}, [isAuthenticated, userID]);
if (!isAuthenticated) {
return <Redirect href="/login" />;
}
return <AppRouteStack />;
}
}

View File

@@ -4,7 +4,7 @@ import { Redirect } from 'expo-router';
import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell';
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
import { BREAKPOINTS } from '../../src/hooks/useResponsive';
import { BREAKPOINTS } from '../../src/hooks';
import { messageManager, useAuthStore } from '../../src/stores';
export default function AppLayoutWeb() {

View File

@@ -0,0 +1,5 @@
import { VerificationFormScreen } from '@/screens/auth/VerificationFormScreen';
export default function VerificationFormPage() {
return <VerificationFormScreen />;
}

View File

@@ -0,0 +1,5 @@
import { VerificationGuideScreen } from '@/screens/auth/VerificationGuideScreen';
export default function VerificationGuidePage() {
return <VerificationGuideScreen />;
}

View File

@@ -6,21 +6,27 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { PaperProvider } from 'react-native-paper';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as Notifications from 'expo-notifications';
import * as SystemUI from 'expo-system-ui';
import { useFonts } from 'expo-font';
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
import { registerNotificationPresentationHandler } from '@/services/notification';
import { api } from '@/services/core';
import { wsService } from '@/services/core';
import { EventSubscriber } from '../src/infrastructure/EventSubscriber';
import {
ThemeBootstrap,
useAppColors,
usePaperThemeFromStore,
useResolvedColorScheme,
} from '../src/theme';
import { AppBackButton } from '../src/components/common';
import AppPromptBar from '../src/components/common/AppPromptBar';
import AppDialogHost from '../src/components/common/AppDialogHost';
import { installAlertOverride } from '../src/services/alertOverride';
import { installAlertOverride } from '@/services/ui';
import { useAuthStore } from '../src/stores';
import { checkForAPKUpdate } from '@/services/platform';
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
import { jpushService } from '@/services/notification/jpushService';
registerNotificationPresentationHandler();
@@ -48,6 +54,34 @@ if (Platform.OS === 'web' && typeof document !== 'undefined') {
}
*:focus { outline: none !important; }
*:focus-visible { outline: none !important; }
/* 修复移动端滑动问题 - 仅对根容器限制 */
html, body {
overscroll-behavior: none;
touch-action: manipulation;
-webkit-overflow-scrolling: touch;
}
/* React Native Web 生成的滚动容器 - 允许双向滑动 */
[class*="css-view"] {
touch-action: auto;
-webkit-overflow-scrolling: touch;
}
/* 水平滚动容器 */
[data-horizontal-scroll="true"],
div[style*="overflow-x"],
div[style*="overflow: scroll"],
div[style*="overflow: auto"] {
touch-action: pan-x pan-y;
-webkit-overflow-scrolling: touch;
}
/* 手势区域 - 允许滑动手势 */
[data-gesture-area="true"] {
touch-action: pan-x pan-y;
}
/* 图片查看器手势区域 */
.react-native-image-viewer,
[data-image-viewer="true"] {
touch-action: pinch-zoom pan-x pan-y;
}
`;
document.head.appendChild(style);
}
@@ -94,15 +128,16 @@ function SessionGate({ children }: { children: React.ReactNode }) {
function NotificationBootstrap() {
const appState = useRef<AppStateStatus>(AppState.currentState);
const notificationResponseListener = useRef<Notifications.EventSubscription | null>(null);
const permissionRequested = useRef(false);
const router = useRouter();
useEffect(() => {
const initNotifications = async () => {
const { loadNotificationPreferences } = await import('../src/services/notificationPreferences');
const { loadNotificationPreferences } = await import('@/services/notification');
await loadNotificationPreferences();
const { systemNotificationService } = await import('../src/services/systemNotificationService');
const { systemNotificationService } = await import('@/services/notification');
await systemNotificationService.initialize();
const { initBackgroundService } = await import('../src/services/backgroundService');
const { initBackgroundService } = await import('@/services/background');
await initBackgroundService();
const subscription = AppState.addEventListener('change', (nextAppState) => {
@@ -112,26 +147,86 @@ function NotificationBootstrap() {
appState.current = nextAppState;
});
notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener(
(response) => {
void response.notification.request.content.data;
}
);
// Listen for JPush notification taps — navigate to the relevant screen
jpushService.onNotification((message) => {
console.log('[NotificationBootstrap] JPush notification:', message.notificationEventType, message);
if (message.notificationEventType !== 'notificationOpened') return;
const notificationReceivedSubscription = Notifications.addNotificationReceivedListener(
(notification) => {
void notification;
const extras = message.extras || {};
const notifType = extras.notification_type || message.notificationType;
if (notifType === 'chat_message' || notifType === 'chat') {
const conversationId = extras.conversation_id || extras.conversationId;
const senderId = extras.sender_id || extras.senderId;
const conversationType = extras.conversation_type || extras.conversationType;
const groupId = extras.group_id || extras.groupId;
const groupName = extras.group_name || extras.groupName;
const groupAvatar = extras.group_avatar || extras.groupAvatar;
if (conversationId) {
const isGroup = conversationType === 'group';
const q = new URLSearchParams();
if (isGroup) {
q.set('isGroupChat', '1');
if (groupId) q.set('groupId', groupId);
if (groupName) q.set('groupName', groupName);
if (groupAvatar) q.set('groupAvatar', groupAvatar);
} else if (senderId) {
q.set('userId', senderId);
}
const qs = q.toString();
router.push(`/chat/${encodeURIComponent(conversationId)}${qs ? `?${qs}` : ''}`);
} else {
router.push('/messages/notifications');
}
} else {
router.push('/messages/notifications');
}
);
});
return () => {
subscription.remove();
notificationResponseListener.current?.remove();
notificationReceivedSubscription.remove();
};
};
const requestPermissionOnLaunch = async () => {
if (permissionRequested.current) return;
permissionRequested.current = true;
if (Platform.OS === 'web') return;
try {
const hasPermission = await jpushService.checkNotificationPermission();
if (!hasPermission) {
await jpushService.requestNotificationPermission();
}
} catch (error) {
console.warn('[NotificationBootstrap] request permission on launch failed:', error);
}
};
initNotifications();
requestPermissionOnLaunch();
}, []);
return null;
}
function APKUpdateBootstrap() {
const hasChecked = useRef(false);
useEffect(() => {
if (hasChecked.current) return;
hasChecked.current = true;
// 延迟执行,避免与启动流程冲突
const timer = setTimeout(() => {
checkForAPKUpdate().catch((error) => {
console.error('APK update check failed:', error);
});
}, 3000);
return () => clearTimeout(timer);
}, []);
return null;
@@ -139,41 +234,42 @@ function NotificationBootstrap() {
function ThemedStack() {
const router = useRouter();
const colors = useAppColors();
const resolved = useResolvedColorScheme();
return (
<>
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
<SystemChrome />
<EventSubscriber />
<SessionGate>
<NotificationBootstrap />
<APKUpdateBootstrap />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(app)" options={{ headerShown: false }} />
<Stack.Screen
name="terms"
options={{
headerShown: false,
}}
/>
<Stack.Screen
name="privacy"
options={{
headerShown: false,
}}
/>
<Stack.Screen
name="post/[postId]"
options={{
headerShown: true,
title: '',
// 与 PostDetailScreen 的 SafeAreaViewbackground.default同色避免出现 header/内容之间的色差线
headerStyle: { backgroundColor: colors.background.default },
headerTintColor: colors.text.primary,
headerShadowVisible: false,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
headerShown: false,
}}
/>
<Stack.Screen
name="user/[userId]"
options={{
headerShown: true,
title: '用户主页',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
headerShown: false,
}}
/>
</Stack>
@@ -188,6 +284,16 @@ function ThemedProviders({ children }: { children: React.ReactNode }) {
}
export default function RootLayout() {
const [fontsLoaded] = useFonts({});
if (!fontsLoaded) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator />
</View>
);
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
@@ -197,6 +303,9 @@ export default function RootLayout() {
<ThemedStack />
<AppPromptBar />
<AppDialogHost />
<IncomingCallModal />
<CallScreen />
<FloatingCallWindow />
</QueryClientProvider>
</ThemedProviders>
</SafeAreaProvider>

View File

@@ -7,5 +7,5 @@ export default function Index() {
if (isAuthenticated) {
return <Redirect href="/home" />;
}
return <Redirect href="/login" />;
return <Redirect href="/welcome" />;
}

5
app/privacy.tsx Normal file
View File

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

5
app/terms.tsx Normal file
View File

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

7
app/trade/[tradeId].tsx Normal file
View File

@@ -0,0 +1,7 @@
import { TradeDetailScreen } from '../../src/screens/trade/TradeDetailScreen';
import { useLocalSearchParams } from 'expo-router';
export default function TradeDetailRoute() {
const { tradeId } = useLocalSearchParams<{ tradeId: string }>();
return <TradeDetailScreen tradeId={tradeId} />;
}

View File

@@ -0,0 +1,46 @@
import { useCallback, useEffect, useState } from 'react';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { CreateTradeScreen } from '../../../src/screens/trade/CreateTradeScreen';
import { tradeService } from '../../../src/services/trade/tradeService';
import { Loading } from '../../../src/components/common';
import { View, Alert } from 'react-native';
import type { TradeItemDetailDTO } from '../../../src/types/trade';
export default function EditTradeRoute() {
const { tradeId } = useLocalSearchParams<{ tradeId: string }>();
const router = useRouter();
const [item, setItem] = useState<TradeItemDetailDTO | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!tradeId) return;
tradeService.getTradeItem(tradeId)
.then(data => setItem(data))
.catch(() => Alert.alert('错误', '加载商品信息失败'))
.finally(() => setLoading(false));
}, [tradeId]);
const handleClose = useCallback(() => {
router.back();
}, [router]);
const handleSuccess = useCallback(() => {
router.back();
}, [router]);
if (loading) {
return <Loading fullScreen />;
}
if (!item) {
return null;
}
return (
<CreateTradeScreen
onClose={handleClose}
onSuccess={handleSuccess}
editItem={item}
/>
);
}

5
app/welcome.tsx Normal file
View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 895 KiB

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 895 KiB

After

Width:  |  Height:  |  Size: 385 KiB

View File

@@ -27,6 +27,9 @@
},
"production": {
"autoIncrement": true,
"android": {
"buildType": "apk"
},
"ios": {
"resourceClass": "m-medium"
}

View File

@@ -1,11 +1,77 @@
/** @type {import('expo/metro-config').MetroConfig} */
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const config = getDefaultConfig(__dirname);
// Add wasm asset support
config.resolver.assetExts.push('wasm');
// Fix for react-native-webrtc event-target-shim import warning
// The package doesn't properly export "./index" in its exports field
config.resolver.unstable_enablePackageExports = false;
// Support TypeScript path aliases (@/ -> ./src/)
config.resolver.alias = {
'@': path.resolve(__dirname, 'src'),
};
// Ensure platform-specific files are resolved correctly
config.resolver.platforms = ['ios', 'android', 'native', 'web'];
// Web shims for native-only React Native APIs that don't exist in react-native-web.
// Packages like react-native-pager-view import codegenNativeComponent from
// react-native/Libraries/Utilities/codegenNativeComponent, which calls
// requireNativeComponent internally. On web (react-native-web), these APIs
// don't exist, causing "(0 , _reactNativeWebDistIndex.requireNativeComponent)
// is not a function".
const webShimPaths = [
'react-native/Libraries/Utilities/codegenNativeComponent',
'react-native/Libraries/Utilities/codegenNativeCommands',
'react-native/Libraries/Types/CodegenTypes',
'react-native/Libraries/ReactNative/requireNativeComponent',
];
// Packages that should be entirely replaced with web shims on web platform.
// These packages import requireNativeComponent directly from 'react-native'
// (which resolves to react-native-web on web) and don't have web implementations.
const webPackageShims = {
'react-native-webrtc': path.resolve(__dirname, 'web-shims/react-native-webrtc/index.js'),
};
const originalResolveRequest = config.resolver.resolveRequest;
const shimDir = path.resolve(__dirname, 'web-shims');
config.resolver.resolveRequest = (context, moduleName, platform) => {
// Redirect entire native-only packages to web shims
if (platform === 'web' && webPackageShims[moduleName]) {
return {
type: 'sourceFile',
filePath: webPackageShims[moduleName],
};
}
// Redirect specific React Native internal modules to web shims
if (platform === 'web' && webShimPaths.some((p) => moduleName.endsWith(p))) {
// For requireNativeComponent, the path doesn't follow the Libraries/* pattern
if (moduleName.endsWith('react-native/Libraries/ReactNative/requireNativeComponent')) {
return {
type: 'sourceFile',
filePath: path.join(shimDir, 'react-native', 'Libraries', 'ReactNative', 'requireNativeComponent.js'),
};
}
const shimName = moduleName.replace('react-native/Libraries/', '');
return {
type: 'sourceFile',
filePath: path.join(shimDir, 'react-native', 'Libraries', shimName + '.js'),
};
}
if (originalResolveRequest) {
return originalResolveRequest(context, moduleName, platform);
}
return context.resolveRequest(context, moduleName, platform);
};
// NOTE: enhanceMiddleware is marked as deprecated in Metro's types,
// but there's currently no official alternative for custom dev server headers.
// For production, configure COEP/COOP headers on your hosting platform.
@@ -18,4 +84,4 @@ config.server.enhanceMiddleware = (middleware) => {
};
};
module.exports = config;
module.exports = config;

19
nginx.conf Normal file
View File

@@ -0,0 +1,19 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
gzip_min_length 256;
}

164
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "carrot_bbs",
"version": "1.0.1",
"name": "with_you",
"version": "0.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "carrot_bbs",
"version": "1.0.1",
"name": "with_you",
"version": "0.0.2",
"dependencies": {
"@expo/vector-icons": "^15.1.1",
"@react-native-async-storage/async-storage": "^2.2.0",
@@ -14,12 +14,11 @@
"@react-navigation/material-top-tabs": "^7.4.16",
"@react-navigation/native": "^7.1.31",
"@react-navigation/native-stack": "^7.14.2",
"@shopify/flash-list": "2.0.2",
"@shopify/flash-list": "^2.3.1",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6",
"date-fns": "^4.1.0",
"expo": "~55.0.4",
"expo-background-fetch": "~55.0.10",
"expo-background-task": "~55.0.10",
"expo-camera": "^55.0.10",
"expo-constants": "~55.0.7",
@@ -29,9 +28,10 @@
"expo-haptics": "~55.0.8",
"expo-image": "^55.0.5",
"expo-image-picker": "^55.0.10",
"expo-intent-launcher": "~55.0.9",
"expo-linear-gradient": "^55.0.8",
"expo-media-library": "~55.0.9",
"expo-notifications": "^55.0.10",
"expo-notifications": "~55.0.0",
"expo-router": "^55.0.4",
"expo-sqlite": "~55.0.10",
"expo-status-bar": "~55.0.4",
@@ -39,6 +39,8 @@
"expo-task-manager": "~55.0.9",
"expo-updates": "^55.0.12",
"expo-video": "^55.0.10",
"jcore-react-native": "^2.3.5",
"jpush-react-native": "^3.2.6",
"katex": "^0.16.42",
"markdown-it": "^14.1.1",
"prismjs": "^1.30.0",
@@ -51,8 +53,10 @@
"react-native-reanimated": "^4.2.1",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0",
"react-native-share": "^12.2.6",
"react-native-sse": "^1.2.1",
"react-native-web": "^0.21.0",
"react-native-webrtc": "^124.0.7",
"react-native-webview": "13.16.0",
"react-native-worklets": "0.7.2",
"zod": "^4.3.6",
@@ -1675,17 +1679,17 @@
}
},
"node_modules/@expo/image-utils": {
"version": "0.8.12",
"resolved": "https://registry.npmmirror.com/@expo/image-utils/-/image-utils-0.8.12.tgz",
"integrity": "sha512-3KguH7kyKqq7pNwLb9j6BBdD/bjmNwXZG/HPWT6GWIXbwrvAJt2JNyYTP5agWJ8jbbuys1yuCzmkX+TU6rmI7A==",
"version": "0.8.13",
"resolved": "https://registry.npmmirror.com/@expo/image-utils/-/image-utils-0.8.13.tgz",
"integrity": "sha512-1I//yBQeTY6p0u1ihqGNDAr35EbSG8uFEupFrIF0jd++h9EWH33521yZJU1yE+mwGlzCb61g3ehu78siMhXBlA==",
"license": "MIT",
"dependencies": {
"@expo/require-utils": "^55.0.4",
"@expo/spawn-async": "^1.7.2",
"chalk": "^4.0.0",
"getenv": "^2.0.0",
"jimp-compact": "0.16.1",
"parse-png": "^2.1.0",
"resolve-from": "^5.0.0",
"semver": "^7.6.0"
}
},
@@ -2050,9 +2054,9 @@
}
},
"node_modules/@expo/require-utils": {
"version": "55.0.3",
"resolved": "https://registry.npmmirror.com/@expo/require-utils/-/require-utils-55.0.3.tgz",
"integrity": "sha512-TS1m5tW45q4zoaTlt6DwmdYHxvFTIxoLrTHKOFrIirHIqIXnHCzpceg8wumiBi+ZXSaGY2gobTbfv+WVhJY6Fw==",
"version": "55.0.4",
"resolved": "https://registry.npmmirror.com/@expo/require-utils/-/require-utils-55.0.4.tgz",
"integrity": "sha512-JAANvXqV7MOysWeVWgaiDzikoyDjJWOV/ulOW60Zb3kXJfrx2oZOtGtDXDFKD1mXuahQgoM5QOjuZhF7gFRNjA==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.20.0",
@@ -3662,13 +3666,10 @@
}
},
"node_modules/@shopify/flash-list": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/@shopify/flash-list/-/flash-list-2.0.2.tgz",
"integrity": "sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w==",
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/@shopify/flash-list/-/flash-list-2.3.1.tgz",
"integrity": "sha512-7oktg2NQR7KAODjFoDaWe8/OBzyYbdTE3zQTrUBMxjIbxHTHN7UXRX1hX3DHk8KvtkgQdRfZOV8Gjj2l4fGrXw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"peerDependencies": {
"@babel/runtime": "*",
"react": "*",
@@ -5524,9 +5525,9 @@
}
},
"node_modules/expo-application": {
"version": "55.0.10",
"resolved": "https://registry.npmmirror.com/expo-application/-/expo-application-55.0.10.tgz",
"integrity": "sha512-5ccf+S6hsQz+doi907TOJxKzV5AKgAgw004z4FoDWSoGhfab0LUPg6uyvOspuU4cbNvqw8EAy08hZbVO8nKc9Q==",
"version": "55.0.14",
"resolved": "https://registry.npmmirror.com/expo-application/-/expo-application-55.0.14.tgz",
"integrity": "sha512-NgqDIt3eCf4aVLp1L6AcEanCYoyJeuBsGrgGSzOIvxAsOvp5X3SYKW3ROgpKUnLQEKMWlzwETpjsUGszcqkk8g==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
@@ -5547,18 +5548,6 @@
"react-native": "*"
}
},
"node_modules/expo-background-fetch": {
"version": "55.0.10",
"resolved": "https://registry.npmmirror.com/expo-background-fetch/-/expo-background-fetch-55.0.10.tgz",
"integrity": "sha512-JRSlQ2GEigUxKqHi11xLPz5/T02O7YzsV1xNKDOxr2SXSERmAN9WFD9l2F9SDw5tUz53ewI1kndHQ6FmSu+7Tw==",
"license": "MIT",
"dependencies": {
"expo-task-manager": "~55.0.10"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-background-task": {
"version": "55.0.10",
"resolved": "https://registry.npmmirror.com/expo-background-task/-/expo-background-task-55.0.10.tgz",
@@ -5592,12 +5581,11 @@
}
},
"node_modules/expo-constants": {
"version": "55.0.9",
"resolved": "https://registry.npmmirror.com/expo-constants/-/expo-constants-55.0.9.tgz",
"integrity": "sha512-iBiXjZeuU5S/8docQeNzsVvtDy4w0zlmXBpFEi1ypwugceEpdQQab65TVRbusXAcwpNVxCPMpNlDssYp0Pli2g==",
"version": "55.0.15",
"resolved": "https://registry.npmmirror.com/expo-constants/-/expo-constants-55.0.15.tgz",
"integrity": "sha512-w394fcZLJjeKN+9ZnJzL/HiarE1nwZFDa+3S9frevh6Ur+MAAs9QDrcXhDrV8T3xqRzzYaqsP6Z8TFZ4efWN1A==",
"license": "MIT",
"dependencies": {
"@expo/config": "~55.0.10",
"@expo/env": "~2.1.1"
},
"peerDependencies": {
@@ -5747,6 +5735,15 @@
"expo": "*"
}
},
"node_modules/expo-intent-launcher": {
"version": "55.0.9",
"resolved": "https://registry.npmmirror.com/expo-intent-launcher/-/expo-intent-launcher-55.0.9.tgz",
"integrity": "sha512-s8k8dF8PfgzN0c+xzNTd9ceHh+/6mt2ncnMuqFaIIry2BeDGITbsgVijHr39eB5vS+KopCcOYYBYr+O1epx4TA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-json-utils": {
"version": "55.0.0",
"resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-55.0.0.tgz",
@@ -5850,16 +5847,16 @@
}
},
"node_modules/expo-notifications": {
"version": "55.0.13",
"resolved": "https://registry.npmmirror.com/expo-notifications/-/expo-notifications-55.0.13.tgz",
"integrity": "sha512-vbtSBcMkYtNTO+6WKdeOzysOqvtmiq/sQrUKJpYcB75m9hBFcAfI2klpXdUiGg5kMr/ygBmFENSolQt1B9QY8A==",
"version": "55.0.20",
"resolved": "https://registry.npmmirror.com/expo-notifications/-/expo-notifications-55.0.20.tgz",
"integrity": "sha512-ENwHZtr2ApR4VaqwwYluEi+ocip2rIkZfHQVi263fZXW3WWVuPa+VxWKtT0KLcvWYGld8lEqwAHWmFWPS6aG7A==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.8.12",
"@expo/image-utils": "^0.8.13",
"abort-controller": "^3.0.0",
"badgin": "^1.1.5",
"expo-application": "~55.0.10",
"expo-constants": "~55.0.8"
"expo-application": "~55.0.14",
"expo-constants": "~55.0.15"
},
"peerDependencies": {
"expo": "*",
@@ -7357,6 +7354,15 @@
"node": ">=8"
}
},
"node_modules/jcore-react-native": {
"version": "2.3.5",
"resolved": "https://registry.npmmirror.com/jcore-react-native/-/jcore-react-native-2.3.5.tgz",
"integrity": "sha512-rxkDPGiSyNVHjNibUGeqFaV07PbMqKZnoHJa0srt002QGUH4/9K4Ux2tK0EkqHLFvGFsJqGWwjaxEqQIPvoPTA==",
"license": "ISC",
"peerDependencies": {
"react-native": ">= 0.60"
}
},
"node_modules/jest-environment-node": {
"version": "29.7.0",
"resolved": "https://registry.npmmirror.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz",
@@ -7562,6 +7568,16 @@
"@sideway/pinpoint": "^2.0.0"
}
},
"node_modules/jpush-react-native": {
"version": "3.2.6",
"resolved": "https://registry.npmmirror.com/jpush-react-native/-/jpush-react-native-3.2.6.tgz",
"integrity": "sha512-dnrdfwsZcAYsQLknVMRMFml/f5cDUFlVKaqpgN41au+g2lxYvPyY7v15XZbNiMdnAjzzVgPhmUW8kuptkLtDXw==",
"license": "ISC",
"peerDependencies": {
"jcore-react-native": ">= 1.9.3",
"react-native": ">= 0.50"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -9737,6 +9753,15 @@
"react-native": "*"
}
},
"node_modules/react-native-share": {
"version": "12.2.6",
"resolved": "https://registry.npmmirror.com/react-native-share/-/react-native-share-12.2.6.tgz",
"integrity": "sha512-K9jZCQaTIqSNG37kMVygU1rflVMJm2g0ikslnbbmQ7EgsckYpw7ipePyp01E64hG+HrWNl+z9ZnFWLCC6H+Tiw==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/react-native-sse": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/react-native-sse/-/react-native-sse-1.2.1.tgz",
@@ -9789,6 +9814,55 @@
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/react-native-webrtc": {
"version": "124.0.7",
"resolved": "https://registry.npmmirror.com/react-native-webrtc/-/react-native-webrtc-124.0.7.tgz",
"integrity": "sha512-gnXPdbUS8IkKHq9WNaWptW/yy5s6nMyI6cNn90LXdobPVCgYSk6NA2uUGdT4c4J14BRgaFA95F+cR28tUPkMVA==",
"license": "MIT",
"dependencies": {
"base64-js": "1.5.1",
"debug": "4.3.4",
"event-target-shim": "6.0.2"
},
"peerDependencies": {
"react-native": ">=0.60.0"
}
},
"node_modules/react-native-webrtc/node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"license": "MIT",
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/react-native-webrtc/node_modules/event-target-shim": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-6.0.2.tgz",
"integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
},
"funding": {
"url": "https://github.com/sponsors/mysticatea"
}
},
"node_modules/react-native-webrtc/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"license": "MIT"
},
"node_modules/react-native-webview": {
"version": "13.16.0",
"resolved": "https://registry.npmmirror.com/react-native-webview/-/react-native-webview-13.16.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "carrot_bbs",
"version": "1.0.1",
"name": "with_you",
"version": "0.0.2",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
@@ -20,12 +20,11 @@
"@react-navigation/material-top-tabs": "^7.4.16",
"@react-navigation/native": "^7.1.31",
"@react-navigation/native-stack": "^7.14.2",
"@shopify/flash-list": "2.0.2",
"@shopify/flash-list": "^2.3.1",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6",
"date-fns": "^4.1.0",
"expo": "~55.0.4",
"expo-background-fetch": "~55.0.10",
"expo-background-task": "~55.0.10",
"expo-camera": "^55.0.10",
"expo-constants": "~55.0.7",
@@ -35,9 +34,10 @@
"expo-haptics": "~55.0.8",
"expo-image": "^55.0.5",
"expo-image-picker": "^55.0.10",
"expo-intent-launcher": "~55.0.9",
"expo-linear-gradient": "^55.0.8",
"expo-media-library": "~55.0.9",
"expo-notifications": "^55.0.10",
"expo-notifications": "~55.0.0",
"expo-router": "^55.0.4",
"expo-sqlite": "~55.0.10",
"expo-status-bar": "~55.0.4",
@@ -45,6 +45,8 @@
"expo-task-manager": "~55.0.9",
"expo-updates": "^55.0.12",
"expo-video": "^55.0.10",
"jcore-react-native": "^2.3.5",
"jpush-react-native": "^3.2.6",
"katex": "^0.16.42",
"markdown-it": "^14.1.1",
"prismjs": "^1.30.0",
@@ -57,8 +59,10 @@
"react-native-reanimated": "^4.2.1",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0",
"react-native-share": "^12.2.6",
"react-native-sse": "^1.2.1",
"react-native-web": "^0.21.0",
"react-native-webrtc": "^124.0.7",
"react-native-webview": "13.16.0",
"react-native-worklets": "0.7.2",
"zod": "^4.3.6",

View File

@@ -1,394 +0,0 @@
# Messages模块与PostService/Manager架构对比分析报告
## 一、Messages模块架构分析
### 1.1 架构层次结构
Messages模块采用了**清晰的分层架构**,各层职责明确:
```
┌─────────────────────────────────────────────────────────────┐
│ 表现层 (Screens) │
│ ChatScreen, MessageListScreen, NotificationsScreen │
├─────────────────────────────────────────────────────────────┤
│ Hooks层 │
│ useDifferentialMessages, useChatScreen │
├─────────────────────────────────────────────────────────────┤
│ 用例层 (UseCases) │
│ ProcessMessageUseCase │
├─────────────────────────────────────────────────────────────┤
│ 领域层 (Entities) │
│ Message, Conversation, GroupNotice │
├─────────────────────────────────────────────────────────────┤
│ 仓库层 (Repositories) │
│ MessageRepository, IMessageRepository │
├─────────────────────────────────────────────────────────────┤
│ 数据源层 (DataSources) │
│ SSEClient, LocalDataSource, ApiDataSource │
└─────────────────────────────────────────────────────────────┘
```
### 1.2 核心组件详解
#### 1.2.1 领域实体层 (Core/Entities)
- **Message.ts**: 定义消息、会话、群通知等核心领域模型
- 包含工厂函数:`createMessage`, `createConversation`
- 包含业务逻辑函数:`isMessageFromCurrentUser`, `shouldIncrementUnread`, `buildTextContent`
#### 1.2.2 用例层 (Core/UseCases)
- **ProcessMessageUseCase**: 核心业务逻辑编排器
- 订阅SSE事件chat, group_message, read, recall, typing, group_notice等
- 消息去重处理processedMessageIds Set
- 已读状态保护pendingReadMap + 版本号机制)
- 用户信息缓存与去重请求pendingUserRequests Map
- 事件发布订阅模式subscribers Set
#### 1.2.3 仓库层 (Data/Repositories)
- **IMessageRepository**: 定义数据访问接口
- 消息操作getMessages, saveMessage, updateMessageStatus, markAsRead
- 会话操作getConversations, saveConversation, updateUnreadCount
- 同步操作syncMessages, getServerLastSeq
- **MessageRepository**: 实现接口
- 封装SQLite数据库操作
- 消息与CachedMessage的转换
#### 1.2.4 映射器层 (Data/Mappers)
- **MessageMapper**: 数据转换
- `fromApiResponse`: API响应 → 应用模型
- `fromDbRecord`: 数据库记录 → 应用模型
- `toDbRecord`: 应用模型 → 数据库记录
- `toApiRequest`: 应用模型 → API请求
- 创建消息片段:`createTextSegment`, `createImageSegment`
#### 1.2.5 差异计算基础设施 (Infrastructure/Diff)
- **MessageDiffCalculator**: 消息列表差异计算
- 计算added, updated, deleted, moved, unchanged
- 变化比例检测changeRatio > 0.5 时触发重置)
- 增量差异计算(基于缓存)
- **MessageUpdateBatcher**: 批量更新处理器
- 16ms批量间隔约60fps
- 去重和合并更新
- 100ms最大等待时间
- 统计信息totalBatches, totalUpdates, averageBatchSize
- **types.ts**: 完整的类型定义
- MessageUpdateType枚举ADD, UPDATE, DELETE, MOVE, BATCH_*
- DiffResult, DiffConfig等接口
#### 1.2.6 Hook层
- **useDifferentialMessages**: 差异更新Hook
- 集成DiffCalculator和Batcher
- 自动处理批量更新
- 提供flush, reset, forceUpdate方法
- 支持配置enableDiff, enableBatching, maxMessageCount, changeRatioThreshold
### 1.3 数据流
```
SSE事件 → ProcessMessageUseCase → 事件发布 → useChatScreen → useDifferentialMessages
↓ ↓
MessageRepository 差异计算 + 批量处理
↓ ↓
SQLite数据库 优化后的消息列表
```
### 1.4 状态管理方式
- **发布-订阅模式**ProcessMessageUseCase作为事件中心
- **本地SQLite缓存**:消息持久化存储
- **内存状态**通过Hook返回组件直接消费
- **差异更新**通过MessageDiffCalculator + MessageUpdateBatcher减少重渲染
---
## 二、PostService/Manager架构分析
### 2.1 架构层次结构
Post模块采用了**扁平化架构**Service和Manager层职责混合
```
┌─────────────────────────────────────────────────────────────┐
│ 表现层 (Screens) │
│ HomeScreen, PostDetailScreen, CreatePostScreen │
├─────────────────────────────────────────────────────────────┤
│ Hooks层 │
│ useCursorPagination (通用), usePrefetch │
├─────────────────────────────────────────────────────────────┤
│ Store层 (Zustand) │
│ useUserStore (直接操作状态), postManager (缓存管理) │
├─────────────────────────────────────────────────────────────┤
│ Service层 │
│ postService (API调用 + 状态更新) │
├─────────────────────────────────────────────────────────────┤
│ 数据源 │
│ API (直接调用) │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 核心组件详解
#### 2.2.1 Service层 (Services)
- **postService.ts**: 帖子服务
- CRUD操作getPosts, getPost, createPost, updatePost, deletePost
- 互动操作likePost, unlikePost, favoritePost, unfavoritePost
- 分页支持:传统分页 + 游标分页
- **问题**直接操作useUserStore违反分层原则
#### 2.2.2 Manager层 (Stores)
- **postManager.ts**: 帖子缓存管理器
- 继承CacheBus发布订阅基类
- 内存缓存listCache, detailCache
- 请求去重pendingRequests Map
- TTL管理LIST_TTL=30s, DETAIL_TTL=60s
- 后台刷新机制
#### 2.2.3 Store层 (Zustand)
- **useUserStore**: 用户状态存储
- 直接存储posts数组
- 帖子操作副作用直接修改状态
- 混合了用户数据和帖子数据
#### 2.2.4 映射器层 (Data/Mappers)
- **PostMapper**: 数据转换
- `fromApiResponse`: API响应 → 应用模型
- `fromApiResponseList`: 批量转换
- `toApiRequest`: 应用模型 → API请求
- 相比MessageMapper缺少数据库记录转换方法
### 2.3 数据流
```
HomeScreen → useCursorPagination → postService.getPostsCursor
useUserStore.setState(posts)
PostCard组件渲染
```
### 2.4 状态管理方式
- **Zustand Store**:集中式状态管理
- **内存缓存**postManager提供应用级缓存
- **请求去重**dedupe方法防止重复请求
- **直接修改**postService直接调用useUserStore.setState
---
## 三、主要架构差异
### 3.1 分层复杂度
| 维度 | Messages模块 | Post模块 |
|------|-------------|---------|
| 层次深度 | 6层Entity→UseCase→Repository→Mapper→Hook→Screen | 4层Service→Store→Hook→Screen |
| 用例层 | 独立ProcessMessageUseCase | 无 |
| 仓库接口 | IMessageRepository抽象接口 | 无 |
| 基础设施 | Diff模块Calculator + Batcher | 无 |
### 3.2 数据持久化
| 维度 | Messages模块 | Post模块 |
|------|-------------|---------|
| 本地存储 | SQLite数据库 | 无 |
| 缓存抽象 | MessageRepository封装 | postManager内存缓存 |
| 离线支持 | 完整 | 不支持 |
### 3.3 实时更新
| 维度 | Messages模块 | Post模块 |
|------|-------------|---------|
| 推送机制 | SSE实时推送 | 轮询/下拉刷新 |
| 事件订阅 | ProcessMessageUseCase发布订阅 | 无 |
| 增量更新 | MessageDiffCalculator | 无 |
### 3.4 状态管理
| 维度 | Messages模块 | Post模块 |
|------|-------------|---------|
| 管理方式 | 发布订阅 + Hook局部状态 | Zustand全局Store |
| 状态来源 | useChatScreen, useDifferentialMessages | useUserStore |
| 批量更新 | MessageUpdateBatcher | 无 |
| 差异检测 | MessageDiffCalculator | 无 |
### 3.5 依赖方向
**Messages模块正确**
```
Hook → UseCase → Repository → DataSource
Entity不依赖外部
```
**Post模块问题**
```
Service → Store循环依赖风险
API直接调用
```
### 3.6 核心问题总结
| # | 问题 | Messages | Post |
|---|------|---------|------|
| 1 | 违反分层原则 | 无 | postService直接操作useUserStore |
| 2 | 缺少UseCase层 | ProcessMessageUseCase | 无业务逻辑编排 |
| 3 | 缺少Repository抽象 | IMessageRepository | 无数据访问接口 |
| 4 | 无差异更新 | useDifferentialMessages | 无 |
| 5 | 无批量处理 | MessageUpdateBatcher | 无 |
| 6 | 无本地持久化 | SQLite | 无 |
| 7 | 无实时推送 | SSE | 无 |
---
## 四、对齐建议
### 4.1 架构重构目标
将Post模块对齐到Messages模块的架构模式
```
┌─────────────────────────────────────────────────────────────┐
│ 表现层 (Screens) │
│ HomeScreen, PostDetailScreen │
├─────────────────────────────────────────────────────────────┤
│ Hooks层 │
│ useDifferentialPosts (新增) │
├─────────────────────────────────────────────────────────────┤
│ 用例层 (UseCases) │
│ ProcessPostUseCase (新增) │
├─────────────────────────────────────────────────────────────┤
│ 领域层 (Entities) │
│ Post, PostComment (新增) │
├─────────────────────────────────────────────────────────────┤
│ 仓库层 (Repositories) │
│ PostRepository, IPostRepository (新增) │
├─────────────────────────────────────────────────────────────┤
│ Service层 │
│ postService (仅保留API调用移除状态操作) │
└─────────────────────────────────────────────────────────────┘
```
### 4.2 具体改造项
#### 4.2.1 创建Post领域实体
```typescript
// src/core/entities/Post.ts
export interface Post {
id: string;
authorId: string;
title: string;
content: string;
images: string[];
likeCount: number;
commentCount: number;
// ... 其他字段
}
```
#### 4.2.2 创建ProcessPostUseCase
```typescript
// src/core/usecases/ProcessPostUseCase.ts
class ProcessPostUseCase {
// 帖子增删改查
// 点赞/收藏逻辑
// 事件发布订阅
}
```
#### 4.2.3 创建IPostRepository接口
```typescript
// src/data/repositories/interfaces/IPostRepository.ts
interface IPostRepository {
getPosts(type: string, page: number, pageSize: number): Promise<Post[]>;
savePost(post: Post): Promise<void>;
updatePost(postId: string, updates: Partial<Post>): Promise<void>;
// ...
}
```
#### 4.2.4 创建PostRepository实现
```typescript
// src/data/repositories/PostRepository.ts
// 封装SQLite操作实现IPostRepository接口
```
#### 4.2.5 创建useDifferentialPosts Hook
```typescript
// src/hooks/useDifferentialPosts.ts
// 类似useDifferentialMessages处理帖子列表差异更新
```
#### 4.2.6 改造postService
- 移除对useUserStore的直接依赖
- 仅保留API调用职责
---
## 五、架构对比图
```mermaid
graph TB
subgraph "Messages模块 [理想架构]"
E1[Entity<br/>Message.ts]
U1[UseCase<br/>ProcessMessageUseCase]
R1[Repository<br/>MessageRepository]
M1[Mapper<br/>MessageMapper]
H1[Hook<br/>useDifferentialMessages]
D1[Diff基础设施<br/>DiffCalculator + Batcher]
S1[(SQLite)]
E1 --> U1
U1 --> R1
U1 --> D1
R1 --> S1
M1 --> R1
D1 --> H1
H1 --> S1
end
subgraph "Post模块 [当前架构]"
E2[PostMapper]
SV2[postService<br/>直接操作Store]
ST2[useUserStore<br/>Zustand]
H2[Hook<br/>useCursorPagination]
PM2[postManager<br/>内存缓存]
E2 --> SV2
SV2 --> ST2
H2 --> SV2
PM2 --> ST2
end
style E1 fill:#90EE90
style U1 fill:#90EE90
style R1 fill:#90EE90
style D1 fill:#90EE90
style H1 fill:#90EE90
style S1 fill:#90EE90
style SV2 fill:#FFB6C1
style ST2 fill:#FFB6C1
```
---
## 六、结论
Messages模块是一个**架构完善**的模块,具备:
1. 清晰的分层架构
2. 独立的业务逻辑编排层UseCase
3. 完整的数据持久化SQLite
4. 高效的差异更新机制
5. 实时事件推送能力
Post模块当前存在以下问题
1. **违反分层原则**Service直接操作Store
2. **缺少UseCase层**:业务逻辑分散
3. **无数据抽象**缺少Repository接口
4. **无差异更新**:每次全量更新导致性能问题
5. **无本地持久化**:无法离线使用
建议按照对齐建议逐步重构Post模块使其架构与Messages模块对齐。

View File

@@ -0,0 +1,467 @@
const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
const FOREGROUND_SERVICE_PERMISSION = 'android.permission.FOREGROUND_SERVICE';
const FOREGROUND_SERVICE_DATA_SYNC = 'android.permission.FOREGROUND_SERVICE_DATA_SYNC';
/**
* Expo Config Plugin为 Android 添加前台服务保活
*
* 功能:
* 1. 添加 FOREGROUND_SERVICE 相关权限
* 2. 在 AndroidManifest 中声明 Service
* 3. 生成 Kotlin 原生服务代码
* 4. 生成 React Native Bridge 模块
*
* 参考Element Android 的 GuardAndroidService
*/
const withForegroundService = (config, options = {}) => {
const {
notificationTitle = '威友',
notificationBody = '正在后台同步消息',
notificationIcon = 'ic_notification',
channelId = 'withyou_sync_foreground',
channelName = '消息同步',
} = options;
// 1. 添加权限和 Service 声明到 AndroidManifest
config = withAndroidManifest(config, (config) => {
const manifest = config.modResults;
// 添加权限声明
const permissions = [
FOREGROUND_SERVICE_PERMISSION,
FOREGROUND_SERVICE_DATA_SYNC,
];
permissions.forEach((permission) => {
const existingPermission = manifest.manifest['uses-permission']?.find(
(p) => p.$['android:name'] === permission
);
if (!existingPermission) {
if (!manifest.manifest['uses-permission']) {
manifest.manifest['uses-permission'] = [];
}
manifest.manifest['uses-permission'].push({
$: { 'android:name': permission },
});
}
});
// 添加 Service 声明
if (!manifest.manifest.application[0].service) {
manifest.manifest.application[0].service = [];
}
const existingService = manifest.manifest.application[0].service.find(
(s) => s.$['android:name'] === '.SyncForegroundService'
);
if (!existingService) {
manifest.manifest.application[0].service.push({
$: {
'android:name': '.SyncForegroundService',
'android:enabled': 'true',
'android:exported': 'false',
'android:foregroundServiceType': 'dataSync',
},
});
}
return config;
});
// 2. 生成 Kotlin 原生代码
config = withDangerousMod(config, [
'android',
async (config) => {
const projectRoot = config.modRequest.projectRoot;
const packageName = config.android.package;
const kotlinPath = path.join(
projectRoot,
'android/app/src/main/java',
packageName.replace(/\./g, '/')
);
// 确保目录存在
if (!fs.existsSync(kotlinPath)) {
fs.mkdirSync(kotlinPath, { recursive: true });
}
// 生成 SyncForegroundService.kt
const serviceCode = generateServiceCode(packageName, {
notificationTitle,
notificationBody,
notificationIcon,
channelId,
channelName,
});
fs.writeFileSync(
path.join(kotlinPath, 'SyncForegroundService.kt'),
serviceCode
);
// 生成 ForegroundServiceModule.kt (React Native Bridge)
const moduleCode = generateModuleCode(packageName);
fs.writeFileSync(
path.join(kotlinPath, 'ForegroundServiceModule.kt'),
moduleCode
);
// 生成 ForegroundServicePackage.kt
const packageCode = generatePackageCode(packageName);
fs.writeFileSync(
path.join(kotlinPath, 'ForegroundServicePackage.kt'),
packageCode
);
// 修改 MainApplication.kt 注册模块
const mainAppPath = path.join(kotlinPath, 'MainApplication.kt');
if (fs.existsSync(mainAppPath)) {
let content = fs.readFileSync(mainAppPath, 'utf-8');
// 添加 import
if (!content.includes('ForegroundServicePackage')) {
content = content.replace(
/package .*\n/,
`$&\nimport ${packageName}.ForegroundServicePackage\n`
);
}
// 在 packages 列表中添加
if (!content.includes('ForegroundServicePackage()')) {
// 查找 PackageList(this).packages.apply 块
content = content.replace(
/(PackageList\(this\)\.packages\.apply\s*\{)/,
`$1\n // 前台服务模块\n add(ForegroundServicePackage())`
);
}
fs.writeFileSync(mainAppPath, content);
}
// 创建通知图标目录和文件
const drawablePath = path.join(projectRoot, 'android/app/src/main/res/drawable');
if (!fs.existsSync(drawablePath)) {
fs.mkdirSync(drawablePath, { recursive: true });
}
const iconPath = path.join(drawablePath, 'ic_notification.xml');
if (!fs.existsSync(iconPath)) {
fs.writeFileSync(iconPath, generateNotificationIcon());
}
return config;
},
]);
return config;
};
/**
* 生成前台服务 Kotlin 代码
*/
function generateServiceCode(packageName, options) {
const { notificationTitle, notificationBody, notificationIcon, channelId, channelName } = options;
return `package ${packageName}
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
/**
* 前台同步服务
*
* 在通知栏显示常驻通知,防止应用进程被系统杀死
* 类似 Element Android 的 GuardAndroidService 和 V2Ray 的保活机制
*/
class SyncForegroundService : Service() {
companion object {
const val NOTIFICATION_ID = 1001
const val CHANNEL_ID = "${channelId}"
const val CHANNEL_NAME = "${channelName}"
const val ACTION_START = "${packageName}.foreground.START"
const val ACTION_STOP = "${packageName}.foreground.STOP"
const val ACTION_UPDATE = "${packageName}.foreground.UPDATE"
const val EXTRA_TITLE = "title"
const val EXTRA_BODY = "body"
/**
* 启动前台服务
*/
fun start(context: Context, title: String = "${notificationTitle}", body: String = "${notificationBody}") {
val intent = Intent(context, SyncForegroundService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_BODY, body)
}
try {
ContextCompat.startForegroundService(context, intent)
} catch (e: Exception) {
android.util.Log.e("SyncForegroundService", "启动前台服务失败", e)
}
}
/**
* 停止前台服务
*/
fun stop(context: Context) {
val intent = Intent(context, SyncForegroundService::class.java).apply {
action = ACTION_STOP
}
context.startService(intent)
}
/**
* 更新通知内容
*/
fun update(context: Context, title: String, body: String) {
val intent = Intent(context, SyncForegroundService::class.java).apply {
action = ACTION_UPDATE
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_BODY, body)
}
context.startService(intent)
}
}
private var notificationTitle: String = "${notificationTitle}"
private var notificationBody: String = "${notificationBody}"
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
notificationTitle = intent.getStringExtra(EXTRA_TITLE) ?: "${notificationTitle}"
notificationBody = intent.getStringExtra(EXTRA_BODY) ?: "${notificationBody}"
startForegroundCompat()
}
ACTION_STOP -> {
stopForegroundCompat()
stopSelf()
}
ACTION_UPDATE -> {
notificationTitle = intent.getStringExtra(EXTRA_TITLE) ?: notificationTitle
notificationBody = intent.getStringExtra(EXTRA_BODY) ?: notificationBody
updateNotification()
}
}
// START_STICKY: 服务被杀后自动重启
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
/**
* 启动前台服务(兼容 Android Q+
*/
private fun startForegroundCompat() {
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
/**
* 停止前台服务(兼容 Android N+
*/
private fun stopForegroundCompat() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
}
/**
* 创建通知渠道
*/
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_MIN // 最低重要性,不打扰用户
).apply {
description = "后台消息同步服务"
setSound(null, null)
setShowBadge(false)
enableLights(false)
enableVibration(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
/**
* 构建通知
*/
private fun buildNotification(): Notification {
// 点击通知打开应用
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
val pendingIntent = PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(notificationTitle)
.setContentText(notificationBody)
.setSmallIcon(R.drawable.${notificationIcon})
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setOngoing(true) // 不可滑动清除
.setShowWhen(false)
.setContentIntent(pendingIntent)
.build()
}
/**
* 更新通知内容
*/
private fun updateNotification() {
val manager = getSystemService(NotificationManager::class.java)
manager.notify(NOTIFICATION_ID, buildNotification())
}
}
`;
}
/**
* 生成 React Native Bridge 模块代码
*/
function generateModuleCode(packageName) {
return `package ${packageName}
import android.content.Context
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.Promise
/**
* React Native Bridge: 前台服务控制模块
*/
class ForegroundServiceModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "ForegroundService"
/**
* 启动前台服务
*/
@ReactMethod
fun start(title: String, body: String, promise: Promise) {
try {
SyncForegroundService.start(reactApplicationContext, title, body)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
}
}
/**
* 停止前台服务
*/
@ReactMethod
fun stop(promise: Promise) {
try {
SyncForegroundService.stop(reactApplicationContext)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
}
}
/**
* 更新通知内容
*/
@ReactMethod
fun update(title: String, body: String, promise: Promise) {
try {
SyncForegroundService.update(reactApplicationContext, title, body)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
}
}
}
`;
}
/**
* 生成 React Native Package 代码
*/
function generatePackageCode(packageName) {
return `package ${packageName}
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class ForegroundServicePackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(ForegroundServiceModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
`;
}
/**
* 生成通知图标 XML
*/
function generateNotificationIcon() {
return `<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- 背景圆 -->
<path
android:fillColor="#4CAF50"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
<!-- 对勾符号 -->
<path
android:fillColor="#FFFFFF"
android:pathData="M10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z"/>
</vector>
`;
}
module.exports = withForegroundService;

283
plugins/withJPush.js Normal file
View File

@@ -0,0 +1,283 @@
const {
withAndroidManifest,
withDangerousMod,
withAppDelegate,
withInfoPlist,
withAppBuildGradle,
} = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
const withJPush = (config, options = {}) => {
const { appKey, channel = 'developer-default' } = options;
if (!appKey) {
throw new Error('JPush appKey is required in plugin options');
}
// 1. Android: Inject manifestPlaceholders into app/build.gradle
config = withAppBuildGradle(config, (config) => {
const block = '\n manifestPlaceholders = [\n' +
' JPUSH_APPKEY: "' + appKey + '",\n' +
' JPUSH_CHANNEL: "' + channel + '"\n' +
' ]';
const contents = config.modResults.contents;
if (contents.includes('manifestPlaceholders')) {
config.modResults.contents = contents.replace(
/manifestPlaceholders\s*=\s*\[[\s\S]*?\]/,
block.trim()
);
} else if (contents.includes('REACT_NATIVE_RELEASE_LEVEL')) {
const lines = contents.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;
}
}
config.modResults.contents = lines.join('\n');
}
return config;
});
// 2. Android: Add JPush metadata and permissions to AndroidManifest
config = withAndroidManifest(config, (config) => {
const manifest = config.modResults;
if (!manifest.manifest['uses-permission']) {
manifest.manifest['uses-permission'] = [];
}
const requiredPermissions = [
'android.permission.RECEIVE_USER_PRESENT',
'android.permission.POST_NOTIFICATIONS',
];
requiredPermissions.forEach((permission) => {
const exists = manifest.manifest['uses-permission'].find(
(p) => p.$['android:name'] === permission
);
if (!exists) {
manifest.manifest['uses-permission'].push({
$: { 'android:name': permission },
});
}
});
return config;
});
// 3. iOS: Add JPush initialization to AppDelegate
config = withAppDelegate(config, (config) => {
const { contents } = config.modResults;
if (contents.includes('JPush') || contents.includes('JCoreModule')) {
return config;
}
const isSwift = contents.includes('class AppDelegate') || contents.includes('@main');
if (isSwift) {
let newContents = contents;
if (!newContents.includes('import JPushRN')) {
if (newContents.includes('import UIKit')) {
newContents = newContents.replace(
/(import UIKit\n)/,
`$1import UserNotifications\n`
);
} else {
newContents = newContents.replace(
/(internal import Expo\n)/,
`$1import UserNotifications\n`
);
}
}
// Swift: Add JPush init in application(_:didFinishLaunchingWithOptions:)
// Match various Swift signatures
const swiftDidFinishPatterns = [
/public override func application\(\s*_ application: UIApplication,\s*didFinishLaunchingWithOptions launchOptions: \[UIApplication\.LaunchOptionsKey: Any\]\? = nil\s*\) -> Bool \{/,
/func application\(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: \[UIApplication\.LaunchOptionsKey: Any\]\?\?\) -> Bool \{/,
];
let didInsertInit = false;
for (const pattern of swiftDidFinishPatterns) {
if (pattern.test(newContents)) {
newContents = newContents.replace(
pattern,
`$&\n // JPush initialization\n #if DEBUG\n JCoreModule.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)\n #else\n JCoreModule.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)\n #endif`
);
didInsertInit = true;
break;
}
}
// Fallback: find the didFinishLaunchingWithOptions method start and insert after the opening brace
if (!didInsertInit && newContents.includes('didFinishLaunchingWithOptions')) {
const lines = newContents.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('didFinishLaunchingWithOptions') && lines[i].includes('{')) {
lines.splice(i + 1, 0,
' // JPush initialization',
' #if DEBUG',
` JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)`,
' #else',
` JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)`,
' #endif',
' UNUserNotificationCenter.current().delegate = self'
);
newContents = lines.join('\n');
didInsertInit = true;
break;
}
}
}
// Also add delegate = self after regex-based insertion if not already present
if (didInsertInit && !newContents.includes('UNUserNotificationCenter.current().delegate')) {
newContents = newContents.replace(
/(#endif\n)(\s+let delegate)/,
`$1 UNUserNotificationCenter.current().delegate = self\n$2`
);
}
// Swift: Add delegate methods before the closing brace of the class
const jpushSwiftMethods = `
// JPush: Register for remote notifications
override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
JPUSHService.registerDeviceToken(deviceToken)
}
override func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
NSLog("did fail to register for remote notifications with error: %@", error.localizedDescription)
}
// JPush: Handle remote notification
override func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
JPUSHService.handleRemoteNotification(userInfo)
completionHandler(.newData)
}
// UNUserNotificationCenter delegate: foreground notification display
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.alert, .badge, .sound])
}
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
JPUSHService.handleRemoteNotification(response.notification.request.content.userInfo)
completionHandler()
}
`;
if (!newContents.includes('JPUSHService.registerDeviceToken')) {
// Find the closing brace of the AppDelegate class
const lastBraceIndex = newContents.lastIndexOf('}');
if (lastBraceIndex !== -1) {
newContents = newContents.slice(0, lastBraceIndex) + jpushSwiftMethods + '\n' + newContents.slice(lastBraceIndex);
}
}
config.modResults.contents = newContents;
} else {
// ObjC AppDelegate
let newContents = contents;
if (!newContents.includes('#import <UserNotifications/UserNotifications.h>')) {
newContents = newContents.replace(
/(#import "AppDelegate.h")/,
`$1\n#import <UserNotifications/UserNotifications.h>`
);
}
if (!newContents.includes('#import <jcore-react-native/JCoreModule.h>')) {
newContents = newContents.replace(
/(#import "AppDelegate.h")/,
`$1\n#import <jcore-react-native/JCoreModule.h>`
);
}
const didFinishLaunchPattern = /(- \(BOOL\)application:\(UIApplication \*\)application didFinishLaunchingWithOptions:\(NSDictionary \*\)launchOptions\s*\{)/;
if (didFinishLaunchPattern.test(newContents)) {
newContents = newContents.replace(
didFinishLaunchPattern,
`$1\n // JPush initialization\n #if DEBUG\n [JCoreModule setupWithOption:launchOptions appKey:@"${appKey}" channel:@"${channel}" apsForProduction:NO];\n #else\n [JCoreModule setupWithOption:launchOptions appKey:@"${appKey}" channel:@"${channel}" apsForProduction:YES];\n #endif\n`
);
}
const jpushMethods = `
// JPush: Register for remote notifications
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
[JCoreModule registerDeviceToken:deviceToken];
}
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error {
NSLog(@"did fail to register for remote notifications with error: %@", error);
}
// JPush: Handle remote notification
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {
[JCoreModule didReceiveRemoteNotification:userInfo];
completionHandler(UIBackgroundFetchResultNewData);
}
// UNUserNotificationCenter delegate
- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler {
completionHandler(UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionBadge | UNNotificationPresentationOptionSound);
}
- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler {
[JCoreModule didReceiveRemoteNotification:response.notification.request.content.userInfo];
completionHandler();
}
`;
if (!newContents.includes('JCoreModule registerDeviceToken')) {
newContents = newContents.replace(
/@end\s*$/,
`${jpushMethods}\n@end\n`
);
}
config.modResults.contents = newContents;
}
return config;
});
// 4. iOS: Add bridging header for Swift if needed
config = withDangerousMod(config, [
'ios',
async (config) => {
const appDelegatePath = path.join(config.modRequest.platformProjectRoot, 'app', 'AppDelegate.swift');
if (fs.existsSync(appDelegatePath)) {
const bridgingHeaderSearch = path.join(config.modRequest.platformProjectRoot, 'app', 'app-Bridging-Header.h');
if (fs.existsSync(bridgingHeaderSearch)) {
let bridgingContent = fs.readFileSync(bridgingHeaderSearch, 'utf-8');
if (!bridgingContent.includes('JCoreModule')) {
bridgingContent += '\n#import "JPUSHService.h"\n#import <UserNotifications/UserNotifications.h>\n';
fs.writeFileSync(bridgingHeaderSearch, bridgingContent);
}
}
}
return config;
},
]);
// 5. iOS: Add required capabilities and permissions to Info.plist
config = withInfoPlist(config, (config) => {
config.modResults.UIBackgroundModes = config.modResults.UIBackgroundModes || [];
if (!config.modResults.UIBackgroundModes.includes('remote-notification')) {
config.modResults.UIBackgroundModes.push('remote-notification');
}
return config;
});
return config;
};
module.exports = withJPush;

View File

@@ -0,0 +1,73 @@
const { withMainActivity } = require('@expo/config-plugins');
/**
* 在 MainActivity 中添加 onConfigurationChanged 方法
* 用于监听系统深色模式变化并通知 React Native
*/
const withMainActivityConfigChange = (config) => {
return withMainActivity(config, async (config) => {
const { contents } = config.modResults;
// 检查是否已经添加了 onConfigurationChanged
if (contents.includes('onConfigurationChanged')) {
return config;
}
// 需要添加的 imports
const importsToAdd = `import android.content.Intent
import android.content.res.Configuration`;
// 需要添加的方法
const methodToAdd = `
/**
* 监听系统配置变化(包括深色模式切换),并广播给 React Native
* 这对于 Appearance API 正确检测系统主题至关重要
*/
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
val intent = Intent("onConfigurationChanged")
intent.putExtra("newConfig", newConfig)
sendBroadcast(intent)
}
`;
let newContents = contents;
// 添加 imports在已有 imports 后面)
if (!newContents.includes('import android.content.Intent')) {
// 找到最后一个 import 语句
const importRegex = /import [^\n]+\n/g;
const imports = newContents.match(importRegex);
if (imports && imports.length > 0) {
const lastImport = imports[imports.length - 1];
const lastImportIndex = newContents.lastIndexOf(lastImport);
const insertPosition = lastImportIndex + lastImport.length;
newContents =
newContents.slice(0, insertPosition) +
importsToAdd +
'\n' +
newContents.slice(insertPosition);
}
}
// 在类体的开头添加 onConfigurationChanged 方法
// 找到类定义后的第一个方法或属性
const classBodyRegex = /class MainActivity\s*:\s*ReactActivity\s*\(\)\s*\{([\s\S]*)/;
const match = newContents.match(classBodyRegex);
if (match) {
// 找到 onCreate 方法之前插入
const onCreateIndex = newContents.indexOf('override fun onCreate');
if (onCreateIndex !== -1) {
newContents =
newContents.slice(0, onCreateIndex) +
methodToAdd +
newContents.slice(onCreateIndex);
}
}
config.modResults.contents = newContents;
return config;
});
};
module.exports = withMainActivityConfigChange;

41
plugins/withSigning.js Normal file
View File

@@ -0,0 +1,41 @@
const { withDangerousMod } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
const withSigning = (config, options = {}) => {
const { teamId = 'X3964MGXHG' } = options;
config = withDangerousMod(config, [
'ios',
async (config) => {
const platformRoot = config.modRequest.platformProjectRoot;
// 1. Inject CODE_SIGN_STYLE and DEVELOPMENT_TEAM into pbxproj
const pbxprojPath = path.join(platformRoot, 'app.xcodeproj', 'project.pbxproj');
if (fs.existsSync(pbxprojPath)) {
let contents = fs.readFileSync(pbxprojPath, 'utf-8');
const entitlementsLine = 'CODE_SIGN_ENTITLEMENTS = app/app.entitlements;';
const signingBlock = `${entitlementsLine}\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tDEVELOPMENT_TEAM = ${teamId};`;
contents = contents.replaceAll(entitlementsLine, signingBlock);
fs.writeFileSync(pbxprojPath, contents);
}
// 2. Set aps-environment to production in entitlements
const entitlementsPath = path.join(platformRoot, 'app', 'app.entitlements');
if (fs.existsSync(entitlementsPath)) {
let entitlements = fs.readFileSync(entitlementsPath, 'utf-8');
entitlements = entitlements.replace(
/<key>aps-environment<\/key>\s*<string>development<\/string>/,
'<key>aps-environment</key>\n\t<string>production</string>'
);
fs.writeFileSync(entitlementsPath, entitlements);
}
return config;
},
]);
return config;
};
module.exports = withSigning;

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
View,
StyleSheet,
@@ -187,7 +187,7 @@ export function AppDesktopShell() {
>
<View style={styles.sidebarHeader}>
<MaterialCommunityIcons name="carrot" size={32} color={colors.primary.main} />
{!isCollapsed && <Text style={styles.logoText}>BBS</Text>}
{!isCollapsed && <Text style={styles.logoText}></Text>}
</View>
<ScrollView style={styles.sidebarContent} showsVerticalScrollIndicator={false}>
{NAV_ITEMS.map((item) => {

View File

@@ -0,0 +1,122 @@
import React, { useImperativeHandle, useCallback } from 'react';
import { View, ScrollView, StyleSheet } from 'react-native';
import { useAppColors, spacing } from '../../../theme';
import { MessageSegment } from '../../../types';
import { EditorBlock } from './blockEditorTypes';
import TextBlockInput from './TextBlockInput';
import ImageBlockView from './ImageBlockView';
import { useBlockEditor } from './useBlockEditor';
export interface BlockEditorHandle {
insertImage: () => Promise<void>;
insertCameraPhoto: () => Promise<void>;
insertTextAtCursor: (text: string) => void;
getSegments: () => MessageSegment[];
getContent: () => string;
hasUploadingImages: () => boolean;
focus: () => void;
}
interface BlockEditorProps {
placeholder?: string;
maxLength?: number;
style?: any;
textStyle?: any;
onContentChange?: (content: string) => void;
initialBlocks?: EditorBlock[];
}
const BlockEditor = React.forwardRef<BlockEditorHandle, BlockEditorProps>(
({ placeholder = '添加正文', maxLength = 2000, style, textStyle, onContentChange, initialBlocks }, ref) => {
const colors = useAppColors();
const editor = useBlockEditor(initialBlocks);
useImperativeHandle(ref, () => ({
insertImage: editor.insertImage,
insertCameraPhoto: editor.insertCameraPhoto,
insertTextAtCursor: editor.insertTextAtCursor,
getSegments: editor.getSegments,
getContent: editor.getContent,
hasUploadingImages: editor.hasUploadingImages,
focus: () => {
const firstTextBlock = editor.blocks.find(b => b.type === 'text');
if (firstTextBlock) {
editor.focusBlock(firstTextBlock.id);
}
},
}));
const handleTextChange = useCallback(
(blockId: string, text: string, mentions: any) => {
editor.updateTextBlock(blockId, text, mentions);
onContentChange?.(editor.getContent());
},
[editor.updateTextBlock, editor.getContent, onContentChange],
);
const handleRemoveImage = useCallback(
(blockId: string) => {
editor.removeImageBlock(blockId);
onContentChange?.(editor.getContent());
},
[editor.removeImageBlock, editor.getContent, onContentChange],
);
return (
<View style={[styles.container, style]}>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
>
{editor.blocks.map(block => {
if (block.type === 'text') {
return (
<TextBlockInput
key={block.id}
block={block}
isActive={editor.activeBlockId === block.id}
placeholder={placeholder}
maxLength={maxLength}
onTextChange={handleTextChange}
onFocusBlock={editor.setActiveBlockId}
onSelectMention={() => {}}
inputRef={(ref) => editor.registerInputRef(block.id, ref)}
style={textStyle}
/>
);
}
return (
<ImageBlockView
key={block.id}
block={block}
onRemove={handleRemoveImage}
/>
);
})}
</ScrollView>
</View>
);
},
);
BlockEditor.displayName = 'BlockEditor';
const styles = StyleSheet.create({
container: {
flexGrow: 1,
},
scroll: {
flexGrow: 1,
},
scrollContent: {
flexGrow: 1,
paddingBottom: spacing.xs,
},
});
export default BlockEditor;

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { View, TouchableOpacity, StyleSheet, Dimensions, ActivityIndicator } from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, spacing, borderRadius } from '../../../theme';
import type { ImageBlock } from './blockEditorTypes';
const SCREEN_WIDTH = Dimensions.get('window').width;
const IMAGE_MAX_WIDTH = SCREEN_WIDTH - 32;
interface ImageBlockViewProps {
block: ImageBlock;
onRemove: (blockId: string) => void;
style?: any;
}
const ImageBlockView: React.FC<ImageBlockViewProps> = ({ block, onRemove, style }) => {
const colors = useAppColors();
const aspectRatio = block.width && block.height
? block.width / block.height
: 4 / 3;
const displayWidth = IMAGE_MAX_WIDTH;
const displayHeight = displayWidth / aspectRatio;
const imageSource = block.remoteUrl
? { uri: block.remoteUrl }
: { uri: block.localUri };
return (
<View style={[styles.container, style]}>
<ExpoImage
source={imageSource}
style={{
width: displayWidth,
height: displayHeight,
borderRadius: borderRadius.md,
}}
contentFit="cover"
cachePolicy="memory-disk"
transition={200}
/>
{block.uploading && (
<View style={styles.uploadingOverlay}>
<ActivityIndicator size="small" color={colors.text.inverse} />
</View>
)}
<TouchableOpacity
style={styles.removeButton}
onPress={() => onRemove(block.id)}
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
>
<View style={styles.removeButtonInner}>
<MaterialCommunityIcons name="close" size={14} color={colors.text.inverse} />
</View>
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginTop: 4,
marginBottom: 0,
position: 'relative',
alignItems: 'center',
},
uploadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
justifyContent: 'center',
alignItems: 'center',
borderRadius: borderRadius.md,
},
removeButton: {
position: 'absolute',
top: 8,
right: 8 + (SCREEN_WIDTH - IMAGE_MAX_WIDTH) / 2,
zIndex: 10,
},
removeButtonInner: {
width: 24,
height: 24,
borderRadius: borderRadius.full,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
justifyContent: 'center',
alignItems: 'center',
},
});
export default ImageBlockView;

View File

@@ -0,0 +1,276 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
View,
TextInput,
ScrollView,
TouchableOpacity,
StyleSheet,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import Text from '../../common/Text';
import Avatar from '../../common/Avatar';
import { useAppColors, spacing, fontSizes, borderRadius } from '../../../theme';
import { useAuthStore } from '../../../stores';
import { authService } from '../../../services/auth';
import type { TextBlock } from './blockEditorTypes';
interface MentionUser {
id: string;
nickname: string;
avatar: string | null;
}
interface TextBlockInputProps {
block: TextBlock;
isActive: boolean;
placeholder: string;
maxLength: number;
onTextChange: (blockId: string, text: string, mentions: Map<number, { endIndex: number; userId: string; nickname: string }>) => void;
onFocusBlock: (blockId: string) => void;
onSelectMention: (blockId: string, userId: string, nickname: string, mentionStartIndex: number) => void;
inputRef: (ref: TextInput | null) => void;
style?: any;
minHeight?: number;
}
const TextBlockInput: React.FC<TextBlockInputProps> = ({
block,
isActive,
placeholder,
maxLength,
onTextChange,
onFocusBlock,
onSelectMention,
inputRef,
style,
minHeight,
}) => {
const colors = useAppColors();
const currentUser = useAuthStore(s => s.currentUser);
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
const [loaded, setLoaded] = useState(false);
const [suggestionMode, setSuggestionMode] = useState<'none' | 'mention'>('none');
const [mentionQuery, setMentionQuery] = useState('');
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
const [selection, setSelection] = useState({ start: 0, end: 0 });
useEffect(() => {
if (!loaded && currentUser?.id) {
loadFollowing();
setLoaded(true);
}
}, [currentUser?.id, loaded]);
const loadFollowing = async () => {
if (!currentUser?.id) return;
try {
const list = await authService.getFollowingList(currentUser.id, 1, 200);
setFollowingUsers(
list.map(u => ({
id: u.id,
nickname: u.nickname || u.username || '',
avatar: u.avatar || null,
}))
);
} catch {
// ignore
}
};
const filteredUsers = followingUsers.filter(u =>
u.nickname.toLowerCase().includes(mentionQuery.toLowerCase())
);
const handleChangeText = useCallback(
(newText: string) => {
const cursorPos = newText.length;
// Check for @ trigger
let atStart = -1;
for (let i = cursorPos - 1; i >= 0; i--) {
if (newText[i] === '@') {
atStart = i;
break;
}
if (newText[i] === ' ' || newText[i] === '\n') {
break;
}
}
if (atStart >= 0) {
const query = newText.slice(atStart + 1, cursorPos);
if (!query.includes(' ') && !query.includes('\n')) {
setMentionQuery(query);
setMentionStartIndex(atStart);
setSuggestionMode('mention');
} else {
setSuggestionMode('none');
}
} else {
setSuggestionMode('none');
}
// Update mentions when text changes: remap existing mentions
const newMentions = new Map<number, { endIndex: number; userId: string; nickname: string }>();
// Simple approach: only keep mentions tracked by this block's own activeMentions
// The parent handles the canonical mentions state
block.activeMentions.forEach((mention, start) => {
if (start < newText.length) {
const mentionText = `@${mention.nickname} `;
const expectedEnd = start + mentionText.length;
// Check if the mention text is still intact
if (newText.slice(start, expectedEnd) === mentionText) {
newMentions.set(start, { ...mention, endIndex: expectedEnd });
}
}
});
onTextChange(block.id, newText, newMentions);
},
[block.id, block.activeMentions, onTextChange],
);
const handleSelectMention = useCallback(
(mentionUser: MentionUser) => {
if (mentionStartIndex < 0) return;
const before = block.text.slice(0, mentionStartIndex);
const mentionText = `@${mentionUser.nickname} `;
const newText = before + mentionText;
const newMentions = new Map(block.activeMentions);
newMentions.set(mentionStartIndex, {
endIndex: before.length + mentionText.length,
userId: mentionUser.id,
nickname: mentionUser.nickname,
});
// Also remove any mention that was partially covered
const toRemove: number[] = [];
newMentions.forEach((m, start) => {
if (start !== mentionStartIndex && start >= mentionStartIndex && start < before.length + mentionText.length) {
toRemove.push(start);
}
});
toRemove.forEach(k => newMentions.delete(k));
onTextChange(block.id, newText, newMentions);
setSuggestionMode('none');
setMentionQuery('');
setMentionStartIndex(-1);
onSelectMention(block.id, mentionUser.id, mentionUser.nickname, mentionStartIndex);
},
[block.id, block.text, mentionStartIndex, onTextChange, onSelectMention],
);
return (
<View>
<TextInput
ref={inputRef}
value={block.text}
onChangeText={handleChangeText}
placeholder={placeholder}
placeholderTextColor={colors.text.hint}
maxLength={maxLength}
multiline
textAlignVertical="top"
scrollEnabled={false}
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
onFocus={() => onFocusBlock(block.id)}
style={[
styles.input,
{
color: colors.text.primary,
minHeight: minHeight,
},
style,
]}
/>
{suggestionMode === 'mention' && isActive && filteredUsers.length > 0 && (
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
<ScrollView
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
style={styles.mentionList}
>
{filteredUsers.map(item => (
<TouchableOpacity
key={item.id}
style={[styles.mentionItem, { borderBottomColor: colors.divider }]}
onPress={() => handleSelectMention(item)}
activeOpacity={0.7}
>
<Avatar source={item.avatar} size={36} name={item.nickname} />
<View style={styles.mentionInfo}>
<Text style={[styles.mentionName, { color: colors.text.primary }]}>
{item.nickname}
</Text>
<Text style={[styles.mentionHint, { color: colors.text.hint }]}>
</Text>
</View>
<MaterialCommunityIcons name="at" size={18} color={colors.primary.main} />
</TouchableOpacity>
))}
</ScrollView>
</View>
)}
{suggestionMode === 'mention' && isActive && filteredUsers.length === 0 && (
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
<Text style={[styles.emptyText, { color: colors.text.hint }]}>
</Text>
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
input: {
fontSize: fontSizes.md,
paddingHorizontal: 0,
paddingVertical: 0,
textAlignVertical: 'top' as const,
},
mentionPanel: {
borderWidth: 1,
borderRadius: borderRadius.md,
maxHeight: 200,
marginHorizontal: spacing.sm,
},
mentionList: {
maxHeight: 200,
},
mentionItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
},
mentionInfo: {
flex: 1,
marginLeft: spacing.sm,
justifyContent: 'center',
},
mentionName: {
fontSize: fontSizes.md,
fontWeight: '500',
},
mentionHint: {
fontSize: fontSizes.xs,
marginTop: 2,
},
emptyText: {
fontSize: fontSizes.sm,
padding: spacing.md,
textAlign: 'center',
},
});
export default TextBlockInput;

View File

@@ -0,0 +1,103 @@
import { MessageSegment, AtSegmentData } from '../../../types';
export interface TextBlock {
id: string;
type: 'text';
text: string;
activeMentions: Map<number, { endIndex: number; userId: string; nickname: string }>;
}
export interface ImageBlock {
id: string;
type: 'image';
localUri: string;
remoteUrl?: string;
uploading: boolean;
width?: number;
height?: number;
}
export type EditorBlock = TextBlock | ImageBlock;
export function generateBlockId(): string {
return `blk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
export function createInitialTextBlock(): TextBlock {
return { id: generateBlockId(), type: 'text', text: '', activeMentions: new Map() };
}
export function remapMentionsBefore(
mentions: Map<number, { endIndex: number; userId: string; nickname: string }>,
cursorPos: number,
): Map<number, { endIndex: number; userId: string; nickname: string }> {
const result = new Map<number, { endIndex: number; userId: string; nickname: string }>();
for (const [start, mention] of mentions) {
if (mention.endIndex <= cursorPos) {
result.set(start, { ...mention });
}
}
return result;
}
export function remapMentionsAfter(
mentions: Map<number, { endIndex: number; userId: string; nickname: string }>,
cursorPos: number,
textLength: number,
): Map<number, { endIndex: number; userId: string; nickname: string }> {
const result = new Map<number, { endIndex: number; userId: string; nickname: string }>();
for (const [start, mention] of mentions) {
if (start >= cursorPos) {
result.set(start - cursorPos, {
...mention,
endIndex: mention.endIndex - cursorPos,
});
}
}
return result;
}
export function mergeMentions(
m1: Map<number, { endIndex: number; userId: string; nickname: string }>,
m1TextLength: number,
m2: Map<number, { endIndex: number; userId: string; nickname: string }>,
): Map<number, { endIndex: number; userId: string; nickname: string }> {
const result = new Map(m1);
for (const [start, mention] of m2) {
result.set(start + m1TextLength, {
...mention,
endIndex: mention.endIndex + m1TextLength,
});
}
return result;
}
export function blockTextToSegments(
text: string,
activeMentions: Map<number, { endIndex: number; userId: string; nickname: string }>,
): MessageSegment[] {
if (activeMentions.size === 0) {
return text.trim() ? [{ type: 'text', data: { text } }] : [];
}
const segments: MessageSegment[] = [];
const sorted = Array.from(activeMentions.entries()).sort((a, b) => a[0] - b[0]);
let lastEnd = 0;
for (const [startIdx, mention] of sorted) {
if (startIdx > lastEnd) {
segments.push({ type: 'text', data: { text: text.slice(lastEnd, startIdx) } });
}
segments.push({
type: 'at',
data: { user_id: mention.userId, nickname: mention.nickname } as AtSegmentData,
});
lastEnd = mention.endIndex;
}
if (lastEnd < text.length) {
segments.push({ type: 'text', data: { text: text.slice(lastEnd) } });
}
return segments;
}

View File

@@ -0,0 +1,97 @@
import { MessageSegment } from '../../../types';
import { EditorBlock, TextBlock, ImageBlock, blockTextToSegments, generateBlockId } from './blockEditorTypes';
export function segmentsToBlocks(segments: MessageSegment[]): EditorBlock[] {
if (!segments || segments.length === 0) return [];
const blocks: EditorBlock[] = [];
for (const seg of segments) {
if (seg.type === 'image' && seg.data?.url) {
const imageBlock: ImageBlock = {
id: generateBlockId(),
type: 'image',
localUri: seg.data.url,
remoteUrl: seg.data.url,
uploading: false,
width: seg.data.width,
height: seg.data.height,
};
blocks.push(imageBlock);
} else if (seg.type === 'text') {
const text = seg.data?.text || seg.data?.content || '';
if (text) {
const textBlock: TextBlock = {
id: generateBlockId(),
type: 'text',
text,
activeMentions: new Map(),
};
blocks.push(textBlock);
}
}
// Skip at/other segment types — mentions from long posts are not editable in block editor
}
// Ensure at least one text block
if (blocks.length === 0) {
blocks.push({ id: generateBlockId(), type: 'text', text: '', activeMentions: new Map() });
}
return blocks;
}
export function blocksToSegments(blocks: EditorBlock[]): MessageSegment[] {
const segments: MessageSegment[] = [];
for (const block of blocks) {
if (block.type === 'text') {
const textSegments = blockTextToSegments(block.text, block.activeMentions);
segments.push(...textSegments);
} else if (block.type === 'image') {
if (block.remoteUrl) {
segments.push({
type: 'image',
data: {
url: block.remoteUrl,
width: block.width,
height: block.height,
},
});
}
}
}
return mergeAdjacentTextSegments(segments);
}
function mergeAdjacentTextSegments(segments: MessageSegment[]): MessageSegment[] {
if (segments.length === 0) return segments;
const merged: MessageSegment[] = [];
for (const seg of segments) {
const last = merged[merged.length - 1];
if (
last &&
last.type === 'text' &&
seg.type === 'text'
) {
const lastText = last.data?.text || last.data?.content || '';
const curText = seg.data?.text || seg.data?.content || '';
last.data = { text: lastText + curText };
} else {
merged.push({ ...seg });
}
}
return merged;
}
export function blocksToContent(blocks: EditorBlock[]): string {
return blocks
.filter((b): b is typeof b & { type: 'text' } => b.type === 'text')
.map(b => b.text)
.join('\n')
.trim();
}

View File

@@ -0,0 +1,2 @@
export { default } from './BlockEditor';
export type { BlockEditorHandle } from './BlockEditor';

View File

@@ -0,0 +1,315 @@
import { useState, useCallback, useRef } from 'react';
import { TextInput, Alert } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { uploadService } from '@/services/upload';
import type { EditorBlock, TextBlock, ImageBlock } from './blockEditorTypes';
import {
generateBlockId,
createInitialTextBlock,
remapMentionsBefore,
remapMentionsAfter,
mergeMentions,
} from './blockEditorTypes';
import { blocksToSegments, blocksToContent } from './blocksToSegments';
interface MentionData {
endIndex: number;
userId: string;
nickname: string;
}
export function useBlockEditor(initialBlocks?: EditorBlock[]) {
const [blocks, setBlocks] = useState<EditorBlock[]>(initialBlocks && initialBlocks.length > 0 ? initialBlocks : [createInitialTextBlock()]);
const [activeBlockId, setActiveBlockId] = useState<string | null>(null);
const inputRefs = useRef<Map<string, TextInput>>(new Map());
const blockCursors = useRef<Map<string, { start: number; end: number }>>(new Map());
const updateTextBlock = useCallback(
(blockId: string, text: string, mentions: Map<number, MentionData>) => {
setBlocks(prev =>
prev.map(b =>
b.id === blockId && b.type === 'text'
? { ...b, text, activeMentions: mentions }
: b
)
);
},
[],
);
const focusBlock = useCallback((blockId: string) => {
requestAnimationFrame(() => {
const ref = inputRefs.current.get(blockId);
ref?.focus();
});
}, []);
const removeImageBlock = useCallback((blockId: string) => {
setBlocks(prev => {
const index = prev.findIndex(b => b.id === blockId);
if (index < 0) return prev;
const updated = prev.filter(b => b.id !== blockId);
// Merge adjacent text blocks if both neighbors are text
const before = updated[index - 1];
const after = updated[index]; // was index + 1 before removal
if (
before && after &&
before.type === 'text' && after.type === 'text'
) {
const mergedMentions = mergeMentions(
before.activeMentions,
before.text.length,
after.activeMentions,
);
const mergedBlock: TextBlock = {
...before,
text: before.text + after.text,
activeMentions: mergedMentions,
};
updated[index - 1] = mergedBlock;
updated.splice(index, 1);
setActiveBlockId(mergedBlock.id);
requestAnimationFrame(() => focusBlock(mergedBlock.id));
}
// Ensure at least one text block exists
if (updated.length === 0) {
const newBlock = createInitialTextBlock();
updated.push(newBlock);
setActiveBlockId(newBlock.id);
}
return updated;
});
}, [focusBlock]);
const uploadImageByLocalUri = useCallback(
async (asset: ImagePicker.ImagePickerAsset) => {
try {
const uploadResult = await uploadService.uploadImage({
uri: asset.uri,
type: asset.mimeType || 'image/jpeg',
});
if (uploadResult) {
setBlocks(prev =>
prev.map(b =>
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
? {
...b,
remoteUrl: uploadResult.url,
uploading: false,
width: uploadResult.width ?? b.width,
height: uploadResult.height ?? b.height,
}
: b
)
);
} else {
// Upload failed
setBlocks(prev =>
prev.map(b =>
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
? { ...b, uploading: false }
: b
)
);
Alert.alert('上传失败', '图片上传失败,请重试');
}
} catch {
setBlocks(prev =>
prev.map(b =>
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
? { ...b, uploading: false }
: b
)
);
}
},
[],
);
const insertImagesFromAssets = useCallback(
(assets: ImagePicker.ImagePickerAsset[]) => {
if (!assets.length) return;
setBlocks(prev => {
let updated = [...prev];
let currentActiveId = activeBlockId;
let insertAfterIndex = updated.findIndex(b => b.id === currentActiveId);
if (insertAfterIndex < 0) {
insertAfterIndex = updated.length - 1;
}
for (const asset of assets) {
const activeBlock = updated[insertAfterIndex];
const cursorPos = activeBlock && activeBlock.type === 'text'
? (blockCursors.current.get(activeBlock.id)?.start ?? activeBlock.text.length)
: 0;
// Create image block
const imageBlockId = generateBlockId();
const imageBlock: ImageBlock = {
id: imageBlockId,
type: 'image',
localUri: asset.uri,
uploading: true,
width: asset.width,
height: asset.height,
};
if (activeBlock && activeBlock.type === 'text') {
const textBefore = activeBlock.text.slice(0, cursorPos);
const textAfter = activeBlock.text.slice(cursorPos);
const mentionsBefore = remapMentionsBefore(activeBlock.activeMentions, cursorPos);
const mentionsAfter = remapMentionsAfter(activeBlock.activeMentions, cursorPos, activeBlock.text.length);
// Create new text block for text after cursor
const newTextBlockId = generateBlockId();
const newTextBlock: TextBlock = {
id: newTextBlockId,
type: 'text',
text: textAfter,
activeMentions: mentionsAfter,
};
// Update current block to text before cursor
updated[insertAfterIndex] = {
...activeBlock,
text: textBefore,
activeMentions: mentionsBefore,
};
// Insert image block and new text block
updated.splice(insertAfterIndex + 1, 0, imageBlock, newTextBlock);
// Update index for next image insertion
insertAfterIndex = insertAfterIndex + 2;
// Focus the new text block after the image
setActiveBlockId(newTextBlockId);
requestAnimationFrame(() => focusBlock(newTextBlockId));
} else {
// Insert after the current block (which is an image)
updated.splice(insertAfterIndex + 1, 0, imageBlock);
insertAfterIndex = insertAfterIndex + 1;
}
}
return updated;
});
// Start async uploads
for (const asset of assets) {
uploadImageByLocalUri(asset);
}
},
[activeBlockId, focusBlock, uploadImageByLocalUri],
);
const insertImage = useCallback(async () => {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
Alert.alert('权限不足', '需要访问相册权限来选择图片');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images',
allowsMultipleSelection: true,
selectionLimit: 0,
quality: 1,
});
if (!result.canceled && result.assets) {
insertImagesFromAssets(result.assets);
}
}, [insertImagesFromAssets]);
const insertCameraPhoto = useCallback(async () => {
const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
if (!permissionResult.granted) {
Alert.alert('权限不足', '需要访问相机权限来拍照');
return;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
quality: 0.8,
});
if (!result.canceled && result.assets && result.assets[0]) {
insertImagesFromAssets([result.assets[0]]);
}
}, [insertImagesFromAssets]);
const insertTextAtCursor = useCallback((text: string) => {
const activeId = activeBlockId;
if (!activeId) return;
setBlocks(prev =>
prev.map(b => {
if (b.id !== activeId || b.type !== 'text') return b;
const cursorPos = blockCursors.current.get(b.id)?.start ?? b.text.length;
const newText = b.text.slice(0, cursorPos) + text + b.text.slice(cursorPos);
// Shift mentions after cursor
const newMentions = new Map<number, MentionData>();
b.activeMentions.forEach((mention, start) => {
if (start >= cursorPos) {
newMentions.set(start + text.length, {
...mention,
endIndex: mention.endIndex + text.length,
});
} else {
newMentions.set(start, mention);
}
});
const newPosition = cursorPos + text.length;
blockCursors.current.set(b.id, { start: newPosition, end: newPosition });
return { ...b, text: newText, activeMentions: newMentions };
})
);
}, [activeBlockId]);
const getSegments = useCallback(() => blocksToSegments(blocks), [blocks]);
const getContent = useCallback(() => blocksToContent(blocks), [blocks]);
const hasUploadingImages = useCallback(() => blocks.some(b => b.type === 'image' && b.uploading), [blocks]);
const registerInputRef = useCallback((blockId: string, ref: TextInput | null) => {
if (ref) {
inputRefs.current.set(blockId, ref);
} else {
inputRefs.current.delete(blockId);
}
}, []);
const updateCursor = useCallback((blockId: string, selection: { start: number; end: number }) => {
blockCursors.current.set(blockId, selection);
}, []);
return {
blocks,
activeBlockId,
setActiveBlockId,
updateTextBlock,
removeImageBlock,
insertImage,
insertCameraPhoto,
insertTextAtCursor,
focusBlock,
getSegments,
getContent,
hasUploadingImages,
registerInputRef,
updateCursor,
};
}

View File

@@ -6,19 +6,19 @@
import React, { useMemo, useState } from 'react';
import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { formatChatTime } from '../../utils/formatTime';
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
import { Comment, CommentImage } from '../../types';
import Text from '../common/Text';
import Avatar from '../common/Avatar';
import { CompactImageGrid, ImageGridItem } from '../common';
import PostContentRenderer from './PostContentRenderer';
interface CommentItemProps {
comment: Comment;
onUserPress: () => void;
onReply: () => void;
onLike: () => void;
onLike: (comment: Comment) => void; // 点赞回调,传入评论对象
floorNumber?: number; // 楼层号
isAuthor?: boolean; // 是否是楼主
replyToUser?: string; // 回复给哪位用户
@@ -29,6 +29,7 @@ interface CommentItemProps {
onDelete?: (comment: Comment) => void; // 删除评论的回调
onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图
currentUserId?: string; // 当前用户ID用于判断子评论作者
onReport?: (comment: Comment) => void; // 举报评论的回调
}
function createCommentItemStyles(colors: AppColors) {
@@ -126,7 +127,7 @@ function createCommentItemStyles(colors: AppColors) {
actionButton: {
flexDirection: 'row',
alignItems: 'center',
marginRight: spacing.lg,
marginRight: spacing.sm,
paddingVertical: 4,
paddingRight: spacing.xs,
},
@@ -141,7 +142,6 @@ function createCommentItemStyles(colors: AppColors) {
paddingVertical: spacing.xs,
borderLeftWidth: 2,
borderLeftColor: colors.divider,
backgroundColor: colors.background.default,
},
subReplyItem: {
flexDirection: 'row',
@@ -152,8 +152,8 @@ function createCommentItemStyles(colors: AppColors) {
marginBottom: 0,
},
subReplyBody: {
fontSize: fontSizes.sm,
lineHeight: 20,
fontSize: fontSizes.md,
lineHeight: 22,
marginTop: 2,
},
subReplyContent: {
@@ -175,10 +175,10 @@ function createCommentItemStyles(colors: AppColors) {
paddingVertical: spacing.xs,
},
replyToText: {
fontSize: fontSizes.xs,
fontSize: fontSizes.sm,
},
replyToName: {
fontSize: fontSizes.xs,
fontSize: fontSizes.sm,
fontWeight: '500',
},
subReplyActions: {
@@ -214,21 +214,11 @@ const CommentItem: React.FC<CommentItemProps> = ({
onDelete,
onImagePress,
currentUserId,
onReport,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
const [isDeleting, setIsDeleting] = useState(false);
// 格式化时间
const formatTime = (dateString: string): string => {
try {
return formatDistanceToNow(new Date(dateString), {
addSuffix: true,
locale: zhCN,
});
} catch {
return '';
}
};
// 格式化数字
const formatNumber = (num: number): string => {
@@ -477,7 +467,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
{targetNickname && (
<>
<Text variant="caption" color={colors.text.hint} style={styles.replyToText}>
{' '}
{' '}{' '}
</Text>
<Text variant="caption" color={colors.primary.main} style={styles.replyToName}>
{targetNickname}
@@ -487,7 +477,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
</View>
{/* 显示回复内容(如果有文字) */}
{reply.content ? (
<Text variant="body" color={colors.text.primary} style={styles.subReplyBody}>
<Text variant="body" color={colors.text.primary} selectable style={styles.subReplyBody}>
{reply.content}
</Text>
) : null}
@@ -495,11 +485,30 @@ const CommentItem: React.FC<CommentItemProps> = ({
{renderSubReplyImages(reply)}
{/* 子评论操作按钮 */}
<View style={styles.subReplyActions}>
{/* 点赞按钮 */}
<TouchableOpacity
style={styles.subActionButton}
onPress={() => onLike?.(reply)}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name={reply.is_liked ? 'heart' : 'heart-outline'}
size={14}
color={reply.is_liked ? colors.error.main : colors.text.hint}
/>
<Text
variant="caption"
color={reply.is_liked ? colors.error.main : colors.text.hint}
style={styles.subActionText}
>
{reply.likes_count > 0 ? formatNumber(reply.likes_count) : '赞'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.subActionButton}
onPress={() => onReplyPress?.(reply)}
>
<MaterialCommunityIcons name="reply" size={12} color={colors.text.hint} />
<MaterialCommunityIcons name="reply" size={14} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
</Text>
@@ -513,7 +522,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
>
<MaterialCommunityIcons
name={isDeleting ? 'loading' : 'delete-outline'}
size={12}
size={14}
color={colors.text.hint}
/>
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
@@ -521,6 +530,17 @@ const CommentItem: React.FC<CommentItemProps> = ({
</Text>
</TouchableOpacity>
)}
{!isSubReplyAuthor && onReport && (
<TouchableOpacity
style={styles.subActionButton}
onPress={() => onReport(reply)}
>
<MaterialCommunityIcons name="flag-outline" size={14} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
</Text>
</TouchableOpacity>
)}
</View>
</View>
</TouchableOpacity>
@@ -564,7 +584,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
{renderBadges()}
<Text style={styles.metaDot}>·</Text>
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
{formatTime(comment.created_at || '')}
{formatChatTime(comment.created_at || '')}
</Text>
</View>
{renderFloorNumber()}
@@ -581,9 +601,17 @@ const CommentItem: React.FC<CommentItemProps> = ({
{/* 评论文本 - 非气泡样式 */}
<View style={styles.commentContent}>
<Text variant="body" color={colors.text.primary} style={styles.text}>
{comment.content}
</Text>
{comment.segments && comment.segments.length > 0 ? (
<PostContentRenderer
content={comment.content}
segments={comment.segments}
textStyle={[styles.text, { color: colors.text.primary }]}
/>
) : (
<Text variant="body" color={colors.text.primary} selectable style={styles.text}>
{comment.content}
</Text>
)}
</View>
{/* 评论图片 */}
@@ -592,7 +620,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
{/* 操作按钮 - 更紧凑 */}
<View style={styles.actions}>
{/* 点赞 */}
<TouchableOpacity style={styles.actionButton} onPress={onLike}>
<TouchableOpacity style={styles.actionButton} onPress={() => onLike(comment)}>
<MaterialCommunityIcons
name={comment.is_liked ? 'heart' : 'heart-outline'}
size={14}
@@ -615,6 +643,19 @@ const CommentItem: React.FC<CommentItemProps> = ({
</Text>
</TouchableOpacity>
{/* 举报按钮 - 只对非评论作者显示 */}
{!isCommentAuthor && onReport && (
<TouchableOpacity
style={styles.actionButton}
onPress={() => onReport(comment)}
>
<MaterialCommunityIcons name="flag-outline" size={14} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
</Text>
</TouchableOpacity>
)}
{/* 删除按钮 - 只对评论作者显示 */}
{isCommentAuthor && (
<TouchableOpacity

View File

@@ -16,13 +16,14 @@ import React, { useMemo, useState, memo, useEffect } from 'react';
import { View, TouchableOpacity, StyleSheet, Alert, TextStyle } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { PostCardProps, PostCardAction } from './types';
import { ImageGridItem, SmartImage } from '../../common';
import { ImageGridItem, SmartImage, ImageGrid } from '../../common';
import Text from '../../common/Text';
import HighlightText from '../../common/HighlightText';
import Avatar from '../../common/Avatar';
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme';
import { getPreviewImageUrl } from '../../../utils/imageHelper';
import PostImages from './components/PostImages';
import { useResponsive } from '../../../hooks/useResponsive';
import PostContentRenderer from '../PostContentRenderer';
import { useResponsive } from '../../../hooks';
import { formatPostCreatedAtString } from '../../../core/entities/Post';
/** 列表相对时间:独立定时刷新,避免外层 PostCard.memo 挡住「分钟前」更新 */
@@ -53,6 +54,13 @@ function imagesSignature(
return images.map((i) => i.id).join('\u001f');
}
function segmentSignature(
segments: PostCardProps['post']['segments'] | undefined
): string {
if (!segments?.length) return '';
return segments.map(s => `${s.type}:${s.type === 'image' ? (s.data as any)?.url || '' : ''}`).join('\u001f');
}
function authorSignature(author: PostCardProps['post']['author']): string {
if (!author) return '';
return [author.id, author.nickname ?? '', author.avatar ?? ''].join('\u001f');
@@ -84,6 +92,7 @@ function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolea
if (prev.isAuthor !== next.isAuthor) return false;
if (prev.style !== next.style) return false;
if (featuresComparable(prev.features) !== featuresComparable(next.features)) return false;
if (prev.highlightKeyword !== next.highlightKeyword) return false;
const a = prev.post;
const b = next.post;
@@ -107,6 +116,7 @@ function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolea
if (!!a.is_favorited !== !!b.is_favorited) return false;
if (authorSignature(a.author) !== authorSignature(b.author)) return false;
if (imagesSignature(a.images) !== imagesSignature(b.images)) return false;
if (segmentSignature(a.segments) !== segmentSignature(b.segments)) return false;
if (topCommentSignature(a.top_comment) !== topCommentSignature(b.top_comment)) return false;
if (channelSignature(a.channel) !== channelSignature(b.channel)) return false;
@@ -206,6 +216,9 @@ function createPostCardStyles(colors: AppColors) {
marginBottom: spacing.xs,
lineHeight: fontSizes.md * 1.45,
},
contentRenderer: {
marginBottom: spacing.xs,
},
expandBtn: {
marginBottom: spacing.sm,
},
@@ -280,10 +293,10 @@ function createPostCardStyles(colors: AppColors) {
marginLeft: 4,
},
activeActionText: {
color: colors.error.main,
color: colors.primary.main,
},
activeActionTextMerged: {
color: colors.error.main,
color: colors.primary.main,
fontSize: fontSizes.sm,
marginLeft: 4,
},
@@ -374,6 +387,10 @@ function createPostCardStyles(colors: AppColors) {
fontSize: fontSizes.sm,
marginLeft: 4,
},
highlight: {
backgroundColor: `${colors.warning.main}40`,
color: colors.text.primary,
},
});
}
@@ -391,6 +408,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
isAuthor = false,
index,
style,
highlightKeyword,
} = normalizedProps;
const colors = useAppColors();
@@ -415,6 +433,22 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
const rawContent = post.content ?? '';
const { isDesktop, isTablet, isWideScreen, isLandscape } = useResponsive();
const displayContent = useMemo(() => {
if (!post.segments?.length) return rawContent;
const segs = post.segments;
let result = '';
for (const s of segs) {
if (s.type === 'image') {
result += '[图片]';
} else if (s.type === 'text') {
result += (s.data as any)?.text || (s.data as any)?.content || '';
} else if (s.type === 'at') {
result += `@${(s.data as any)?.nickname || '某人'}`;
}
}
return result || rawContent;
}, [post.segments, rawContent]);
const handleCardPress = () => emit({ type: 'press' });
const handleUserPress = () => emit({ type: 'userPress' });
const handleLike = () => emit({ type: post.is_liked ? 'unlike' : 'like' });
@@ -444,17 +478,29 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
);
};
const images: ImageGridItem[] = Array.isArray(post.images)
? post.images.map((img) => ({
id: img.id,
url: img.url,
thumbnail_url: img.thumbnail_url,
preview_url: img.preview_url,
preview_url_large: img.preview_url_large,
width: img.width,
height: img.height,
}))
: [];
const segmentImages: ImageGridItem[] = (post.segments || [])
.filter((s: any) => s.type === 'image' && s.data?.url)
.map((s: any, i: number) => ({
id: `seg-${i}`,
url: s.data.url,
width: s.data.width,
height: s.data.height,
}));
const images: ImageGridItem[] = [
...Array.isArray(post.images)
? post.images.map((img) => ({
id: img.id,
url: img.url,
thumbnail_url: img.thumbnail_url,
preview_url: img.preview_url,
preview_url_large: img.preview_url_large,
width: img.width,
height: img.height,
}))
: [],
...segmentImages,
];
const formatNumber = (num: number): string => {
if (num >= 10000) return `${(num / 10000).toFixed(1)}w`;
@@ -463,6 +509,13 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
};
const getResponsiveMaxLength = () => {
// 搜索高亮模式下展示更多内容
if (highlightKeyword) {
if (isWideScreen) return 500;
if (isDesktop) return 400;
if (isTablet) return 350;
return 220;
}
if (isWideScreen) return 300;
if (isDesktop) return 250;
if (isTablet) return 200;
@@ -470,22 +523,60 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
};
const contentNumberOfLines = useMemo(() => {
if (highlightKeyword) {
if (isWideScreen) return 12;
if (isDesktop) return 10;
if (isTablet) return 8;
return 5;
}
if (isWideScreen) return 8;
if (isDesktop) return 6;
if (isTablet) return 5;
return 3;
}, [isWideScreen, isDesktop, isTablet]);
}, [isWideScreen, isDesktop, isTablet, highlightKeyword]);
const getSearchExcerpt = (value: string, keyword: string): string => {
const maxLength = getResponsiveMaxLength();
if (value.length <= maxLength) return value;
const lowerValue = value.toLowerCase();
const lowerKeyword = keyword.toLowerCase();
const index = lowerValue.indexOf(lowerKeyword);
if (index === -1) {
return `${value.substring(0, maxLength)}...`;
}
const context = Math.floor((maxLength - keyword.length) / 2);
let start = Math.max(0, index - context);
let end = Math.min(value.length, index + keyword.length + context);
if (start > 0) {
while (start > 0 && value[start] !== ' ' && value[start] !== '\n') start--;
if (value[start] === ' ' || value[start] === '\n') start++;
}
if (end < value.length) {
while (end < value.length && value[end] !== ' ' && value[end] !== '\n') end++;
}
const prefix = start > 0 ? '...' : '';
const suffix = end < value.length ? '...' : '';
return `${prefix}${value.substring(start, end)}${suffix}`;
};
const getTruncatedContent = (value: string): string => {
const maxLength = getResponsiveMaxLength();
if (value.length <= maxLength || isExpanded) return value;
if (highlightKeyword) {
return getSearchExcerpt(value, highlightKeyword);
}
return `${value.substring(0, maxLength)}...`;
};
const shouldTruncate = !showGrid && !isCompact && rawContent.length > getResponsiveMaxLength();
const shouldTruncate = !showGrid && !isCompact && displayContent.length > getResponsiveMaxLength();
const content = useMemo(
() => (showGrid || isCompact ? rawContent : getTruncatedContent(rawContent)),
[rawContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
() => (showGrid || isCompact ? displayContent : getTruncatedContent(displayContent)),
[displayContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
);
const renderImages = () => {
@@ -507,10 +598,24 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
}
return (
<PostImages
images={post.images || []}
displayMode="list"
<ImageGrid
images={images.map(img => ({
id: img.id,
url: img.url || '',
thumbnail_url: img.thumbnail_url,
preview_url: img.preview_url,
preview_url_large: img.preview_url_large,
width: img.width,
height: img.height,
}))}
maxDisplayCount={isWideScreen ? 12 : 9}
mode="auto"
gap={isDesktop ? 4 : 2}
borderRadius={isDesktop ? borderRadius.xl : borderRadius.md}
showMoreOverlay={true}
onImagePress={handleImagePress}
displayMode="list"
usePreview={true}
/>
);
};
@@ -556,7 +661,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
) : (
<View style={styles.gridNoImagePreview}>
<Text style={styles.gridNoImageText} numberOfLines={6}>
{contentPreview}
{highlightKeyword ? <HighlightText text={contentPreview} keyword={highlightKeyword} style={styles.highlight} /> : contentPreview}
</Text>
</View>
)}
@@ -570,7 +675,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
{!!post.title && (
<Text style={styles.gridTitleMain} numberOfLines={hasImage ? 2 : 3}>
{post.title}
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} style={styles.highlight} /> : post.title}
</Text>
)}
@@ -589,8 +694,14 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
<Text style={styles.gridUsername} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
</TouchableOpacity>
<View style={styles.gridLikeArea}>
<MaterialCommunityIcons name="heart-outline" size={14} color={colors.text.secondary} />
<Text style={styles.gridLikeCount}>{formatNumber(post.likes_count || 0)}</Text>
<MaterialCommunityIcons
name={post.is_liked ? 'thumb-up' : 'thumb-up-outline'}
size={14}
color={post.is_liked ? colors.primary.main : colors.text.secondary}
/>
<Text style={[styles.gridLikeCount, post.is_liked ? { color: colors.primary.main } : {}]}>
{formatNumber(post.likes_count || 0)}
</Text>
</View>
</View>
</TouchableOpacity>
@@ -636,7 +747,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
{!!post.title && (
<Text style={styles.title} numberOfLines={isCompact ? 2 : undefined}>
{post.title}
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} style={styles.highlight} /> : post.title}
</Text>
)}
@@ -646,7 +757,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
style={styles.content}
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
>
{content}
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} style={styles.highlight} /> : content}
</Text>
{shouldTruncate && (
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
@@ -656,6 +767,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
</>
)}
{/* 仅当 segments 中不含 image 时才渲染独立的图片区域 */}
{renderImages()}
{renderTopComment()}
@@ -677,9 +789,9 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
<View style={styles.actionButtons}>
<TouchableOpacity style={styles.actionBtn} onPress={handleLike}>
<MaterialCommunityIcons
name={post.is_liked ? 'heart' : 'heart-outline'}
name={post.is_liked ? 'thumb-up' : 'thumb-up-outline'}
size={18}
color={post.is_liked ? colors.error.main : colors.text.secondary}
color={post.is_liked ? colors.primary.main : colors.text.secondary}
/>
<Text style={post.is_liked ? styles.activeActionTextMerged : styles.actionText}>
{post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'}
@@ -694,7 +806,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
</TouchableOpacity>
<TouchableOpacity style={styles.actionBtn} onPress={handleBookmark}>
<MaterialCommunityIcons
name={post.is_favorited ? 'bookmark' : 'bookmark-outline'}
name={post.is_favorited ? 'star' : 'star-outline'}
size={18}
color={post.is_favorited ? colors.warning.main : colors.text.secondary}
/>

View File

@@ -6,7 +6,7 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { spacing, borderRadius } from '../../../../theme';
import { useResponsive } from '../../../../hooks/useResponsive';
import { useResponsive } from '../../../../hooks';
import { ImageGrid, ImageGridItem } from '../../../common';
import { PostImagesProps } from '../types';

View File

@@ -22,7 +22,8 @@ export type PostCardActionType =
| 'unbookmark' // 取消收藏
| 'share' // 分享
| 'imagePress' // 点击图片
| 'delete'; // 删除
| 'delete' // 删除
| 'report'; // 举报
/**
* Action payload 类型
@@ -117,6 +118,9 @@ export interface PostCardProps {
/** 索引(用于虚拟化列表优化) */
index?: number;
/** 搜索高亮关键词 */
highlightKeyword?: string;
/** 自定义样式 */
style?: StyleProp<ViewStyle>;
}
@@ -163,7 +167,7 @@ export interface PostContentProps {
* PostImages 子组件 Props
*/
export interface PostImagesProps {
images: PostImageDTO[];
images: (PostImageDTO | { id?: string; url: string; thumbnail_url?: string; preview_url?: string; preview_url_large?: string; width?: number; height?: number })[];
displayMode: 'list' | 'grid';
onImagePress: (images: ImageGridItem[], index: number) => void;
}

View File

@@ -0,0 +1,299 @@
/**
* PostContentRenderer - 帖子内容渲染器
* 根据 segments 渲染富文本内容(@提及、投票、内嵌图片等)
* 降级:如果 segments 为空但 content 非空,直接显示纯文本
*/
import React, { useMemo } from 'react';
import { View, TouchableOpacity, StyleSheet, TextStyle, StyleProp, Dimensions } from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import Text from '../common/Text';
import HighlightText from '../common/HighlightText';
import { useAppColors } from '../../theme';
import {
MessageSegment,
AtSegmentData,
VoteSegmentData,
PostRefSegmentData,
ImageSegmentData,
partitionSegments,
} from '../../types';
import PostRefCard from './PostRefCard';
interface PostContentRendererProps {
content?: string;
segments?: MessageSegment[];
numberOfLines?: number;
onMentionPress?: (userId: string) => void;
onPostRefPress?: (postId: string) => void;
onImagePress?: (url: string) => void;
style?: any;
textStyle?: any;
highlightKeyword?: string;
highlightStyle?: StyleProp<TextStyle>;
}
const SCREEN_WIDTH = Dimensions.get('window').width;
const INLINE_IMAGE_MAX_WIDTH = SCREEN_WIDTH - 32;
function hasInlineImages(segments?: MessageSegment[]): boolean {
if (!segments) return false;
return segments.some(s => s.type === 'image');
}
const PostContentRenderer: React.FC<PostContentRendererProps> = ({
content,
segments,
numberOfLines,
onMentionPress,
onPostRefPress,
onImagePress,
style,
textStyle,
highlightKeyword,
highlightStyle,
}) => {
const colors = useAppColors();
const hasImages = hasInlineImages(segments);
if (!segments || segments.length === 0) {
return (
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
{highlightKeyword && content ? (
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
) : (
content || ''
)}
</Text>
);
}
const hasComplexContent = hasImages ||
segments.some(s => s.type === 'vote' || s.type === 'post_ref');
if (!hasComplexContent || (numberOfLines && !hasImages)) {
const elements: React.ReactNode[] = [];
let keyIndex = 0;
for (const segment of segments) {
const key = `seg_${keyIndex++}`;
switch (segment.type) {
case 'text': {
const text = segment.data?.text || segment.data?.content || '';
if (text) {
elements.push(
<Text key={key} selectable style={textStyle}>
{highlightKeyword ? (
<HighlightText text={text} keyword={highlightKeyword} style={highlightStyle} />
) : (
text
)}
</Text>
);
}
break;
}
case 'at': {
const atData = segment.data as AtSegmentData;
const userId = atData?.user_id;
const isAtAll = userId === 'all';
const displayName = atData?.nickname || (isAtAll ? '所有人' : '某人');
elements.push(
<Text
key={key}
selectable
style={[
textStyle,
{ color: colors.primary.main, fontWeight: '500' },
]}
>
@{displayName}{' '}
</Text>
);
break;
}
case 'image':
case 'link':
case 'face':
default:
break;
}
}
if (elements.length === 0) {
return (
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
{highlightKeyword && content ? (
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
) : (
content || ''
)}
</Text>
);
}
if (numberOfLines) {
return (
<View style={style}>
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
{elements}
</Text>
</View>
);
}
return <View style={[styles.container, style]}>{elements}</View>;
}
const chunks = partitionSegments(segments);
return (
<View style={[styles.chunkContainer, style]}>
{chunks.map((chunk, i) => {
if (chunk.kind === 'inline') {
return (
<Text key={`inl-${i}`} selectable style={textStyle}>
{chunk.parts.map((segment, j) => {
const segKey = `inl-${i}-${j}`;
switch (segment.type) {
case 'text': {
const text = segment.data?.text || segment.data?.content || '';
return highlightKeyword ? (
<HighlightText key={segKey} text={text} keyword={highlightKeyword} style={highlightStyle} />
) : (
text
);
}
case 'at': {
const atData = segment.data as AtSegmentData;
const userId = atData?.user_id;
const displayName = atData?.nickname || (userId === 'all' ? '所有人' : '某人');
return (
<Text
key={segKey}
style={[textStyle, { color: colors.primary.main, fontWeight: '500' }]}
onPress={() => onMentionPress?.(userId)}
>
@{displayName}{' '}
</Text>
);
}
default:
return null;
}
})}
</Text>
);
}
if (chunk.kind === 'images') {
return (
<View key={`imgs-${i}`} style={styles.inlineImagesContainer}>
{chunk.parts.map((segment, j) => {
const imgData = segment.data as ImageSegmentData;
const url = imgData?.url || '';
if (!url) return null;
const aspectRatio = imgData.width && imgData.height
? imgData.width / imgData.height
: 4 / 3;
const displayWidth = INLINE_IMAGE_MAX_WIDTH;
const displayHeight = displayWidth / aspectRatio;
return (
<TouchableOpacity
key={`img-${i}-${j}`}
activeOpacity={0.9}
onPress={() => onImagePress?.(url)}
style={j < chunk.parts.length - 1 ? styles.imageSpacing : undefined}
>
<ExpoImage
source={{ uri: url }}
style={{
width: displayWidth,
height: displayHeight,
borderRadius: 8,
}}
contentFit="cover"
cachePolicy="memory-disk"
transition={200}
/>
</TouchableOpacity>
);
})}
</View>
);
}
const { segment } = chunk;
switch (segment.type) {
case 'vote': {
const voteData = segment.data as VoteSegmentData;
if (!voteData?.options?.length) return null;
return (
<View key={`blk-${i}`} style={[styles.voteBlock, { borderColor: colors.divider }]}>
<Text style={[styles.voteTitle, { color: colors.text.secondary }]}>
{voteData.options.length}
</Text>
{voteData.options.map((opt, k) => (
<Text key={`vote_opt_${k}`} selectable style={[styles.voteOption, { color: colors.text.primary }]}>
{k + 1}. {opt.content}
</Text>
))}
</View>
);
}
case 'post_ref': {
const refData = segment.data as PostRefSegmentData;
return (
<PostRefCard
key={`blk-${i}`}
data={refData}
compact={false}
onPress={onPostRefPress}
/>
);
}
default:
return null;
}
})}
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'flex-start',
},
chunkContainer: {
flexDirection: 'column',
},
inlineImagesContainer: {
marginVertical: 8,
},
imageSpacing: {
marginBottom: 6,
},
voteBlock: {
width: '100%',
borderWidth: 1,
borderRadius: 8,
padding: 10,
marginTop: 4,
marginBottom: 4,
},
voteTitle: {
fontSize: 13,
marginBottom: 6,
},
voteOption: {
fontSize: 14,
paddingVertical: 2,
},
});
export default PostContentRenderer;

View File

@@ -0,0 +1,419 @@
/**
* PostMentionInput - 帖子内容输入框(支持 @提及)
* 复用关注列表加载逻辑
* 检测输入中的 @ 字符,弹出关注者列表供选择
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
View,
TextInput,
ScrollView,
TouchableOpacity,
StyleSheet,
Platform,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import Text from '../common/Text';
import Avatar from '../common/Avatar';
import { useAppColors, spacing, fontSizes, borderRadius } from '../../theme';
import { useAuthStore } from '../../stores';
import { authService } from '../../services/auth';
import { postService } from '../../services/post/postService';
import { MessageSegment, AtSegmentData } from '../../types';
interface MentionUser {
id: string;
nickname: string;
avatar: string | null;
}
interface SuggestPost {
id: string;
title: string;
channel?: string;
}
type SuggestionMode = 'none' | 'mention' | 'postref';
interface PostMentionInputProps {
value: string;
onChangeText: (text: string) => void;
onSegmentsChange: (segments: MessageSegment[]) => void;
placeholder?: string;
maxLength?: number;
style?: any;
minHeight?: number;
autoExpand?: boolean;
onPostRefPasted?: (postId: string, title: string) => void;
onSubmitEditing?: () => void;
returnKeyType?: 'default' | 'send' | 'done';
}
const PostMentionInput: React.FC<PostMentionInputProps> = ({
value,
onChangeText,
onSegmentsChange,
placeholder = '说点什么...',
maxLength = 2000,
style,
minHeight = 100,
autoExpand = false,
onPostRefPasted,
onSubmitEditing,
returnKeyType = 'default',
}) => {
const colors = useAppColors();
const currentUser = useAuthStore(s => s.currentUser);
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
const [suggestPosts, setSuggestPosts] = useState<SuggestPost[]>([]);
const [suggestionMode, setSuggestionMode] = useState<SuggestionMode>('none');
const [mentionQuery, setMentionQuery] = useState('');
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
const [loaded, setLoaded] = useState(false);
const [postSearchTimer, setPostSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
const inputRef = useRef<TextInput>(null);
useEffect(() => {
if (!loaded && currentUser?.id) {
loadFollowing();
setLoaded(true);
}
}, [currentUser?.id, loaded]);
const loadFollowing = async () => {
if (!currentUser?.id) return;
try {
const list = await authService.getFollowingList(currentUser.id, 1, 200);
setFollowingUsers(
list.map(u => ({
id: u.id,
nickname: u.nickname || u.username || '',
avatar: u.avatar || null,
}))
);
} catch (_) {
// ignore
}
};
const filteredUsers = followingUsers.filter(u =>
u.nickname.toLowerCase().includes(mentionQuery.toLowerCase())
);
const activeMentions = useRef(new Map<number, { endIndex: number; userId: string; nickname: string }>());
const segmentsRef = useRef<MessageSegment[]>([]);
const rebuildSegments = useCallback(
(text: string) => {
const mentions = activeMentions.current;
if (mentions.size === 0) {
onSegmentsChange(text.trim() ? [{ type: 'text', data: { text } }] : []);
return;
}
const segments: MessageSegment[] = [];
const sorted = Array.from(mentions.entries()).sort((a, b) => a[0] - b[0]);
let lastEnd = 0;
for (const [startIdx, mention] of sorted) {
if (startIdx > lastEnd) {
segments.push({ type: 'text', data: { text: text.slice(lastEnd, startIdx) } });
}
segments.push({
type: 'at',
data: { user_id: mention.userId, nickname: mention.nickname } as AtSegmentData,
});
lastEnd = mention.endIndex;
}
if (lastEnd < text.length) {
segments.push({ type: 'text', data: { text: text.slice(lastEnd) } });
}
onSegmentsChange(segments);
},
[onSegmentsChange]
);
const handleChangeText = useCallback(
(text: string) => {
onChangeText(text);
const cursorPos = text.length;
// Check for # trigger (post ref search)
let hashStart = -1;
for (let i = cursorPos - 1; i >= 0; i--) {
if (text[i] === '#') {
hashStart = i;
break;
}
if (text[i] === ' ' || text[i] === '\n') {
break;
}
}
if (hashStart >= 0) {
const query = text.slice(hashStart + 1, cursorPos);
if (query.length >= 1 && !query.includes(' ') && !query.includes('\n')) {
if (postSearchTimer) {
clearTimeout(postSearchTimer);
}
const timer = setTimeout(async () => {
try {
const results = await postService.suggestPosts(query, 8);
setSuggestPosts(results);
setSuggestionMode('postref');
setMentionStartIndex(hashStart);
} catch {
// ignore
}
}, 300);
setPostSearchTimer(timer);
return;
}
}
// Check for @ trigger (mention)
let atStart = -1;
for (let i = cursorPos - 1; i >= 0; i--) {
if (text[i] === '@') {
atStart = i;
break;
}
if (text[i] === ' ' || text[i] === '\n') {
break;
}
}
if (atStart >= 0) {
const query = text.slice(atStart + 1, cursorPos);
if (!query.includes(' ') && !query.includes('\n')) {
setMentionQuery(query);
setMentionStartIndex(atStart);
setSuggestionMode('mention');
return;
}
}
setSuggestionMode('none');
rebuildSegments(text);
},
[onChangeText, rebuildSegments, postSearchTimer],
);
const handleSelectMention = useCallback(
(mentionUser: MentionUser) => {
if (mentionStartIndex < 0) return;
const before = value.slice(0, mentionStartIndex);
const mentionText = `@${mentionUser.nickname} `;
const newText = before + mentionText;
activeMentions.current.set(mentionStartIndex, {
endIndex: before.length + mentionText.length,
userId: mentionUser.id,
nickname: mentionUser.nickname,
});
onChangeText(newText);
setSuggestionMode('none');
setMentionQuery('');
setMentionStartIndex(-1);
rebuildSegments(newText);
inputRef.current?.focus();
},
[value, mentionStartIndex, onChangeText, rebuildSegments],
);
const handleSelectPost = useCallback(
(post: SuggestPost) => {
if (mentionStartIndex < 0) return;
const before = value.slice(0, mentionStartIndex);
const refText = `#${post.title} `;
const newText = before + refText;
onChangeText(newText);
setSuggestionMode('none');
setSuggestPosts([]);
setMentionStartIndex(-1);
if (onPostRefPasted) {
onPostRefPasted(post.id, post.title);
}
rebuildSegments(newText);
inputRef.current?.focus();
},
[value, mentionStartIndex, onChangeText, rebuildSegments, onPostRefPasted],
);
const renderMentionItem = ({ item }: { item: MentionUser }) => (
<TouchableOpacity
style={[styles.mentionItem, { borderBottomColor: colors.divider }]}
onPress={() => handleSelectMention(item)}
activeOpacity={0.7}
>
<Avatar source={item.avatar} size={36} name={item.nickname} />
<View style={styles.mentionInfo}>
<Text style={[styles.mentionName, { color: colors.text.primary }]}>
{item.nickname}
</Text>
<Text style={[styles.mentionHint, { color: colors.text.hint }]}>
</Text>
</View>
<MaterialCommunityIcons name="at" size={18} color={colors.primary.main} />
</TouchableOpacity>
);
return (
<View style={[styles.container, style]}>
<TextInput
ref={inputRef}
value={value}
onChangeText={handleChangeText}
placeholder={placeholder}
placeholderTextColor={colors.text.hint}
maxLength={maxLength}
multiline
textAlignVertical="top"
scrollEnabled={!autoExpand}
returnKeyType={returnKeyType}
onSubmitEditing={onSubmitEditing}
blurOnSubmit={false}
style={[
styles.input,
autoExpand && styles.inputAutoExpand,
{
color: colors.text.primary,
backgroundColor: autoExpand ? 'transparent' : colors.background.default,
minHeight,
},
]}
/>
{suggestionMode === 'mention' && filteredUsers.length > 0 && (
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
<ScrollView
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
style={styles.mentionList}
>
{filteredUsers.map(item => (
<React.Fragment key={item.id}>
{renderMentionItem({ item })}
</React.Fragment>
))}
</ScrollView>
</View>
)}
{suggestionMode === 'mention' && filteredUsers.length === 0 && (
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
<Text style={[styles.emptyText, { color: colors.text.hint }]}>
</Text>
</View>
)}
{suggestionMode === 'postref' && suggestPosts.length > 0 && (
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
<ScrollView
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
style={styles.mentionList}
>
{suggestPosts.map(item => (
<TouchableOpacity
key={item.id}
style={[styles.mentionItem, { borderBottomColor: colors.divider }]}
onPress={() => handleSelectPost(item)}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="file-document-outline" size={18} color={colors.primary.main} />
<View style={styles.mentionInfo}>
<Text style={[styles.mentionName, { color: colors.text.primary }]} numberOfLines={1}>
{item.title}
</Text>
<Text style={[styles.mentionHint, { color: colors.text.hint }]}>
</Text>
</View>
</TouchableOpacity>
))}
</ScrollView>
</View>
)}
{suggestionMode === 'postref' && suggestPosts.length === 0 && (
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
<Text style={[styles.emptyText, { color: colors.text.hint }]}>
</Text>
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flexGrow: 1,
},
input: {
fontSize: fontSizes.md,
padding: spacing.sm,
borderRadius: borderRadius.md,
minHeight: 100,
textAlignVertical: 'top' as const,
},
inputAutoExpand: {
borderRadius: 0,
paddingHorizontal: 0,
paddingTop: spacing.sm,
paddingBottom: spacing.sm,
},
mentionPanel: {
borderWidth: 1,
borderRadius: borderRadius.md,
maxHeight: 200,
marginTop: spacing.xs,
},
mentionList: {
maxHeight: 200,
},
mentionItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
},
mentionInfo: {
flex: 1,
marginLeft: spacing.sm,
justifyContent: 'center',
},
mentionName: {
fontSize: fontSizes.md,
fontWeight: '500',
},
mentionHint: {
fontSize: fontSizes.xs,
marginTop: 2,
},
emptyText: {
fontSize: fontSizes.sm,
padding: spacing.md,
textAlign: 'center',
},
});
export default PostMentionInput;

View File

@@ -0,0 +1,124 @@
import React, { useCallback } from 'react';
import { View, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import Text from '../common/Text';
import { useAppColors, spacing, borderRadius } from '../../theme';
import type { PostRefSegmentData } from '../../types';
interface PostRefCardProps {
data: PostRefSegmentData;
compact?: boolean;
onPress?: (postId: string) => void;
}
const PostRefCard: React.FC<PostRefCardProps> = ({ data, compact = false, onPress }) => {
const colors = useAppColors();
const router = useRouter();
const handlePress = useCallback(() => {
if (onPress) {
onPress(data.post_id);
} else {
router.push(`/post/${data.post_id}` as any);
}
}, [data.post_id, onPress, router]);
const isDeleted = data.status === 'deleted';
const isHidden = data.status === 'hidden';
const isUnavailable = isDeleted || isHidden || !data.accessible;
const authorName = data.author?.nickname || '';
if (isUnavailable) {
return (
<View
style={[
styles.container,
{ backgroundColor: colors.background.default, borderColor: colors.divider },
compact && styles.containerCompact,
]}
>
<MaterialCommunityIcons name="link-off" size={16} color={colors.text.hint} />
<Text style={[styles.unavailableText, { color: colors.text.hint }]} numberOfLines={1}>
{isDeleted ? '该帖子已删除' : isHidden ? '该帖子不可见' : '请登录后查看'}
</Text>
</View>
);
}
return (
<TouchableOpacity
style={[
styles.container,
{ backgroundColor: colors.background.default, borderColor: colors.primary.light },
compact && styles.containerCompact,
]}
onPress={handlePress}
activeOpacity={0.7}
>
<View style={[styles.iconWrap, { backgroundColor: colors.primary.light }]}>
<MaterialCommunityIcons name="file-document-outline" size={14} color={colors.primary.main} />
</View>
<View style={styles.contentWrap}>
<Text
style={[styles.title, { color: colors.text.primary }]}
numberOfLines={compact ? 1 : 2}
>
{data.title || '帖子'}
</Text>
{authorName ? (
<Text style={[styles.author, { color: colors.text.secondary }]} numberOfLines={1}>
@{authorName}
</Text>
) : null}
</View>
<MaterialCommunityIcons name="chevron-right" size={16} color={colors.text.hint} />
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: borderRadius.md,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
marginTop: spacing.xs,
marginBottom: spacing.xs,
},
containerCompact: {
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
},
iconWrap: {
width: 24,
height: 24,
borderRadius: 4,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm,
},
contentWrap: {
flex: 1,
justifyContent: 'center',
},
title: {
fontSize: 13,
fontWeight: '500',
lineHeight: 18,
},
author: {
fontSize: 11,
lineHeight: 14,
marginTop: 1,
},
unavailableText: {
fontSize: 13,
marginLeft: spacing.sm,
},
});
export default PostRefCard;

View File

@@ -7,12 +7,13 @@ import {
Alert,
Modal,
Dimensions,
Platform,
} from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as hrefs from '../../navigation/hrefs';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import { blurActiveElement } from '../../infrastructure/platform';
const { width, height } = Dimensions.get('window');
@@ -133,22 +134,85 @@ function createQrScannerStyles(colors: AppColors) {
fontSize: fontSizes.md,
fontWeight: '600',
},
webNotSupportedContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
backgroundColor: '#000',
},
webNotSupportedText: {
marginTop: spacing.lg,
fontSize: fontSizes.md,
color: 'rgba(255,255,255,0.72)',
textAlign: 'center',
lineHeight: 24,
},
});
}
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
const router = useRouter();
// Web 端不支持扫码组件
const QRCodeScannerWeb: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
const themeColors = useAppColors();
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
const [permission, requestPermission] = useCameraPermissions();
const [scanned, setScanned] = useState(false);
useEffect(() => {
if (visible) {
setScanned(false);
if (!permission?.granted) {
requestPermission();
}
blurActiveElement();
}
}, [visible]);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<MaterialCommunityIcons name="close" size={24} color="#fff" />
</TouchableOpacity>
<Text style={styles.headerTitle}></Text>
<View style={styles.placeholder} />
</View>
<View style={styles.webNotSupportedContainer}>
<MaterialCommunityIcons name="web-off" size={64} color="rgba(255,255,255,0.5)" />
<Text style={styles.webNotSupportedText}>
使{'\n'}
App
</Text>
<TouchableOpacity style={styles.permissionButton} onPress={onClose}>
<Text style={styles.permissionButtonText}></Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};
// 原生端扫码组件
const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
const router = useRouter();
const themeColors = useAppColors();
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
// 动态导入 expo-camera避免 Web 端加载
const [cameraModule, setCameraModule] = useState<typeof import('expo-camera') | null>(null);
const [permissionStatus, setPermissionStatus] = useState<'undetermined' | 'granted' | 'denied'>('undetermined');
const [scanned, setScanned] = useState(false);
useEffect(() => {
if (visible && Platform.OS !== 'web') {
import('expo-camera').then((module) => {
setCameraModule(module);
module.Camera.getCameraPermissionsAsync().then((result) => {
setPermissionStatus(result.granted ? 'granted' : result.canAskAgain ? 'undetermined' : 'denied');
if (!result.granted && result.canAskAgain) {
module.Camera.requestCameraPermissionsAsync().then((reqResult) => {
setPermissionStatus(reqResult.granted ? 'granted' : 'denied');
});
}
});
}).catch((err) => {
console.warn('Failed to load expo-camera:', err);
});
}
}, [visible]);
@@ -157,7 +221,7 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
setScanned(true);
onClose();
if (data.startsWith('carrotbbs://qrcode/login')) {
if (data.startsWith('withyou://qrcode/login')) {
const sessionId = extractSessionId(data);
if (sessionId) {
router.push(hrefs.hrefQrLoginConfirm(sessionId));
@@ -178,7 +242,7 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
}
};
if (!permission?.granted) {
if (!cameraModule || permissionStatus !== 'granted') {
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={styles.container}>
@@ -192,7 +256,16 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
<View style={styles.permissionContainer}>
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
<Text style={styles.permissionText}></Text>
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
<TouchableOpacity
style={styles.permissionButton}
onPress={() => {
if (cameraModule) {
cameraModule.Camera.requestCameraPermissionsAsync().then((result) => {
setPermissionStatus(result.granted ? 'granted' : 'denied');
});
}
}}
>
<Text style={styles.permissionButtonText}></Text>
</TouchableOpacity>
</View>
@@ -201,6 +274,8 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
);
}
const { CameraView } = cameraModule;
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={styles.container}>
@@ -243,4 +318,9 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
);
};
// 根据平台导出不同组件
export const QRCodeScanner: React.FC<QRCodeScannerProps> = Platform.OS === 'web'
? QRCodeScannerWeb
: QRCodeScannerNative;
export default QRCodeScanner;

View File

@@ -0,0 +1,493 @@
/**
* 举报对话框组件
* 支持举报帖子、评论、消息
* 提供友好的用户界面和流畅的交互体验
*/
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import {
Modal,
View,
TouchableOpacity,
StyleSheet,
ScrollView,
TextInput,
ActivityIndicator,
Alert,
Platform,
KeyboardAvoidingView,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors } from '../../../theme';
import { spacing, borderRadius, fontSizes, shadows } from '../../../theme';
import { reportService, ReportReason, ReportTargetType, REPORT_REASONS } from '@/services/post';
import { blurActiveElement } from '../../../infrastructure/platform';
import Text from '../../common/Text';
export interface ReportDialogProps {
visible: boolean;
targetType: ReportTargetType;
targetId: string;
onClose: () => void;
onSuccess?: () => void;
}
// 目标类型配置
const TARGET_CONFIG: Record<ReportTargetType, { label: string; icon: string; color: string }> = {
post: { label: '帖子', icon: 'file-document-outline', color: '#FF6B35' },
comment: { label: '评论', icon: 'comment-text-outline', color: '#4CAF50' },
message: { label: '消息', icon: 'message-text-outline', color: '#2196F3' },
};
// 举报原因详细配置(带图标和说明)
const REPORT_REASONS_DETAILED = [
{ value: 'spam' as ReportReason, label: '垃圾广告', icon: 'email-newsletter', description: '包含广告、推广或重复内容' },
{ value: 'inappropriate' as ReportReason, label: '违规内容', icon: 'alert-circle-outline', description: '包含违法、色情或暴力内容' },
{ value: 'harassment' as ReportReason, label: '辱骂攻击', icon: 'account-off-outline', description: '包含人身攻击、骚扰或歧视' },
{ value: 'misinformation' as ReportReason, label: '虚假信息', icon: 'alert-outline', description: '包含谣言、诈骗或误导信息' },
{ value: 'other' as ReportReason, label: '其他原因', icon: 'dots-horizontal-circle-outline', description: '其他需要举报的情况' },
];
const ReportDialog: React.FC<ReportDialogProps> = ({
visible,
targetType,
targetId,
onClose,
onSuccess,
}) => {
const colors = useAppColors();
const styles = useStyles(colors);
const [selectedReason, setSelectedReason] = useState<ReportReason | null>(null);
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const targetConfig = TARGET_CONFIG[targetType];
useEffect(() => {
if (visible) {
blurActiveElement();
}
}, [visible]);
// 判断补充说明是否必填(选择"其他原因"时必填)
const isDescriptionRequired = selectedReason === 'other';
const descriptionPlaceholder = isDescriptionRequired
? '请详细描述您举报的原因(必填)...'
: '请提供更多详细信息(选填)...';
// 提交举报
const handleSubmit = useCallback(async () => {
if (!selectedReason) {
Alert.alert('提示', '请选择举报原因', [{ text: '确定' }]);
return;
}
if (isDescriptionRequired && !description.trim()) {
Alert.alert('提示', '请选择"其他原因"时,请详细描述举报原因', [{ text: '确定' }]);
return;
}
setSubmitting(true);
try {
const result = await reportService.createReport(
targetType,
targetId,
selectedReason,
description.trim() || undefined
);
if (result) {
Alert.alert('举报成功', '感谢您的反馈,我们会尽快处理', [
{
text: '确定',
onPress: () => {
onClose();
onSuccess?.();
},
},
]);
} else {
Alert.alert('举报失败', '请稍后重试', [{ text: '确定' }]);
}
} catch (error) {
console.error('Submit report error:', error);
Alert.alert('举报失败', '网络错误,请稍后重试', [{ text: '确定' }]);
} finally {
setSubmitting(false);
}
}, [selectedReason, description, targetType, targetId, onClose, onSuccess]);
// 重置状态
const handleClose = useCallback(() => {
setSelectedReason(null);
setDescription('');
onClose();
}, [onClose]);
const selectedReasonData = useMemo(
() => REPORT_REASONS_DETAILED.find(r => r.value === selectedReason),
[selectedReason]
);
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<View style={styles.overlay}>
<View style={styles.container}>
{/* 头部 */}
<View style={styles.header}>
<View style={styles.headerContent}>
<View style={[styles.headerIcon, { backgroundColor: targetConfig.color + '15' }]}>
<MaterialCommunityIcons
name={targetConfig.icon as any}
size={20}
color={targetConfig.color}
/>
</View>
<View style={styles.headerText}>
<Text style={styles.title}>{targetConfig.label}</Text>
<Text style={styles.subtitle}></Text>
</View>
</View>
<TouchableOpacity
style={styles.closeButton}
onPress={handleClose}
disabled={submitting}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<MaterialCommunityIcons
name="close"
size={24}
color={colors.chat.textSecondary}
/>
</TouchableOpacity>
</View>
{/* 内容 */}
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
{/* 举报原因选择 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.reasonList}>
{REPORT_REASONS_DETAILED.map((item) => (
<TouchableOpacity
key={item.value}
style={[
styles.reasonItem,
selectedReason === item.value && [
styles.reasonItemSelected,
{ borderColor: colors.primary.main + '40' }
],
]}
onPress={() => setSelectedReason(item.value)}
disabled={submitting}
activeOpacity={0.7}
>
<View style={styles.reasonLeft}>
<View
style={[
styles.reasonIcon,
selectedReason === item.value && styles.reasonIconSelected,
]}
>
<MaterialCommunityIcons
name={item.icon as any}
size={20}
color={selectedReason === item.value ? colors.primary.main : colors.chat.textSecondary}
/>
</View>
<View style={styles.reasonTextContainer}>
<Text style={[
styles.reasonLabel,
...(selectedReason === item.value ? [styles.reasonLabelSelected] : []),
]}>
{item.label}
</Text>
<Text style={styles.reasonDescription}>{item.description}</Text>
</View>
</View>
<View
style={[
styles.radio,
selectedReason === item.value && styles.radioSelected,
]}
>
{selectedReason === item.value && (
<View style={styles.radioDot} />
)}
</View>
</TouchableOpacity>
))}
</View>
</View>
{/* 补充说明 */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>
{isDescriptionRequired ? ' *' : ''}
</Text>
<Text style={styles.charCount}>{description.length}/500</Text>
</View>
<TextInput
style={[
styles.textInput,
isDescriptionRequired && !description && styles.textInputError,
]}
placeholder={descriptionPlaceholder}
placeholderTextColor={colors.chat.textPlaceholder}
multiline
maxLength={500}
value={description}
onChangeText={setDescription}
editable={!submitting}
textAlignVertical="top"
/>
</View>
</ScrollView>
{/* 底部按钮 */}
<View style={styles.footer}>
<TouchableOpacity
style={styles.cancelButton}
onPress={handleClose}
disabled={submitting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.submitButton,
(!selectedReason || submitting) && styles.submitButtonDisabled,
]}
onPress={handleSubmit}
disabled={!selectedReason || submitting}
activeOpacity={0.9}
>
{submitting ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.submitButtonText}></Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
};
// 创建样式
const useStyles = (colors: ReturnType<typeof useAppColors>) =>
StyleSheet.create({
keyboardView: {
flex: 1,
},
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
container: {
backgroundColor: colors.chat.card,
borderRadius: borderRadius.xl,
width: '100%',
maxWidth: 420,
maxHeight: '85%',
...shadows.lg,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: spacing.lg,
paddingBottom: spacing.md,
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
headerIcon: {
width: 40,
height: 40,
borderRadius: borderRadius.md,
justifyContent: 'center',
alignItems: 'center',
},
headerText: {
flex: 1,
},
title: {
fontSize: fontSizes.xl,
fontWeight: '600',
color: colors.chat.textPrimary,
},
subtitle: {
fontSize: fontSizes.sm,
color: colors.chat.textSecondary,
marginTop: 2,
},
closeButton: {
padding: spacing.xs,
borderRadius: borderRadius.full,
},
content: {
padding: spacing.lg,
paddingTop: 0,
maxHeight: 400,
},
section: {
marginBottom: spacing.lg,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.sm,
},
sectionTitle: {
fontSize: fontSizes.md,
fontWeight: '600',
color: colors.chat.textPrimary,
},
reasonList: {
gap: spacing.sm,
},
reasonItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: spacing.md,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: 'transparent',
backgroundColor: colors.chat.surfaceMuted,
},
reasonItemSelected: {
backgroundColor: colors.primary.main + '08',
borderWidth: 1,
},
reasonLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
gap: spacing.md,
},
reasonIcon: {
width: 36,
height: 36,
borderRadius: borderRadius.md,
backgroundColor: colors.chat.surfaceRaised,
justifyContent: 'center',
alignItems: 'center',
},
reasonIconSelected: {
backgroundColor: colors.primary.main + '15',
},
reasonTextContainer: {
flex: 1,
},
reasonLabel: {
fontSize: fontSizes.md,
fontWeight: '500',
color: colors.chat.textPrimary,
},
reasonLabelSelected: {
color: colors.primary.main,
fontWeight: '600',
},
reasonDescription: {
fontSize: fontSizes.sm,
color: colors.chat.textSecondary,
marginTop: 2,
},
radio: {
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
borderColor: colors.chat.border,
justifyContent: 'center',
alignItems: 'center',
marginLeft: spacing.sm,
},
radioSelected: {
borderColor: colors.primary.main,
backgroundColor: colors.primary.main,
},
radioDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: '#FFFFFF',
},
textInput: {
borderWidth: 1,
borderColor: colors.chat.border,
borderRadius: borderRadius.md,
padding: spacing.md,
fontSize: fontSizes.md,
color: colors.chat.textPrimary,
minHeight: 100,
backgroundColor: colors.chat.surfaceMuted,
},
textInputError: {
borderColor: colors.error.main,
backgroundColor: colors.error.main + '08',
},
charCount: {
fontSize: fontSizes.sm,
color: colors.chat.textSecondary,
},
footer: {
flexDirection: 'row',
padding: spacing.lg,
gap: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.chat.borderLight,
},
cancelButton: {
flex: 1,
paddingVertical: spacing.md,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.chat.border,
alignItems: 'center',
backgroundColor: colors.chat.surfaceMuted,
},
cancelButtonText: {
fontSize: fontSizes.md,
fontWeight: '500',
color: colors.chat.textSecondary,
},
submitButton: {
flex: 1,
paddingVertical: spacing.md,
borderRadius: borderRadius.md,
backgroundColor: colors.primary.main,
alignItems: 'center',
},
submitButtonDisabled: {
backgroundColor: colors.background.disabled,
},
submitButtonText: {
fontSize: fontSizes.md,
color: '#FFFFFF',
fontWeight: '600',
},
});
export default ReportDialog;

View File

@@ -0,0 +1,2 @@
export { default } from './ReportDialog';
export type { ReportDialogProps } from './ReportDialog';

View File

@@ -16,59 +16,52 @@ interface SearchBarProps {
onFocus?: () => void;
onBlur?: () => void;
autoFocus?: boolean;
compact?: boolean;
}
function createSearchBarStyles(colors: AppColors) {
function createSearchBarStyles(colors: AppColors, compact: boolean) {
return StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
backgroundColor: `${colors.primary.main}08`,
borderRadius: borderRadius.full,
paddingHorizontal: spacing.xs,
height: 46,
borderWidth: 1,
borderColor: colors.divider,
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
paddingHorizontal: compact ? spacing.sm : spacing.md,
height: compact ? 38 : 48,
borderWidth: 1.5,
borderColor: 'transparent',
},
containerFocused: {
borderColor: colors.primary.main,
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
borderColor: `${colors.primary.main}50`,
backgroundColor: `${colors.primary.main}10`,
},
searchIconWrap: {
width: 30,
height: 30,
marginLeft: spacing.xs,
marginRight: spacing.xs,
width: compact ? 22 : 32,
height: compact ? 22 : 32,
marginLeft: compact ? 2 : 0,
marginRight: compact ? 6 : spacing.sm,
borderRadius: borderRadius.full,
backgroundColor: `${colors.text.secondary}12`,
backgroundColor: 'transparent',
alignItems: 'center',
justifyContent: 'center',
},
searchIconWrapFocused: {
backgroundColor: `${colors.primary.main}1A`,
backgroundColor: 'transparent',
},
input: {
flex: 1,
fontSize: fontSizes.md,
fontSize: compact ? fontSizes.md : fontSizes.lg,
color: colors.text.primary,
paddingVertical: spacing.sm + 1,
paddingHorizontal: spacing.xs,
paddingVertical: compact ? 0 : spacing.sm,
paddingHorizontal: compact ? 2 : 0,
fontWeight: '500',
},
clearButton: {
width: 24,
height: 24,
marginHorizontal: spacing.xs,
width: 22,
height: 22,
marginLeft: spacing.xs,
borderRadius: borderRadius.full,
backgroundColor: `${colors.text.secondary}14`,
backgroundColor: `${colors.text.secondary}20`,
alignItems: 'center',
justifyContent: 'center',
},
@@ -83,9 +76,10 @@ const SearchBar: React.FC<SearchBarProps> = ({
onFocus,
onBlur,
autoFocus = false,
compact = false,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createSearchBarStyles(colors), [colors]);
const styles = useMemo(() => createSearchBarStyles(colors, compact), [colors, compact]);
const [isFocused, setIsFocused] = useState(false);
const handleFocus = () => {
@@ -103,7 +97,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
<View style={[styles.searchIconWrap, isFocused && styles.searchIconWrapFocused]}>
<MaterialCommunityIcons
name="magnify"
size={18}
size={compact ? 16 : 18}
color={isFocused ? colors.primary.main : colors.text.secondary}
/>
</View>

View File

@@ -0,0 +1,55 @@
import React, { useEffect, useMemo } from 'react';
import { Share as RNShare, Platform } from 'react-native';
export interface ShareSheetProps {
visible: boolean;
postUrl?: string;
postTitle?: string;
onClose: () => void;
onShareAction?: (actionKey: string) => void;
}
const ShareSheet: React.FC<ShareSheetProps> = ({
visible,
postUrl,
postTitle,
onClose,
onShareAction,
}) => {
const shareText = useMemo(() => {
const title = postTitle || '威友帖子';
return `${title} ${postUrl || ''}`;
}, [postTitle, postUrl]);
useEffect(() => {
if (!visible) return;
const doShare = async () => {
try {
await RNShare.share(
{
message: shareText,
url: Platform.OS === 'ios' ? postUrl : undefined,
title: postTitle || '分享帖子',
},
{
subject: postTitle,
dialogTitle: '分享到',
}
);
onShareAction?.('system_share');
} catch {
// User cancelled
} finally {
onClose();
}
};
doShare();
}, [visible, shareText, postUrl, postTitle, onShareAction, onClose]);
return null;
};
export default ShareSheet;

View File

@@ -0,0 +1,2 @@
export { default } from './ShareSheet';
export type { ShareSheetProps } from './ShareSheet';

View File

@@ -1,15 +1,14 @@
/**
* SystemMessageItem 系统消息项组件 - 美化版
* SystemMessageItem 系统消息项组件 - 扁平化风格
* 根据系统消息类型显示不同图标和内容
* 采用卡片式设计符合胡萝卜BBS整体风格
* 与登录、注册、设置页面风格保持一致
*/
import React, { useMemo } from 'react';
import { View, TouchableOpacity, StyleSheet } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
import { formatChatTime } from '../../utils/formatTime';
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
import Text from '../common/Text';
import Avatar from '../common/Avatar';
@@ -124,17 +123,13 @@ function createSystemMessageStyles(colors: AppColors) {
container: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: spacing.md,
padding: 16,
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
marginHorizontal: spacing.md,
marginBottom: spacing.sm,
...shadows.sm,
borderBottomWidth: 1,
borderBottomColor: colors.divider || '#E5E5EA',
},
unreadContainer: {
backgroundColor: colors.primary.light + '08',
borderLeftWidth: 3,
borderLeftColor: colors.primary.main,
backgroundColor: colors.primary.main + '0A',
},
unreadIndicator: {
position: 'absolute',
@@ -143,19 +138,19 @@ function createSystemMessageStyles(colors: AppColors) {
marginTop: -4,
width: 8,
height: 8,
borderRadius: borderRadius.full,
borderRadius: 4,
backgroundColor: colors.primary.main,
},
iconContainer: {
marginRight: spacing.md,
marginRight: 14,
},
avatarWrapper: {
position: 'relative',
},
iconWrapper: {
width: 48,
height: 48,
borderRadius: borderRadius.lg,
width: 44,
height: 44,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
@@ -163,9 +158,9 @@ function createSystemMessageStyles(colors: AppColors) {
position: 'absolute',
bottom: -2,
right: -2,
width: 20,
height: 20,
borderRadius: borderRadius.full,
width: 18,
height: 18,
borderRadius: 9,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
@@ -180,17 +175,17 @@ function createSystemMessageStyles(colors: AppColors) {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.xs,
marginBottom: 4,
},
titleLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
marginRight: spacing.sm,
marginRight: 8,
},
title: {
fontWeight: '500',
fontSize: fontSizes.md,
fontSize: 15,
color: colors.text.primary,
},
unreadTitle: {
@@ -200,10 +195,10 @@ function createSystemMessageStyles(colors: AppColors) {
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.xs,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: borderRadius.sm,
marginLeft: spacing.xs,
borderRadius: 4,
marginLeft: 6,
},
statusText: {
fontSize: 10,
@@ -211,55 +206,59 @@ function createSystemMessageStyles(colors: AppColors) {
marginLeft: 2,
},
time: {
fontSize: fontSizes.sm,
fontSize: 13,
flexShrink: 0,
color: colors.text.hint,
},
messageContent: {
lineHeight: 20,
fontSize: fontSizes.sm,
fontSize: 14,
color: colors.text.secondary,
},
extraInfo: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.xs,
backgroundColor: colors.primary.light + '12',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: borderRadius.md,
marginTop: 8,
backgroundColor: colors.background.default,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 8,
alignSelf: 'flex-start',
},
extraText: {
marginLeft: spacing.xs,
marginLeft: 6,
flex: 1,
fontWeight: '500',
fontSize: 13,
},
actionsRow: {
flexDirection: 'row',
marginTop: spacing.sm,
gap: spacing.sm,
marginTop: 12,
gap: 10,
},
actionBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.xs,
paddingHorizontal: spacing.md,
borderRadius: borderRadius.md,
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 10,
borderWidth: 1,
gap: spacing.xs,
gap: 4,
},
rejectBtn: {
borderColor: `${colors.error.main}55`,
backgroundColor: `${colors.error.main}18`,
borderColor: colors.error.main + '40',
backgroundColor: colors.error.light + '20',
},
approveBtn: {
borderColor: `${colors.success.main}55`,
backgroundColor: `${colors.success.main}18`,
borderColor: colors.success.main + '40',
backgroundColor: colors.success.light + '20',
},
actionBtnText: {
fontWeight: '500',
fontSize: 13,
},
arrowContainer: {
marginLeft: spacing.sm,
marginLeft: 8,
justifyContent: 'center',
alignItems: 'center',
height: 20,
@@ -329,18 +328,6 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
const colors = useAppColors();
const styles = useMemo(() => createSystemMessageStyles(colors), [colors]);
// 格式化时间
const formatTime = (dateString: string): string => {
try {
return formatDistanceToNow(new Date(dateString), {
addSuffix: true,
locale: zhCN,
});
} catch {
return '';
}
};
const { icon, color, bgColor } = getSystemMessageIcon(message.system_type, colors);
const title = getSystemMessageTitle(message);
const { extra_data } = message;
@@ -441,7 +428,7 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
{getStatusBadge()}
</View>
<Text variant="caption" color={colors.text.hint} style={styles.time}>
{formatTime(message.created_at)}
{formatChatTime(message.created_at)}
</Text>
</View>

View File

@@ -135,26 +135,21 @@ function createTabBarStyles(colors: AppColors) {
modernContainer: {
flexDirection: 'row',
backgroundColor: colors.background.paper,
borderRadius: borderRadius.xl,
marginHorizontal: spacing.lg,
marginVertical: spacing.md,
padding: spacing.xs,
shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 3,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
alignItems: 'center',
paddingRight: spacing.xs,
},
modernTab: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.sm,
borderRadius: borderRadius.lg,
paddingVertical: spacing.md,
position: 'relative',
backgroundColor: 'transparent',
},
modernTabActive: {
backgroundColor: colors.primary.main + '15',
backgroundColor: 'transparent',
},
modernTabContent: {
flexDirection: 'row',
@@ -169,15 +164,17 @@ function createTabBarStyles(colors: AppColors) {
fontSize: fontSizes.md,
},
modernTabTextActive: {
fontWeight: '700',
fontWeight: '600',
},
modernTabIndicator: {
position: 'absolute',
bottom: 4,
width: 20,
bottom: 0,
left: '25%',
right: '25%',
height: 3,
backgroundColor: colors.primary.main,
borderRadius: borderRadius.full,
borderTopLeftRadius: borderRadius.sm,
borderTopRightRadius: borderRadius.sm,
},
});
}
@@ -206,7 +203,7 @@ const TabBar: React.FC<TabBarProps> = ({
key={index}
style={[styles.modernTab, isActive && styles.modernTabActive]}
onPress={() => onTabChange(index)}
activeOpacity={0.85}
activeOpacity={0.7}
>
<View style={styles.modernTabContent}>
{icon && (

View File

@@ -0,0 +1,382 @@
import React, { memo } from 'react';
import { View, TouchableOpacity, Image, StyleSheet, Pressable } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, spacing, borderRadius, fontSizes, type AppColors } from '../../../theme';
import { Text } from '../../common';
import type { TradeItemDTO } from '../../../types/trade';
import { TRADE_CATEGORY_MAP, TRADE_CONDITION_MAP, TRADE_TYPE_MAP } from '../../../types/trade';
export interface TradeCardProps {
item: TradeItemDTO;
variant?: 'list' | 'grid';
onPress?: (id: string) => void;
onFavorite?: (id: string) => void;
}
function formatPrice(price?: number | null): string {
if (price == null) return '面议';
return `¥${price.toFixed(price % 1 === 0 ? 0 : 2)}`;
}
function createTradeCardStyles(colors: AppColors) {
return StyleSheet.create({
gridCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 6,
elevation: 3,
},
listCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
overflow: 'hidden',
padding: spacing.md,
},
imageContainer: {
position: 'relative',
aspectRatio: 1,
backgroundColor: colors.background.default,
minHeight: 140,
},
listImageContainer: {
width: 120,
height: 120,
borderRadius: borderRadius.md,
backgroundColor: colors.background.default,
overflow: 'hidden',
},
image: {
width: '100%',
height: '100%',
resizeMode: 'cover',
},
// 包邮/标签 badge 放在图片左下角,黄色背景
shippingBadge: {
position: 'absolute',
bottom: 6,
left: 6,
paddingHorizontal: 5,
paddingVertical: 2,
borderRadius: borderRadius.sm,
backgroundColor: '#FFE066',
},
shippingBadgeText: {
color: '#5C4B00',
fontSize: 10,
fontWeight: '700',
},
typeBadge: {
position: 'absolute',
top: 6,
left: 6,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: borderRadius.sm,
overflow: 'hidden',
},
sellBadge: {
backgroundColor: `${colors.success.main}CC`,
},
buyBadge: {
backgroundColor: `${colors.primary.main}CC`,
},
typeBadgeText: {
color: '#fff',
fontSize: fontSizes.xs,
fontWeight: '600',
},
statusBadge: {
position: 'absolute',
top: 6,
right: 6,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: borderRadius.sm,
backgroundColor: `${colors.warning.main}BB`,
},
statusBadgeText: {
color: '#fff',
fontSize: fontSizes.xs,
fontWeight: '600',
},
gridContent: {
paddingHorizontal: spacing.sm,
paddingTop: spacing.xs,
paddingBottom: spacing.sm,
},
titleRow: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: spacing.xs,
},
title: {
fontSize: fontSizes.md,
fontWeight: '500',
color: colors.text.primary,
lineHeight: 20,
flex: 1,
},
priceInline: {
fontSize: fontSizes.sm,
fontWeight: '700',
color: '#FF4D4F',
flexShrink: 0,
},
priceRow: {
flexDirection: 'row',
alignItems: 'baseline',
gap: 4,
marginTop: spacing.xs,
},
price: {
fontSize: fontSizes['2xl'],
fontWeight: '700',
color: '#FF4D4F',
},
originalPrice: {
fontSize: fontSizes.xs,
color: colors.text.hint,
textDecorationLine: 'line-through',
},
negotiable: {
fontSize: fontSizes.lg,
fontWeight: '600',
color: colors.text.secondary,
},
wantCount: {
fontSize: fontSizes.xs,
color: colors.text.hint,
marginLeft: 4,
},
tagsRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 4,
marginTop: 2,
},
tag: {
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: borderRadius.sm,
backgroundColor: `${colors.primary.main}10`,
},
tagText: {
fontSize: 10,
color: colors.primary.main,
},
conditionTag: {
backgroundColor: `${colors.success.main}10`,
},
conditionTagText: {
fontSize: 10,
color: colors.success.main,
},
// 促销标签样式(如"24小时发货"
promoTag: {
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: borderRadius.sm,
backgroundColor: '#FFF2F0',
borderWidth: 0.5,
borderColor: '#FFCCC7',
},
promoTagText: {
fontSize: 10,
color: '#FF4D4F',
},
bottomRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: spacing.xs,
},
authorRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
flex: 1,
},
avatar: {
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: colors.background.default,
},
authorName: {
fontSize: 11,
color: colors.text.secondary,
flexShrink: 1,
},
statsRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
statItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
statText: {
fontSize: 11,
color: colors.text.hint,
},
favButton: {
padding: 2,
},
listContent: {
flex: 1,
justifyContent: 'space-between',
},
listLayout: {
flexDirection: 'row',
gap: spacing.md,
},
imagePlaceholder: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background.default,
},
});
}
const TradeCardBase: React.FC<TradeCardProps> = ({ item, variant = 'list', onPress, onFavorite }) => {
const colors = useAppColors();
const styles = createTradeCardStyles(colors);
const firstImage = item.images?.[0];
const isSell = item.trade_type === 'sell';
const isActive = item.status === 'active';
const conditionLabel = item.condition ? TRADE_CONDITION_MAP[item.condition as keyof typeof TRADE_CONDITION_MAP] : null;
const categoryLabel = TRADE_CATEGORY_MAP[item.category] || item.category;
const renderImage = (containerStyle: any, imageStyle: any) => (
<View style={containerStyle}>
{firstImage ? (
<Image
source={{ uri: firstImage.preview_url || firstImage.url }}
style={imageStyle}
defaultSource={undefined}
/>
) : (
<View style={[imageStyle, styles.imagePlaceholder]}>
<MaterialCommunityIcons name="image-outline" size={32} color={colors.text.hint} />
</View>
)}
{/* 包邮标签 */}
{item.is_free_shipping && (
<View style={styles.shippingBadge}>
<Text style={styles.shippingBadgeText}></Text>
</View>
)}
<View style={[styles.typeBadge, isSell ? styles.sellBadge : styles.buyBadge]}>
<Text style={styles.typeBadgeText}>{TRADE_TYPE_MAP[item.trade_type]}</Text>
</View>
{!isActive && (
<View style={styles.statusBadge}>
<Text style={styles.statusBadgeText}>
{item.status === 'sold' ? '已售' : item.status === 'bought' ? '已收' : item.status === 'offline' ? '下架' : ''}
</Text>
</View>
)}
</View>
);
const renderTitleRow = () => (
<View style={styles.titleRow}>
<Text style={styles.title} numberOfLines={2}>{item.title}</Text>
<Text style={styles.priceInline}>
{item.price != null ? formatPrice(item.price) : '面议'}
</Text>
</View>
);
const renderTags = () => {
if (!conditionLabel && !isSell && !item.is_quick_ship) return null;
return (
<View style={styles.tagsRow}>
{conditionLabel && isSell && (
<View style={[styles.tag, styles.conditionTag]}>
<Text style={styles.conditionTagText}>{conditionLabel}</Text>
</View>
)}
{item.is_quick_ship && (
<View style={styles.promoTag}>
<Text style={styles.promoTagText}>24</Text>
</View>
)}
</View>
);
};
const renderBottom = () => (
<View style={styles.bottomRow}>
<View style={styles.authorRow}>
{item.author?.avatar ? (
<Image source={{ uri: item.author.avatar }} style={styles.avatar} />
) : (
<View style={[styles.avatar, { backgroundColor: colors.primary.main + '33' }]} />
)}
<Text style={styles.authorName} numberOfLines={1}>{item.author?.nickname || '匿名'}</Text>
</View>
<View style={styles.statsRow}>
{(item.good_reputation || 0) > 0 && (
<View style={[styles.promoTag, { backgroundColor: '#FFF7E6', borderColor: '#FFD591' }]}>
<Text style={[styles.promoTagText, { color: '#FA8C16' }]}></Text>
</View>
)}
<View style={styles.tag}>
<Text style={styles.tagText}>{categoryLabel}</Text>
</View>
</View>
</View>
);
if (variant === 'grid') {
return (
<TouchableOpacity
style={styles.gridCard}
onPress={() => onPress?.(item.id)}
activeOpacity={0.7}
>
{renderImage(styles.imageContainer, styles.image)}
<View style={styles.gridContent}>
{renderTitleRow()}
{renderTags()}
{renderBottom()}
</View>
</TouchableOpacity>
);
}
// grid variant is used in masonry layout; list variant below is unused but kept for compatibility
return (
<TouchableOpacity
style={styles.listCard}
onPress={() => onPress?.(item.id)}
activeOpacity={0.7}
>
<View style={styles.listLayout}>
{renderImage(styles.listImageContainer, styles.image)}
<View style={styles.listContent}>
{renderTitleRow()}
{renderTags()}
{renderBottom()}
</View>
</View>
</TouchableOpacity>
);
};
export const TradeCard = memo(TradeCardBase);
export default TradeCard;

File diff suppressed because it is too large Load Diff

View File

@@ -22,3 +22,10 @@ export { default as TabBar } from './TabBar';
export { default as VoteCard } from './VoteCard';
export { default as VoteEditor } from './VoteEditor';
export { default as VotePreview } from './VotePreview';
export { default as PostContentRenderer } from './PostContentRenderer';
export { default as PostMentionInput } from './PostMentionInput';
export { default as BlockEditor } from './BlockEditor';
export type { BlockEditorHandle } from './BlockEditor';
export { default as ReportDialog } from './ReportDialog';
export { default as ShareSheet } from './ShareSheet';
export { TradeCard } from './TradeCard/TradeCard';

View File

@@ -0,0 +1,412 @@
import React, { useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
StatusBar,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { callStore } from '../../stores/call';
import { RTCView } from 'react-native-webrtc';
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const CallScreen: React.FC = () => {
const currentCall = callStore((s) => s.currentCall);
const callDuration = callStore((s) => s.callDuration);
const peerStream = callStore((s) => s.peerStream);
const localStream = callStore((s) => s.localStream);
const endCall = callStore((s) => s.endCall);
const toggleMute = callStore((s) => s.toggleMute);
const toggleSpeaker = callStore((s) => s.toggleSpeaker);
const toggleVideo = callStore((s) => s.toggleVideo);
const isMinimized = callStore((s) => s.isMinimized);
const toggleMinimize = callStore((s) => s.toggleMinimize);
// Track whether peer has video
const [hasPeerVideo, setHasPeerVideo] = useState(false);
// Track whether local has video
const [hasLocalVideo, setHasLocalVideo] = useState(false);
// Check peer stream for video tracks
useEffect(() => {
if (peerStream) {
const videoTracks = peerStream.getVideoTracks();
const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
setHasPeerVideo(hasVideo);
} else {
setHasPeerVideo(false);
}
}, [peerStream]);
// Check local stream for video tracks
useEffect(() => {
if (localStream) {
const videoTracks = localStream.getVideoTracks();
const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
setHasLocalVideo(hasVideo);
} else {
setHasLocalVideo(false);
}
}, [localStream]);
// Don't render full screen when minimized or no call
if (!currentCall || isMinimized) return null;
const getStatusText = (): string => {
switch (currentCall.status) {
case 'calling':
return '正在等待对方接听...';
case 'ringing':
return '来电响铃中...';
case 'connecting':
return '连接中...';
case 'connected':
return formatDuration(callDuration);
case 'reconnecting':
return '网络重连中...';
case 'ended':
return '通话已结束';
case 'failed':
return '连接失败';
default:
return '';
}
};
const handleEndCall = () => {
endCall('hangup');
};
const handleToggleMute = () => {
toggleMute();
};
const handleToggleSpeaker = () => {
toggleSpeaker();
};
const handleToggleVideo = () => {
toggleVideo();
};
const handleMinimize = () => {
toggleMinimize();
};
// Determine if we should show video UI
const showRemoteVideo = hasPeerVideo;
// Use currentCall.isVideoEnabled directly for local video
// This is more reliable than checking localStream.getVideoTracks()
// because the stream object reference may not trigger useEffect properly
const showLocalVideo = currentCall?.isVideoEnabled && localStream;
const isVideoCallActive = showRemoteVideo || showLocalVideo;
return (
<View style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Background */}
<View style={styles.background} />
{/* Remote video - full screen when peer has video */}
{showRemoteVideo && peerStream && (
<RTCView
streamURL={peerStream.toURL()}
style={styles.fullScreenVideo}
objectFit="cover"
mirror={false}
/>
)}
{/* Local video - picture in picture */}
{showLocalVideo && localStream && (
<View style={styles.localVideoContainer}>
<RTCView
streamURL={localStream.toURL()}
style={styles.localVideo}
objectFit="cover"
mirror={true}
/>
</View>
)}
{/* Top area: minimize button */}
<View style={styles.topBar}>
<TouchableOpacity
style={styles.topButton}
onPress={handleMinimize}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
</TouchableOpacity>
</View>
{/* Center: Peer info - only show when no video */}
{!isVideoCallActive && (
<View style={styles.centerArea}>
<View style={styles.avatarOuter}>
{currentCall.peerAvatar ? (
<Image source={{ uri: currentCall.peerAvatar }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
</View>
<Text style={styles.peerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.status}>{getStatusText()}</Text>
</View>
)}
{/* Peer name overlay when video is active */}
{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.controlRow}>
{/* Mute */}
<TouchableOpacity
style={[styles.controlButton, currentCall.isMuted && styles.controlButtonActive]}
onPress={handleToggleMute}
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>
{/* Video toggle */}
<TouchableOpacity
style={[styles.controlButton, hasLocalVideo && styles.controlButtonActive]}
onPress={handleToggleVideo}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, hasLocalVideo && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={hasLocalVideo ? 'video' : 'video-off'}
size={26}
color={hasLocalVideo ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, hasLocalVideo && styles.controlLabelActive]}>
{hasLocalVideo ? '关闭摄像头' : '打开摄像头'}
</Text>
</TouchableOpacity>
{/* Speaker */}
<TouchableOpacity
style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]}
onPress={handleToggleSpeaker}
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>
{/* End call - prominent red button */}
<TouchableOpacity
style={styles.endCallButton}
onPress={handleEndCall}
activeOpacity={0.8}
>
<View style={styles.endCallCircle}>
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
</View>
<Text style={styles.endCallLabel}></Text>
</TouchableOpacity>
</View>
</View>
);
};
const CONTROL_CIRCLE_SIZE = 56;
const END_CALL_SIZE = 64;
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
zIndex: 9999,
},
background: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
},
topBar: {
position: 'absolute',
top: 50,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
height: 44,
zIndex: 100,
},
topButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
},
centerArea: {
position: 'absolute',
top: '28%',
left: 0,
right: 0,
alignItems: 'center',
paddingHorizontal: 30,
},
avatarOuter: {
marginBottom: 16,
},
avatar: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: '#3A3A5C',
},
avatarPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 40,
color: '#fff',
fontWeight: '600',
},
peerName: {
fontSize: 22,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 6,
textAlign: 'center',
maxWidth: 280,
},
status: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.5)',
letterSpacing: 0.3,
},
fullScreenVideo: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
},
localVideoContainer: {
position: 'absolute',
top: 100,
right: 20,
width: 120,
height: 160,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#2A2A4E',
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.2)',
zIndex: 50,
},
localVideo: {
width: '100%',
height: '100%',
},
videoOverlayInfo: {
position: 'absolute',
top: 100,
left: 0,
right: 0,
alignItems: 'center',
zIndex: 50,
},
videoPeerName: {
fontSize: 18,
fontWeight: '600',
color: '#FFFFFF',
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
videoStatus: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.8)',
marginTop: 4,
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
controls: {
position: 'absolute',
bottom: 60,
left: 0,
right: 0,
alignItems: 'center',
zIndex: 100,
},
controlRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: 24,
marginBottom: 24,
},
controlButton: {
alignItems: 'center',
width: 70,
},
controlCircle: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(255, 255, 255, 0.12)',
justifyContent: 'center',
alignItems: 'center',
},
controlCircleActive: {
backgroundColor: '#FFFFFF',
},
controlButtonActive: {},
controlLabel: {
fontSize: 11,
color: 'rgba(255, 255, 255, 0.55)',
marginTop: 8,
textAlign: 'center',
},
controlLabelActive: {
color: '#FFFFFF',
fontWeight: '500',
},
endCallButton: {
alignItems: 'center',
},
endCallCircle: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
},
endCallLabel: {
fontSize: 11,
color: 'rgba(255, 255, 255, 0.55)',
marginTop: 8,
},
});
export default CallScreen;

View File

@@ -0,0 +1,160 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
StatusBar,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { callStore } from '../../stores/call';
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const CallScreen: React.FC = () => {
const currentCall = callStore((s) => s.currentCall);
const callDuration = callStore((s) => s.callDuration);
const endCall = callStore((s) => s.endCall);
const isMinimized = callStore((s) => s.isMinimized);
// Don't render full screen when minimized or no call
if (!currentCall || isMinimized) return null;
const getStatusText = (): string => {
switch (currentCall.status) {
case 'ringing':
return '通话功能暂不支持网页端';
case 'connecting':
return '通话功能暂不支持网页端';
case 'connected':
return '通话功能暂不支持网页端';
case 'ended':
return '通话已结束';
default:
return '';
}
};
const handleEndCall = () => {
endCall('hangup');
};
return (
<View style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Background - single consistent color */}
<View style={styles.background} />
{/* Center: Peer info */}
<View style={styles.centerArea}>
<View style={styles.avatarOuter}>
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<MaterialCommunityIcons name="web-off" size={50} color="#fff" />
</View>
</View>
<Text style={styles.peerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.status}>{getStatusText()}</Text>
</View>
{/* Bottom controls */}
<View style={styles.controls}>
{/* End call - prominent red button */}
<TouchableOpacity
style={styles.endCallButton}
onPress={handleEndCall}
activeOpacity={0.8}
>
<View style={styles.endCallCircle}>
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
</View>
<Text style={styles.endCallLabel}></Text>
</TouchableOpacity>
</View>
</View>
);
};
const END_CALL_SIZE = 64;
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
zIndex: 9999,
},
background: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
},
centerArea: {
position: 'absolute',
top: '28%',
left: 0,
right: 0,
alignItems: 'center',
paddingHorizontal: 30,
},
avatarOuter: {
marginBottom: 16,
},
avatar: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: '#3A3A5C',
},
avatarPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 40,
color: '#fff',
fontWeight: '600',
},
peerName: {
fontSize: 22,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 6,
textAlign: 'center',
maxWidth: 280,
},
status: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.5)',
letterSpacing: 0.3,
},
controls: {
position: 'absolute',
bottom: 60,
left: 0,
right: 0,
alignItems: 'center',
},
endCallButton: {
alignItems: 'center',
},
endCallCircle: {
width: END_CALL_SIZE,
height: END_CALL_SIZE,
borderRadius: END_CALL_SIZE / 2,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
},
endCallLabel: {
fontSize: 11,
color: 'rgba(255, 255, 255, 0.55)',
marginTop: 8,
},
});
export default CallScreen;

View File

@@ -0,0 +1,159 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { callStore } from '../../stores/call';
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const FloatingCallWindow: React.FC = () => {
const currentCall = callStore((s) => s.currentCall);
const callDuration = callStore((s) => s.callDuration);
const isMinimized = callStore((s) => s.isMinimized);
const toggleMinimize = callStore((s) => s.toggleMinimize);
const endCall = callStore((s) => s.endCall);
// Only show when there's an active call and it's minimized
if (!currentCall || !isMinimized) return null;
const getStatusText = (): string => {
switch (currentCall.status) {
case 'calling':
return '等待接听...';
case 'ringing':
return '来电响铃...';
case 'connecting':
return '连接中...';
case 'connected':
return formatDuration(callDuration);
case 'reconnecting':
return '重连中...';
case 'ended':
return '已结束';
case 'failed':
return '连接失败';
default:
return '';
}
};
return (
<View style={styles.container}>
<TouchableOpacity
style={styles.window}
onPress={toggleMinimize}
activeOpacity={0.9}
>
{/* Avatar */}
<View style={styles.avatarContainer}>
{currentCall.peerAvatar ? (
<Image
source={{ uri: currentCall.peerAvatar }}
style={styles.avatar}
/>
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
</View>
{/* Info */}
<View style={styles.info}>
<Text style={styles.name} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.status}>{getStatusText()}</Text>
</View>
{/* End call button */}
<TouchableOpacity
style={styles.endButton}
onPress={(e) => {
e.stopPropagation();
endCall('hangup');
}}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="phone-hangup" size={20} color="#fff" />
</TouchableOpacity>
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 100,
left: 16,
right: 16,
zIndex: 10000,
},
window: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#2A2A4E',
borderRadius: 16,
padding: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
avatarContainer: {
marginRight: 12,
},
avatar: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#3A3A5C',
},
avatarPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 18,
color: '#fff',
fontWeight: '600',
},
info: {
flex: 1,
justifyContent: 'center',
},
name: {
fontSize: 15,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 2,
},
status: {
fontSize: 12,
color: 'rgba(255, 255, 255, 0.6)',
},
endButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
marginLeft: 8,
},
});
export default FloatingCallWindow;

View File

@@ -0,0 +1,279 @@
import React, { useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
Animated,
Modal,
StatusBar,
Dimensions,
Platform,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { callStore } from '../../stores/call';
import { blurActiveElement } from '../../infrastructure/platform';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
const IncomingCallModal: React.FC = () => {
const incomingCall = callStore((s) => s.incomingCall);
const acceptCall = callStore((s) => s.acceptCall);
const rejectCall = callStore((s) => s.rejectCall);
const pulseAnim = useRef(new Animated.Value(1)).current;
const rippleAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (incomingCall) {
blurActiveElement();
const pulse = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.06,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
])
);
const ripple = Animated.loop(
Animated.sequence([
Animated.timing(rippleAnim, {
toValue: 1,
duration: 2000,
useNativeDriver: true,
}),
Animated.timing(rippleAnim, {
toValue: 0,
duration: 0,
useNativeDriver: true,
}),
])
);
Animated.parallel([pulse, ripple]).start();
return () => {
pulse.stop();
ripple.stop();
};
}
}, [incomingCall, pulseAnim, rippleAnim]);
const handleAccept = () => {
acceptCall();
};
const handleReject = () => {
rejectCall();
};
if (!incomingCall) return null;
return (
<Modal
visible={!!incomingCall}
transparent
animationType="fade"
statusBarTranslucent
>
<View style={styles.overlay}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Avatar with ripple effect - positioned in upper area */}
<View style={styles.avatarSection}>
{/* Ripple rings */}
{[0, 1, 2].map((i) => (
<Animated.View
key={i}
style={[
styles.rippleRing,
{
transform: [
{
scale: rippleAnim.interpolate({
inputRange: [0, 1],
outputRange: [1, 1.5 + i * 0.2],
}),
},
],
opacity: rippleAnim.interpolate({
inputRange: [0, 0.4, 1],
outputRange: [0.25, 0.12, 0],
}),
},
]}
/>
))}
<Animated.View
style={[styles.avatarContainer, { transform: [{ scale: pulseAnim }] }]}
>
{incomingCall.callerAvatar ? (
<Image
source={{ uri: incomingCall.callerAvatar }}
style={styles.avatar}
/>
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{incomingCall.callerName?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
</Animated.View>
</View>
{/* Caller info */}
<View style={styles.infoSection}>
<Text style={styles.callerName} numberOfLines={1}>
{incomingCall.callerName || '未知用户'}
</Text>
<Text style={styles.callType}>
{incomingCall.callType === 'video' ? '视频通话' : '语音通话'}
</Text>
</View>
{/* Bottom controls */}
<View style={styles.controls}>
{/* Decline */}
<TouchableOpacity
style={styles.actionButton}
onPress={handleReject}
activeOpacity={0.8}
>
<View style={styles.declineCircle}>
<MaterialCommunityIcons name="phone-hangup" size={28} color="#fff" />
</View>
<Text style={styles.actionLabel}></Text>
</TouchableOpacity>
{/* Accept */}
<TouchableOpacity
style={styles.actionButton}
onPress={handleAccept}
activeOpacity={0.8}
>
<View style={styles.acceptCircle}>
<MaterialCommunityIcons name="phone" size={28} color="#fff" />
</View>
<Text style={styles.actionLabel}></Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};
const AVATAR_SIZE = 100;
const ACTION_CIRCLE_SIZE = 60;
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: '#0D0D0D',
},
avatarSection: {
position: 'absolute',
top: '18%',
left: 0,
right: 0,
height: AVATAR_SIZE + 60,
justifyContent: 'center',
alignItems: 'center',
},
rippleRing: {
position: 'absolute',
width: AVATAR_SIZE + 16,
height: AVATAR_SIZE + 16,
borderRadius: (AVATAR_SIZE + 16) / 2,
borderWidth: 2,
borderColor: '#4CD964',
},
avatarContainer: {
width: AVATAR_SIZE + 8,
height: AVATAR_SIZE + 8,
borderRadius: (AVATAR_SIZE + 8) / 2,
backgroundColor: '#4CD964',
justifyContent: 'center',
alignItems: 'center',
},
avatar: {
width: AVATAR_SIZE,
height: AVATAR_SIZE,
borderRadius: AVATAR_SIZE / 2,
backgroundColor: '#3A3A5C',
},
avatarPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 36,
color: '#fff',
fontWeight: '600',
},
infoSection: {
position: 'absolute',
top: '42%',
left: 0,
right: 0,
alignItems: 'center',
paddingHorizontal: 40,
},
callerName: {
fontSize: 24,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 6,
textAlign: 'center',
maxWidth: 280,
},
callType: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.5)',
letterSpacing: 0.3,
},
controls: {
position: 'absolute',
bottom: '12%',
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'center',
gap: 60,
},
actionButton: {
alignItems: 'center',
width: 80,
},
declineCircle: {
width: ACTION_CIRCLE_SIZE,
height: ACTION_CIRCLE_SIZE,
borderRadius: ACTION_CIRCLE_SIZE / 2,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
},
acceptCircle: {
width: ACTION_CIRCLE_SIZE,
height: ACTION_CIRCLE_SIZE,
borderRadius: ACTION_CIRCLE_SIZE / 2,
backgroundColor: '#4CD964',
justifyContent: 'center',
alignItems: 'center',
},
actionLabel: {
fontSize: 12,
color: 'rgba(255, 255, 255, 0.6)',
marginTop: 10,
},
});
export default IncomingCallModal;

View File

@@ -0,0 +1,3 @@
export { default as CallScreen } from './CallScreen';
export { default as IncomingCallModal } from './IncomingCallModal';
export { default as FloatingCallWindow } from './FloatingCallWindow';

View File

@@ -15,12 +15,14 @@ import {
Modal,
Pressable,
Dimensions,
Platform,
} from 'react-native';
import {
useResponsive,
useBreakpointGTE,
FineBreakpointKey,
} from '../../hooks/useResponsive';
} from '../../hooks';
import { blurActiveElement } from '../../infrastructure/platform';
export interface AdaptiveLayoutProps {
/** 主内容区 */
@@ -159,6 +161,9 @@ export function AdaptiveLayout({
// 抽屉动画
useEffect(() => {
if (isDrawerOpen) {
if (isDrawerOpen) {
blurActiveElement();
}
Animated.parallel([
Animated.timing(slideAnim, {
toValue: 1,
@@ -359,44 +364,4 @@ const styles = StyleSheet.create({
},
});
/**
* 简化的侧边栏布局组件
* 仅包含主内容和侧边栏,无头部底部
*/
export interface SidebarLayoutProps {
children: React.ReactNode;
sidebar: React.ReactNode;
style?: StyleProp<ViewStyle>;
contentStyle?: StyleProp<ViewStyle>;
sidebarStyle?: StyleProp<ViewStyle>;
sidebarWidth?: number;
showSidebarBreakpoint?: FineBreakpointKey;
sidebarPosition?: 'left' | 'right';
}
export function SidebarLayout({
children,
sidebar,
style,
contentStyle,
sidebarStyle,
sidebarWidth = 280,
showSidebarBreakpoint = 'lg',
sidebarPosition = 'left',
}: SidebarLayoutProps) {
return (
<AdaptiveLayout
sidebar={sidebar}
style={style}
contentStyle={contentStyle}
sidebarStyle={sidebarStyle}
sidebarWidth={sidebarWidth}
showSidebarBreakpoint={showSidebarBreakpoint}
sidebarPosition={sidebarPosition}
>
{children}
</AdaptiveLayout>
);
}
export default AdaptiveLayout;

View File

@@ -2,56 +2,55 @@ import React from 'react';
import { TouchableOpacity, StyleProp, ViewStyle, StyleSheet } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { borderRadius, spacing, useAppColors } from '../../theme';
import { useAppColors } from '../../theme';
type AppBackButtonIcon = 'arrow-left' | 'close';
type AppBackButtonIcon = 'chevron-left' | 'arrow-left' | 'close';
interface AppBackButtonProps {
onPress: () => void;
icon?: AppBackButtonIcon;
style?: StyleProp<ViewStyle>;
iconColor?: string;
backgroundColor?: string;
hitSlop?: number;
testID?: string;
}
const AppBackButton: React.FC<AppBackButtonProps> = ({
onPress,
icon = 'arrow-left',
icon = 'chevron-left',
style,
iconColor: iconColorProp,
backgroundColor: backgroundColorProp,
hitSlop = 8,
testID,
}) => {
const colors = useAppColors();
const iconColor = iconColorProp ?? colors.text.primary;
const backgroundColor = backgroundColorProp ?? colors.background.paper;
return (
<TouchableOpacity
onPress={onPress}
style={[styles.button, { backgroundColor }, style]}
activeOpacity={0.75}
style={[styles.button, style]}
activeOpacity={0.6}
hitSlop={hitSlop}
testID={testID}
accessibilityRole="button"
accessibilityLabel={icon === 'close' ? '关闭' : '返回'}
>
<MaterialCommunityIcons name={icon} size={22} color={iconColor} />
<MaterialCommunityIcons name={icon} size={36} color={iconColor} />
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
width: 36,
height: 36,
borderRadius: borderRadius.full,
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.xs,
marginLeft: -12,
marginRight: 0,
padding: 0,
backgroundColor: 'transparent',
},
});

View File

@@ -1,11 +1,12 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View, Platform } from 'react-native';
import type { AlertButton } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { bindDialogListener, DialogPayload } from '../../services/dialogService';
import { bindDialogListener, DialogPayload } from '@/services/ui';
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
import { blurActiveElement } from '../../infrastructure/platform';
function createDialogHostStyles(colors: AppColors) {
return StyleSheet.create({
@@ -105,6 +106,12 @@ const AppDialogHost: React.FC = () => {
return unbind;
}, []);
useEffect(() => {
if (dialog) {
blurActiveElement();
}
}, [dialog]);
const actions = useMemo<AlertButton[]>(() => {
if (!dialog?.actions?.length) return [{ text: '确定' }];
return dialog.actions;

View File

@@ -3,7 +3,7 @@ import { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { bindPromptListener, PromptPayload, PromptType } from '../../services/promptService';
import { bindPromptListener, PromptPayload, PromptType } from '@/services/ui';
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
interface PromptState extends PromptPayload {

View File

@@ -0,0 +1,54 @@
import React, { useMemo } from 'react';
import { Text, TextStyle, StyleProp } from 'react-native';
interface HighlightTextProps {
text: string;
keyword: string;
style?: StyleProp<TextStyle>;
}
const HighlightText: React.FC<HighlightTextProps> = ({ text, keyword, style }) => {
const parts = useMemo(() => {
if (!text || !keyword) return [{ text, highlight: false }];
const result: Array<{ text: string; highlight: boolean }> = [];
const lowerText = text.toLowerCase();
const lowerKeyword = keyword.toLowerCase();
let lastIndex = 0;
let searchFrom = 0;
while (searchFrom < lowerText.length) {
const index = lowerText.indexOf(lowerKeyword, searchFrom);
if (index === -1) break;
if (index > lastIndex) {
result.push({ text: text.substring(lastIndex, index), highlight: false });
}
result.push({ text: text.substring(index, index + keyword.length), highlight: true });
lastIndex = index + keyword.length;
searchFrom = lastIndex;
}
if (lastIndex < text.length) {
result.push({ text: text.substring(lastIndex), highlight: false });
}
return result.length > 0 ? result : [{ text, highlight: false }];
}, [text, keyword]);
return (
<Text>
{parts.map((part, i) =>
part.highlight ? (
<Text key={i} style={style}>
{part.text}
</Text>
) : (
<Text key={i}>{part.text}</Text>
)
)}
</Text>
);
};
export default HighlightText;

View File

@@ -14,6 +14,7 @@ import {
Text,
StatusBar,
Alert,
Platform,
} from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import {
@@ -32,6 +33,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as MediaLibrary from 'expo-media-library';
import { File, Paths } from 'expo-file-system';
import { spacing, borderRadius, fontSizes } from '../../theme';
import { blurActiveElement } from '../../infrastructure/platform';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
@@ -126,6 +128,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
// 打开/关闭时重置状态
useEffect(() => {
if (visible) {
blurActiveElement();
setCurrentIndex(initialIndex);
setShowControls(true);
setError(false);
@@ -215,7 +218,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
const allowedExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
const fileExt = allowedExts.includes(ext) ? ext : 'jpg';
const fileName = `carrot_${Date.now()}.${fileExt}`;
const fileName = `withyou_${Date.now()}.${fileExt}`;
const destination = new File(Paths.cache, fileName);
// File.downloadFileAsync 是新版 expo-file-system/next 的静态方法
@@ -358,7 +361,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
onRequestClose={requestClose}
statusBarTranslucent
>
<GestureHandlerRootView style={styles.root}>
<GestureHandlerRootView style={styles.root} data-image-viewer="true">
<View style={[styles.container, { backgroundColor: `rgba(0, 0, 0, ${backgroundOpacity})` }]}>
{/* 顶部控制栏 */}
{showControls && (

View File

@@ -132,10 +132,10 @@ function createImageGridStyles(colors: AppColors) {
aspectRatio: 1,
},
gridItem2: {
width: '49%',
width: '48.5%',
},
gridItem3: {
width: '32.5%',
width: '31.8%',
},
masonryContainer: {
flexDirection: 'row',

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { ViewStyle } from 'react-native';
import PagerViewNative from 'react-native-pager-view';
type PagerViewProps = {
style?: ViewStyle;
initialPage?: number;
onPageSelected?: (e: { nativeEvent: { position: number } }) => void;
overdrag?: boolean;
children: React.ReactNode;
};
export const PagerView = React.forwardRef<PagerViewNative, PagerViewProps>(
({ style, initialPage, onPageSelected, overdrag, children }, ref) => {
return (
<PagerViewNative
ref={ref}
style={style}
initialPage={initialPage}
onPageSelected={onPageSelected}
overdrag={overdrag}
>
{children}
</PagerViewNative>
);
}
);
PagerView.displayName = 'PagerView';
export default PagerView;

View File

@@ -0,0 +1,34 @@
import React, { ForwardedRef } from 'react';
import { View, ViewStyle } from 'react-native';
type PagerViewProps = {
style?: ViewStyle;
initialPage?: number;
onPageSelected?: (e: { nativeEvent: { position: number } }) => void;
overdrag?: boolean;
children: React.ReactNode;
testID?: string;
};
type PagerViewRef = {
setPage: (page: number) => void;
setPageWithoutAnimation: (page: number) => void;
setScrollEnabled: (scrollEnabled: boolean) => void;
};
function PagerViewInternal(
{ style, children }: PagerViewProps,
_ref: ForwardedRef<PagerViewRef>
) {
const childrenArray = React.Children.toArray(children);
return (
<View style={style}>
{childrenArray[0]}
</View>
);
}
export const PagerView = React.forwardRef(PagerViewInternal);
PagerView.displayName = 'PagerView';
export default PagerView;

View File

@@ -5,7 +5,7 @@
import React from 'react';
import { View, StyleProp, ViewStyle } from 'react-native';
import { useResponsive } from '../../hooks/useResponsive';
import { useResponsive } from '../../hooks';
export interface ResponsiveContainerProps {
children: React.ReactNode;

View File

@@ -16,7 +16,7 @@ import {
useColumnCount,
useResponsiveSpacing,
FineBreakpointKey,
} from '../../hooks/useResponsive';
} from '../../hooks';
export interface ResponsiveGridProps {
/** 子元素 */

View File

@@ -1,212 +0,0 @@
/**
* 响应式堆叠布局组件
* 移动端垂直堆叠,平板/桌面端水平排列
*/
import React, { useMemo } from 'react';
import {
View,
StyleProp,
ViewStyle,
StyleSheet,
FlexAlignType,
} from 'react-native';
import {
useResponsive,
useResponsiveSpacing,
FineBreakpointKey,
useBreakpointGTE,
} from '../../hooks/useResponsive';
export type StackDirection = 'horizontal' | 'vertical' | 'responsive';
export type StackAlignment = 'start' | 'center' | 'end' | 'stretch' | 'baseline';
export type StackJustify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
export interface ResponsiveStackProps {
/** 子元素 */
children: React.ReactNode;
/** 布局方向 */
direction?: StackDirection;
/** 切换为水平布局的断点(仅在 direction='responsive' 时有效) */
horizontalBreakpoint?: FineBreakpointKey;
/** 间距 */
gap?: Partial<Record<FineBreakpointKey, number>> | number;
/** 是否允许换行 */
wrap?: boolean;
/** 对齐方式(交叉轴) */
align?: StackAlignment;
/** 分布方式(主轴) */
justify?: StackJustify;
/** 自定义样式 */
style?: StyleProp<ViewStyle>;
/** 子元素样式 */
itemStyle?: StyleProp<ViewStyle>;
/** 是否反转顺序 */
reverse?: boolean;
/** 是否等分空间 */
equalItem?: boolean;
}
const alignMap: Record<StackAlignment, FlexAlignType> = {
start: 'flex-start',
center: 'center',
end: 'flex-end',
stretch: 'stretch',
baseline: 'baseline',
};
const justifyMap: Record<StackJustify, 'flex-start' | 'center' | 'flex-end' | 'space-between' | 'space-around' | 'space-evenly'> = {
start: 'flex-start',
center: 'center',
end: 'flex-end',
between: 'space-between',
around: 'space-around',
evenly: 'space-evenly',
};
/**
* 响应式堆叠布局组件
*
* - 移动端垂直堆叠
* - 平板/桌面端水平排列
* - 支持间距和换行配置
*
* @example
* // 基础用法 - 自动响应式
* <ResponsiveStack>
* <Item1 />
* <Item2 />
* <Item3 />
* </ResponsiveStack>
*
* @example
* // 自定义断点和间距
* <ResponsiveStack
* direction="responsive"
* horizontalBreakpoint="md"
* gap={{ xs: 8, md: 16, lg: 24 }}
* align="center"
* justify="between"
* >
* <Item1 />
* <Item2 />
* </ResponsiveStack>
*
* @example
* // 固定水平方向
* <ResponsiveStack direction="horizontal" wrap gap={16}>
* {items.map(item => <Tag key={item.id} {...item} />)}
* </ResponsiveStack>
*/
export function ResponsiveStack({
children,
direction = 'responsive',
horizontalBreakpoint = 'lg',
gap: gapConfig,
wrap = false,
align = 'stretch',
justify = 'start',
style,
itemStyle,
reverse = false,
equalItem = false,
}: ResponsiveStackProps) {
const { isMobile, isTablet } = useResponsive();
const isHorizontalBreakpoint = useBreakpointGTE(horizontalBreakpoint);
// 计算间距
const gap = useMemo(() => {
if (typeof gapConfig === 'number') {
return gapConfig;
}
// 使用 hook 获取响应式间距
return gapConfig;
}, [gapConfig]);
const responsiveGap = useResponsiveSpacing(typeof gap === 'number' ? undefined : gap);
const finalGap = typeof gap === 'number' ? gap : responsiveGap;
// 确定布局方向
const isHorizontal = useMemo(() => {
if (direction === 'horizontal') return true;
if (direction === 'vertical') return false;
// direction === 'responsive'
return isHorizontalBreakpoint;
}, [direction, isHorizontalBreakpoint]);
// 构建容器样式
const containerStyle = useMemo((): ViewStyle => {
const flexDirection = isHorizontal
? (reverse ? 'row-reverse' : 'row')
: (reverse ? 'column-reverse' : 'column');
return {
flexDirection,
flexWrap: wrap ? 'wrap' : 'nowrap',
alignItems: alignMap[align],
justifyContent: justifyMap[justify],
gap: finalGap,
};
}, [isHorizontal, reverse, wrap, align, justify, finalGap]);
// 处理子元素
const processedChildren = useMemo(() => {
const childrenArray = React.Children.toArray(children);
return childrenArray.map((child, index) => {
if (!React.isValidElement(child)) {
return child;
}
const childStyle: ViewStyle = {};
if (equalItem) {
childStyle.flex = 1;
}
// 如果不是最后一个元素,添加间距
// 注意:使用 gap 后不需要手动添加 margin
return (
<View
key={child.key ?? `stack-item-${index}`}
style={[equalItem && styles.equalItem, itemStyle, childStyle]}
>
{child}
</View>
);
});
}, [children, equalItem, itemStyle]);
return (
<View style={[styles.container, containerStyle, style]}>
{processedChildren}
</View>
);
}
const styles = StyleSheet.create({
container: {
width: '100%',
},
equalItem: {
flex: 1,
minWidth: 0, // 防止 flex item 溢出
},
});
/**
* 水平堆叠组件(快捷方式)
*/
export function HStack(props: Omit<ResponsiveStackProps, 'direction'>) {
return <ResponsiveStack {...props} direction="horizontal" />;
}
/**
* 垂直堆叠组件(快捷方式)
*/
export function VStack(props: Omit<ResponsiveStackProps, 'direction'>) {
return <ResponsiveStack {...props} direction="vertical" />;
}
export default ResponsiveStack;

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { useAppColors, spacing } from '../../theme';
import { AppBackButton, Text } from './index';
interface SimpleHeaderProps {
title: string;
onBack: () => void;
rightComponent?: React.ReactNode;
}
export const SimpleHeader: React.FC<SimpleHeaderProps> = ({ title, onBack, rightComponent }) => {
const colors = useAppColors();
return (
<View style={[styles.header, { backgroundColor: colors.background.paper }]}>
<AppBackButton onPress={onBack} style={styles.backButton} />
<Text style={[styles.title, { color: colors.text.primary }]} numberOfLines={1}>
{title}
</Text>
{rightComponent ? (
<View style={styles.rightContainer}>
{rightComponent}
</View>
) : (
<View style={styles.rightPlaceholder} />
)}
</View>
);
};
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
height: 52,
paddingHorizontal: spacing.xs,
},
title: {
flex: 1,
fontSize: 17,
fontWeight: '600',
marginLeft: spacing.xs,
},
rightContainer: {
flexDirection: 'row',
alignItems: 'center',
},
rightPlaceholder: {
width: 40,
},
backButton: {
marginLeft: 0,
},
});

View File

@@ -0,0 +1,200 @@
/**
* 步骤指示器组件 - 极简扁平设计
* 与下方表单风格统一
*/
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import { useAppColors, type AppColors } from '../../theme';
export interface Step {
id: number;
title: string;
}
export interface StepIndicatorProps {
steps: Step[];
currentStep: number;
containerStyle?: ViewStyle;
}
export const StepIndicator: React.FC<StepIndicatorProps> = ({
steps,
currentStep,
containerStyle,
}) => {
const colors = useAppColors();
const styles = createStyles(colors);
return (
<View style={[styles.container, containerStyle]}>
{steps.map((step, index) => {
const isCompleted = index < currentStep;
const isCurrent = index === currentStep;
const isPending = index > currentStep;
const isLast = index === steps.length - 1;
return (
<React.Fragment key={step.id}>
<View style={styles.stepWrapper}>
{/* 步骤编号/状态 */}
<View
style={[
styles.stepBadge,
isCompleted && styles.stepBadgeCompleted,
isCurrent && styles.stepBadgeCurrent,
isPending && styles.stepBadgePending,
]}
>
<Text
style={[
styles.stepBadgeText,
isCompleted && styles.stepBadgeTextCompleted,
isCurrent && styles.stepBadgeTextCurrent,
isPending && styles.stepBadgeTextPending,
]}
>
{isCompleted ? '✓' : index + 1}
</Text>
</View>
{/* 步骤标题 */}
<Text
style={[
styles.stepTitle,
isCompleted && styles.stepTitleCompleted,
isCurrent && styles.stepTitleCurrent,
isPending && styles.stepTitlePending,
]}
>
{step.title}
</Text>
</View>
{/* 连接线 */}
{!isLast && (
<View style={styles.connector}>
<View
style={[
styles.connectorLine,
isCompleted && styles.connectorLineCompleted,
]}
/>
</View>
)}
</React.Fragment>
);
})}
</View>
);
};
// 注册流程专用步骤指示器
export const RegisterStepIndicator: React.FC<{
currentStep: number;
containerStyle?: ViewStyle;
}> = ({ currentStep, containerStyle }) => {
const steps: Step[] = [
{ id: 0, title: '输入邮箱' },
{ id: 1, title: '验证码' },
{ id: 2, title: '设置信息' },
];
return (
<StepIndicator
steps={steps}
currentStep={currentStep}
containerStyle={containerStyle}
/>
);
};
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
width: '100%',
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
},
stepWrapper: {
alignItems: 'center',
minWidth: 80,
},
// 步骤徽章
stepBadge: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#F5F5F7',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 0,
},
stepBadgeCompleted: {
backgroundColor: '#FF6B35',
},
stepBadgeCurrent: {
backgroundColor: '#FFFFFF',
borderWidth: 2,
borderColor: '#FF6B35',
},
stepBadgePending: {
backgroundColor: '#F5F5F7',
},
stepBadgeText: {
fontSize: 13,
fontWeight: '600',
color: '#999',
},
stepBadgeTextCompleted: {
color: '#FFFFFF',
fontSize: 14,
},
stepBadgeTextCurrent: {
color: '#FF6B35',
},
stepBadgeTextPending: {
color: '#BBB',
},
// 步骤标题
stepTitle: {
marginTop: 8,
fontSize: 12,
color: '#999',
fontWeight: '500',
textAlign: 'center',
},
stepTitleCompleted: {
color: '#FF6B35',
fontWeight: '600',
},
stepTitleCurrent: {
color: '#333',
fontWeight: '600',
},
stepTitlePending: {
color: '#BBB',
},
// 连接线
connector: {
width: 60,
height: 28,
justifyContent: 'center',
alignItems: 'center',
},
connectorLine: {
width: '100%',
height: 2,
backgroundColor: '#E5E5E5',
borderRadius: 1,
},
connectorLineCompleted: {
backgroundColor: '#FF6B35',
},
});
}
export default StepIndicator;

View File

@@ -4,8 +4,8 @@
*/
import React from 'react';
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native';
import { useAppColors, fontSizes } from '../../theme';
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle, Platform } from 'react-native';
import { useAppColors, fontSizes, fontWeights } from '../../theme';
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
@@ -15,39 +15,46 @@ interface CustomTextProps extends Omit<TextProps, 'style'> {
color?: string;
numberOfLines?: number;
onPress?: () => void;
style?: TextStyle | TextStyle[];
style?: TextStyle | TextStyle[] | (TextStyle | undefined)[];
weight?: 'regular' | 'medium' | 'semibold' | 'bold';
}
const variantStyles: Record<TextVariant, object> = {
h1: {
fontSize: fontSizes['4xl'],
fontWeight: '700',
lineHeight: fontSizes['4xl'] * 1.4,
fontWeight: fontWeights.bold,
lineHeight: fontSizes['4xl'] * 1.3,
letterSpacing: -0.5,
},
h2: {
fontSize: fontSizes['3xl'],
fontWeight: '600',
lineHeight: fontSizes['3xl'] * 1.4,
fontWeight: fontWeights.semibold,
lineHeight: fontSizes['3xl'] * 1.3,
letterSpacing: -0.3,
},
h3: {
fontSize: fontSizes['2xl'],
fontWeight: '600',
fontWeight: fontWeights.semibold,
lineHeight: fontSizes['2xl'] * 1.3,
letterSpacing: -0.2,
},
body: {
fontSize: fontSizes.md,
fontWeight: '400',
lineHeight: fontSizes.md * 1.5,
fontWeight: fontWeights.regular,
lineHeight: fontSizes.md * 1.6,
letterSpacing: 0.1,
},
caption: {
fontSize: fontSizes.sm,
fontWeight: '400',
lineHeight: fontSizes.sm * 1.4,
fontWeight: fontWeights.regular,
lineHeight: fontSizes.sm * 1.5,
letterSpacing: 0.2,
},
label: {
fontSize: fontSizes.xs,
fontWeight: '500',
fontWeight: fontWeights.medium,
lineHeight: fontSizes.xs * 1.4,
letterSpacing: 0.3,
},
};
@@ -55,6 +62,7 @@ const Text: React.FC<CustomTextProps> = ({
children,
variant = 'body',
color,
weight,
numberOfLines,
onPress,
style,
@@ -64,6 +72,7 @@ const Text: React.FC<CustomTextProps> = ({
const textStyle = [
styles.base,
variantStyles[variant],
weight ? { fontWeight: fontWeights[weight] } : null,
color ? { color } : { color: colors.text.primary },
style,
];

View File

@@ -0,0 +1,266 @@
/**
* 验证码输入组件
* 参考设计4-6位数字验证码每个数字独立显示在一个方框中
* 支持自动聚焦、粘贴、删除回退
*/
import React, { useRef, useEffect, useCallback } from 'react';
import {
View,
TextInput,
StyleSheet,
Keyboard,
Platform,
Text,
ViewStyle,
useWindowDimensions,
} from 'react-native';
import { useAppColors, type AppColors } from '../../theme';
interface VerificationCodeInputProps {
value: string;
onChangeValue: (value: string) => void;
length?: number; // 验证码位数默认4位
autoFocus?: boolean;
onComplete?: (value: string) => void; // 输入完成回调
containerStyle?: ViewStyle;
disabled?: boolean;
}
const DEFAULT_CELL_WIDTH = 56;
const MIN_CELL_WIDTH = 32;
const CELL_HEIGHT = 64;
const CELL_MARGIN = 8;
const CONTAINER_PADDING = 24 * 2; // matches parent scrollContent paddingHorizontal
const EXTRA_MARGIN = 32; // additional safety margin
export const VerificationCodeInput: React.FC<VerificationCodeInputProps> = ({
value,
onChangeValue,
length = 4,
autoFocus = true,
onComplete,
containerStyle,
disabled = false,
}) => {
const colors = useAppColors();
const { width: screenWidth } = useWindowDimensions();
const availableWidth = screenWidth - CONTAINER_PADDING - EXTRA_MARGIN;
const maxCellWidth = Math.floor(availableWidth / length) - CELL_MARGIN;
const cellWidth = Math.max(MIN_CELL_WIDTH, Math.min(DEFAULT_CELL_WIDTH, maxCellWidth));
const styles = createStyles(colors, cellWidth);
// 为每个数字创建一个 ref
const inputRefs = useRef<(TextInput | null)[]>(Array(length).fill(null));
// 将 value 分割为单个字符数组
const chars = value.split('').slice(0, length);
// 填充空位
while (chars.length < length) {
chars.push('');
}
// 获取当前聚焦的位置
const getFocusedIndex = useCallback(() => {
return Math.min(value.length, length - 1);
}, [value.length, length]);
// 聚焦到指定位置
const focusInput = useCallback((index: number) => {
if (index >= 0 && index < length) {
inputRefs.current[index]?.focus();
}
}, [length]);
// 自动聚焦第一个输入框
useEffect(() => {
if (autoFocus && !disabled) {
const timer = setTimeout(() => {
focusInput(0);
}, 100);
return () => clearTimeout(timer);
}
}, [autoFocus, disabled, focusInput]);
// 当值改变时,自动聚焦到下一个输入框
useEffect(() => {
if (value.length < length) {
focusInput(value.length);
}
// 当输入完成时触发回调
if (value.length === length && onComplete) {
onComplete(value);
}
}, [value, length, onComplete, focusInput]);
// 处理文本变化
const handleChangeText = (text: string, index: number) => {
if (disabled) return;
// 只允许数字
const numericText = text.replace(/[^0-9]/g, '');
if (numericText.length === 0) {
// 删除操作
if (value.length > index) {
// 删除当前位置的字符
const newValue = value.slice(0, index) + value.slice(index + 1);
onChangeValue(newValue);
// 聚焦到前一个输入框
if (index > 0) {
focusInput(index - 1);
}
}
} else if (numericText.length === 1) {
// 单个字符输入
let newValue: string;
if (index < value.length) {
// 替换已有字符
newValue = value.slice(0, index) + numericText + value.slice(index + 1);
} else {
// 追加新字符
newValue = value + numericText;
}
onChangeValue(newValue.slice(0, length));
// 自动聚焦到下一个
if (index < length - 1) {
focusInput(index + 1);
}
} else if (numericText.length > 1) {
// 粘贴操作:尝试将粘贴的内容作为完整验证码
const newValue = numericText.slice(0, length);
onChangeValue(newValue);
// 聚焦到最后一个或下一个
const focusIndex = Math.min(newValue.length, length - 1);
focusInput(focusIndex);
}
};
// 处理按键事件(用于删除键)
const handleKeyPress = (e: any, index: number) => {
if (disabled) return;
if (e.nativeEvent.key === 'Backspace') {
if (value.length > index) {
// 删除当前位置的字符
const newValue = value.slice(0, index) + value.slice(index + 1);
onChangeValue(newValue);
} else if (index > 0 && value.length === index) {
// 当前位置没有字符,删除前一个
const newValue = value.slice(0, -1);
onChangeValue(newValue);
focusInput(index - 1);
}
}
};
// 聚焦时全选文本
const handleFocus = (index: number) => {
if (disabled) return;
inputRefs.current[index]?.setNativeProps({
selection: { start: 0, end: 1 },
});
};
return (
<View style={[styles.container, containerStyle]}>
<View style={styles.cellsContainer}>
{chars.map((char, index) => {
const isFocused = value.length === index;
const isFilled = char !== '';
const isLastFilled = index === value.length - 1 && isFilled;
return (
<View
key={index}
style={[
styles.cell,
isFocused && styles.cellFocused,
isFilled && styles.cellFilled,
disabled && styles.cellDisabled,
]}
>
<TextInput
ref={(ref) => {
inputRefs.current[index] = ref;
}}
style={styles.cellInput}
value={char}
onChangeText={(text) => handleChangeText(text, index)}
onKeyPress={(e) => handleKeyPress(e, index)}
onFocus={() => handleFocus(index)}
keyboardType="number-pad"
maxLength={1}
selectTextOnFocus
editable={!disabled}
caretHidden={true}
textContentType="oneTimeCode" // iOS 自动填充支持
/>
{/* 光标指示器 */}
{isFocused && !isFilled && !disabled && (
<View style={styles.cursor} />
)}
</View>
);
})}
</View>
</View>
);
};
function createStyles(colors: AppColors, cellWidth: number) {
return StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
},
cellsContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
cell: {
width: cellWidth,
height: CELL_HEIGHT,
borderRadius: 12,
borderWidth: 1.5,
borderColor: colors.divider,
backgroundColor: colors.background.paper,
marginHorizontal: CELL_MARGIN / 2,
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
},
cellFocused: {
borderColor: colors.primary.main,
backgroundColor: `${colors.primary.main}10`,
},
cellFilled: {
borderColor: colors.primary.main,
},
cellDisabled: {
opacity: 0.5,
backgroundColor: colors.background.disabled,
},
cellInput: {
width: '100%',
height: '100%',
textAlign: 'center',
fontSize: cellWidth >= 48 ? 28 : cellWidth >= 40 ? 24 : 20,
fontWeight: '600',
color: colors.text.primary,
padding: 0,
},
cursor: {
position: 'absolute',
width: 2,
height: 28,
backgroundColor: colors.primary.main,
borderRadius: 1,
},
});
}
export default VerificationCodeInput;

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