2 Commits
dev ... master

Author SHA1 Message Date
2014be28c4 Merge pull request 'dev' (#5) from dev into master
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 39s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
Reviewed-on: #5
2026-03-25 20:54:09 +08:00
cf466a3959 Merge pull request 'dev' (#3) from dev into master
All checks were successful
Frontend CI / ota-android (push) Successful in 13m19s
Frontend CI / build-android-apk (push) Successful in 56m46s
Frontend CI / build-and-push-web (push) Successful in 5m46s
Reviewed-on: #3
2026-03-19 01:00:14 +08:00
451 changed files with 26087 additions and 57752 deletions

View File

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

View File

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

View File

@@ -21,28 +21,25 @@ on:
env:
REGISTRY: code.littlelan.cn
IMAGE_NAME: carrot_bbs/frontend-web
OTA_PLATFORM: android
OTA_PUBLISH_URL: https://updates.littlelan.cn/admin/publish
OTA_MANIFEST_URL: https://updates.littlelan.cn/api/manifest
jobs:
ota:
ota-android:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_ota == 'true')
permissions:
contents: read
strategy:
matrix:
platform: [android, ios]
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '25.6.1'
cache: 'npm'
- name: Install dependencies
run: npm ci
@@ -50,48 +47,48 @@ jobs:
- name: Resolve runtime version
id: runtime
run: |
RUNTIME_VERSION="$(node -p "require('./app.config.js').runtimeVersion")"
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
- name: Export update bundle
- name: Export Android update bundle
run: |
rm -rf dist-${{ matrix.platform }} dist-${{ matrix.platform }}-update.zip
npx expo export --platform ${{ matrix.platform }} --output-dir dist-${{ matrix.platform }}
npx expo config --type public --json > dist-${{ matrix.platform }}/expoConfig.json
rm -rf dist-android dist-android-update.zip
npx expo export --platform android --output-dir dist-android
npx expo config --type public --json > dist-android/expoConfig.json
- name: Archive update bundle
- name: Archive Android update bundle
run: |
python -c "
import os, zipfile
p = '${{ matrix.platform }}'
dist = f'dist-{p}'
with zipfile.ZipFile(f'dist-{p}-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
for root, _, files in os.walk(dist):
python - <<'PY'
import os
import zipfile
with zipfile.ZipFile('dist-android-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
for root, _, files in os.walk('dist-android'):
for name in files:
src = os.path.join(root, name)
arc = os.path.relpath(src, dist)
arc = os.path.relpath(src, 'dist-android')
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=${{ matrix.platform }}" \
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${OTA_PLATFORM}" \
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
-H "Content-Type: application/zip" \
--data-binary @"dist-${{ matrix.platform }}-update.zip"
--data-binary @"dist-android-update.zip"
- name: Verify OTA manifest
run: |
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
-H "expo-platform: ${{ matrix.platform }}" \
-H "expo-platform: ${OTA_PLATFORM}" \
-H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
-H "expo-protocol-version: 1" \
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\"id\":.*\"extra\":\{.*\}\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
LOCAL="$(python -c "import json; m=json.load(open('dist-${{ matrix.platform }}/metadata.json')); print(m['fileMetadata']['${{ matrix.platform }}']['bundle'].split('/')[-1])")"
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\\\"id\\\":.*\\\"extra\\\":\\{.*\\}\\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
LOCAL="$(python -c "import json; m=json.load(open('dist-android/metadata.json')); print(m['fileMetadata']['android']['bundle'].split('/')[-1])")"
echo "Remote bundle: ${REMOTE}"
echo "Local bundle: ${LOCAL}"
test "${REMOTE}" = "${LOCAL}"
@@ -101,17 +98,18 @@ jobs:
container:
image: reactnativecommunity/react-native-android:latest
env:
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=false -Dorg.gradle.workers.max=1"
NODE_OPTIONS: "--max-old-space-size=2048"
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=4 -Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
_JAVA_OPTIONS: "-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
NODE_OPTIONS: "--max-old-space-size=8192"
NODE_ENV: "production"
NDK_NUM_JOBS: "4"
CMAKE_BUILD_PARALLEL_LEVEL: "4"
GRADLE_USER_HOME: /root/.gradle
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Java
uses: actions/setup-java@v4
@@ -123,127 +121,157 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '25.6.1'
cache: 'npm'
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.android/build-cache
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Cache node_modules
uses: actions/cache@v4
id: cache-node-modules
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-modules-
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- name: Generate Android native project
run: npx expo prebuild --platform android
- name: Configure Gradle with signing
- name: Configure Gradle with Aliyun Maven mirror
run: |
cd android
# Decode Android signing keystore
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > app/withyou-release-key.keystore
# Append signing properties to gradle.properties (secrets appended after prebuild for better cache hit rate).
# IMPORTANT 1: each echo line writes a column-0 string — gradle.properties keys cannot have
# leading whitespace, otherwise hasProperty() returns false and release signing breaks
# (see app/build.gradle signingConfigs.release).
# IMPORTANT 2: prebuild emits gradle.properties WITHOUT a trailing newline (last line is
# `expo.inlineModules.watchedDirectories=[]`). A naive `>>` append concatenates the first
# new key onto that last line, corrupting MYAPP_UPLOAD_STORE_FILE. We unconditionally
# prepend a newline before the appended block to guarantee a clean line break.
# NB: this block runs with `set -e`; if any echo fails the step aborts.
{
printf '\n'
echo "MYAPP_UPLOAD_STORE_FILE=withyou-release-key.keystore"
echo "MYAPP_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}"
echo "MYAPP_UPLOAD_KEY_ALIAS=withyou-key"
echo "MYAPP_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}"
} >> gradle.properties
# CI build tuning (low-memory container): force single-worker, reduce JVM heap,
# plus restore properties that prebuild does NOT generate but the build needs.
# printf '\n' below is defensive — even though the signing block above already
# ensured a trailing newline, both blocks should be independently safe.
{
printf '\n'
echo "org.gradle.daemon=false"
echo "org.gradle.parallel=false"
echo "org.gradle.configureondemand=false"
echo "org.gradle.workers.max=1"
echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError"
echo "kotlin.daemon.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m"
echo "ndkVersion=27.1.12297006"
echo "systemProp.org.gradle.internal.http.connectionTimeout=30000"
echo "systemProp.org.gradle.internal.http.socketTimeout=30000"
# ExpoAutolinkingPlugin requires this; without it Gradle calls
# `node ... mirror-kotlin-inline-modules --watched-directories-serialized ''`
# and node JSON.parse('') throws SyntaxError, exiting 1.
# See ExpoAutolinkingPlugin.kt:51 (findProperty fallback to emptyList)
# and ExpoAutolinkingPlugin.kt:67 (passes value to node as-is).
echo "expo.inlineModules.watchedDirectories=[]"
} >> gradle.properties
# Verify signing config in app/build.gradle
echo "=== app/build.gradle signing section ==="
grep -A 20 'signingConfigs' app/build.gradle || echo "signingConfigs not found"
# Diagnostic: dump full gradle.properties + verify all 4 signing keys are at column 0.
# Each grep -c MUST print 1; 0 means the key was concatenated onto a previous line
# (typically because prebuild's gradle.properties had no trailing newline — see fix above).
echo "=== gradle.properties (final) ==="
cat gradle.properties
echo "=== signing keys column-0 check (each must be 1) ==="
for k in MYAPP_UPLOAD_STORE_FILE MYAPP_UPLOAD_STORE_PASSWORD MYAPP_UPLOAD_KEY_ALIAS MYAPP_UPLOAD_KEY_PASSWORD; do
count=$(grep -c "^${k}=" gradle.properties || true)
echo "${k}=${count}"
if [ "${count}" != "1" ]; then
echo "FATAL: ${k} not found at column 0 — release signing will fail."
exit 1
fi
done
# Update settings.gradle with Aliyun Maven mirror
cat > settings.gradle << 'SETTINGS_EOF'
pluginManagement {
repositories {
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 {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)
def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
}
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
}
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
}
expoAutolinking.useExpoModules()
rootProject.name = '萝卜社区'
expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(expoAutolinking.reactNativeGradlePlugin)
SETTINGS_EOF
# Update build.gradle with Aliyun Maven mirror
cat > build.gradle << 'BUILD_EOF'
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
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')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
}
}
allprojects {
repositories {
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' }
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"
BUILD_EOF
# Update gradle.properties
cat > gradle.properties << 'PROPS_EOF'
org.gradle.daemon=false
org.gradle.parallel=true
org.gradle.configureondemand=true
org.gradle.workers.max=4
org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.enableJetifier=false
android.useAndroidX=true
hermesEnabled=true
reactNativeArchitectures=arm64-v8a
newArchEnabled=true
edgeToEdgeEnabled=true
expo.gif.enabled=true
expo.webp.enabled=true
expo.webp.animated=false
expo.useLegacyPackaging=false
PROPS_EOF
- name: Build Android release APK (arm64 only)
run: |
cd android
chmod +x gradlew
taskset -c 0-7 ./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=1 -Dkotlin.daemon.jvm.options="-Xmx2g,XX:MaxMetaspaceSize=1g"
./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=4 --parallel
- name: Upload APK artifact
uses: actions/upload-artifact@v3
with:
name: withyou-android-release-apk
name: carrot-bbs-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.config.js').runtimeVersion")"
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:
@@ -286,6 +314,8 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
provenance: false
cache-from: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache
cache-to: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache,mode=max
- name: Show image tags
run: |

10
.idea/.gitignore generated vendored Normal file
View File

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

9
.idea/frontend.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?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 Normal file
View File

@@ -0,0 +1,448 @@
<?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 Normal file
View File

@@ -0,0 +1,7 @@
<?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 Normal file
View File

@@ -0,0 +1,8 @@
<?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 Normal file
View File

@@ -0,0 +1,6 @@
<?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
View File

@@ -1,376 +0,0 @@
{
"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

@@ -0,0 +1,231 @@
# 架构修复方案文档
## 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

@@ -1,4 +1,4 @@
FROM node:25-alpine AS builder
FROM node:20-alpine AS builder
WORKDIR /app
@@ -6,11 +6,10 @@ COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN node /app/node_modules/.bin/expo export --platform web --output-dir dist-web
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,27 +1,10 @@
const appJson = require('./app.json');
const { execSync } = require('child_process');
const isDevVariant = process.env.APP_VARIANT === 'dev';
const releaseApiBaseUrl = 'https://withyou.littlelan.cn/api/v1';
const releaseApiBaseUrl = 'https://bbs.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 getCommitCount() {
try {
return execSync('git rev-list --count HEAD', { encoding: 'utf-8' }).trim();
} catch {
return '1';
}
}
// 在 commit count 上加偏移,保证 versionCode / buildNumber / runtimeVersion
// 始终大于历史最大值(之前的最后一个版本是 5547避免被系统/Play Store
// 误判为降级。后续即使 commit count 重置或换仓库,偏移也能保证单调递增。
const BUILD_NUMBER_OFFSET = 100000;
const commitCount = getCommitCount();
const buildNumber = String(parseInt(commitCount, 10) + BUILD_NUMBER_OFFSET);
function toManifestUrl(baseUrl, portOverride) {
const parsed = new URL(baseUrl);
if (portOverride != null) {
@@ -40,73 +23,27 @@ 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,
// runtimeVersion 用语义版本expo.version同一版本号的所有 build 共享 OTA 通道,
// 改原生代码/配置后应升级 version 来切换通道。
// versionCode / buildNumber 仍用 commit count + 偏移,保证应用商店版本号单调递增。
runtimeVersion: appJson.expo.version,
runtimeVersion: {
policy: 'appVersion',
},
updates: {
...(expo.updates || {}),
url: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
checkAutomatically: 'ON_LOAD',
fallbackToCacheTimeout: 0,
},
ios: {
...expo.ios,
buildNumber: buildNumber,
},
ios: expo.ios,
android: {
...expo.android,
package: 'cn.qczlit.withyou',
versionCode: parseInt(buildNumber, 10),
package: 'skin.carrot.bbs',
},
// 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: [
'livekit-client',
'@livekit',
],
},
},
},
}),
};

103
app.json
View File

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

View File

@@ -1,67 +1,28 @@
import { useMemo, useCallback } from 'react';
import { Platform, Pressable, useWindowDimensions } from 'react-native';
import { Tabs, usePathname, useRouter } from 'expo-router';
import { useMemo } 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 type { PressableProps } from 'react-native';
import { useAppColors, shadows } from '../../../src/theme';
import { BREAKPOINTS } from '../../../src/hooks';
import { useHomeTabBarVisibilityStore, useTotalUnreadCount, useHomeTabPressStore } from '../../../src/stores';
import { hrefHome } from '../../../src/navigation/hrefs';
import { BREAKPOINTS } from '../../../src/hooks/useResponsive';
import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores';
const TAB_BAR_HEIGHT = 56;
const TAB_BAR_FLOATING_MARGIN = 12;
const TAB_BAR_MARGIN = 20;
const TabBarButton = ({ children, style, ...rest }: PressableProps) => (
<Pressable
{...rest}
style={style}
android_ripple={null}
>
{children}
</Pressable>
);
export default function TabsLayout() {
const colors = useAppColors();
const { width } = useWindowDimensions();
const insets = useSafeAreaInsets();
const pathname = usePathname();
const router = useRouter();
const unreadCount = useTotalUnreadCount();
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
const triggerHomeTabPress = useHomeTabPressStore((s) => s.triggerPress);
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
const handleHomeTabPress = useCallback(
(e: { preventDefault: () => void }) => {
if (!isHomeStackRoute) return;
e.preventDefault();
if (pathname === '/home') {
triggerHomeTabPress();
} else {
router.navigate(hrefHome());
}
},
[isHomeStackRoute, pathname, triggerHomeTabPress, router]
);
const handleAppsTabPress = useCallback(() => {
if (pathname.startsWith('/apps/')) {
router.replace('/apps');
}
}, [pathname, router]);
const handleProfileTabPress = useCallback(() => {
if (pathname.startsWith('/profile/')) {
router.replace('/profile');
}
}, [pathname, router]);
const tabBarStyle = useMemo(() => {
if (hideTabBar) {
return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
@@ -104,7 +65,6 @@ export default function TabsLayout() {
marginHorizontal: 2,
paddingVertical: 1,
},
tabBarButton: (props) => <TabBarButton {...props} />,
tabBarShowLabel: true,
tabBarLabelStyle: { fontSize: 11, fontWeight: '600', marginTop: -1 },
}}
@@ -121,9 +81,6 @@ export default function TabsLayout() {
/>
),
}}
listeners={{
tabPress: handleHomeTabPress,
}}
/>
<Tabs.Screen
name="apps"
@@ -137,9 +94,6 @@ export default function TabsLayout() {
/>
),
}}
listeners={{
tabPress: handleAppsTabPress,
}}
/>
<Tabs.Screen
name="messages"
@@ -167,9 +121,6 @@ export default function TabsLayout() {
/>
),
}}
listeners={{
tabPress: handleProfileTabPress,
}}
/>
</Tabs>
);

View File

@@ -1,11 +0,0 @@
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

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

View File

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

View File

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

View File

@@ -1,27 +1,41 @@
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={{
headerShown: false,
...headerOptions,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
}}
>
<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="help" />
<Stack.Screen name="verification" />
<Stack.Screen name="data-storage" />
<Stack.Screen name="privacy-settings" />
<Stack.Screen name="account-deletion" />
<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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import '../src/polyfills';
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { AppState, AppStateStatus, Platform, View, ActivityIndicator } from 'react-native';
import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
@@ -7,27 +6,21 @@ 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 '@/services/notification';
import { EventSubscriber } from '../src/infrastructure/EventSubscriber';
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
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 '@/services/ui';
import { checkForAPKUpdate } from '@/services/platform';
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
import { jpushService } from '@/services/notification/jpushService';
import { callKeepService } from '@/services/callkeep';
import { callStore } from '@/stores/call';
import * as hrefs from '../src/navigation/hrefs';
import { installAlertOverride } from '../src/services/alertOverride';
import { useAuthStore } from '../src/stores';
registerNotificationPresentationHandler();
@@ -55,34 +48,6 @@ 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);
}
@@ -101,19 +66,43 @@ function SystemChrome() {
return null;
}
function SessionGate({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
const colors = useAppColors();
useEffect(() => {
fetchCurrentUser().finally(() => setReady(true));
}, [fetchCurrentUser]);
if (!ready) {
return (
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.default,
}}
>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
);
}
return <>{children}</>;
}
function NotificationBootstrap() {
const appState = useRef<AppStateStatus>(AppState.currentState);
const permissionRequested = useRef(false);
const router = useRouter();
const notificationResponseListener = useRef<Notifications.EventSubscription | null>(null);
useEffect(() => {
const initNotifications = async () => {
const { loadNotificationPreferences } = await import('@/services/notification');
const { loadNotificationPreferences } = await import('../src/services/notificationPreferences');
await loadNotificationPreferences();
const { systemNotificationService } = await import('@/services/notification');
const { systemNotificationService } = await import('../src/services/systemNotificationService');
await systemNotificationService.initialize();
const { initBackgroundService } = await import('@/services/background');
// 默认静默模式,不会注册后台任务,不会触发自启动
const { initBackgroundService } = await import('../src/services/backgroundService');
await initBackgroundService();
const subscription = AppState.addEventListener('change', (nextAppState) => {
@@ -123,126 +112,26 @@ function NotificationBootstrap() {
appState.current = nextAppState;
});
// 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;
// 点击任意一条通知即清除通知栏所有通知(与 QQ 一致:点一条清全部)
// JPush 的 notificationOpened 在通知被点击时触发,此时清除系统通知栏中
// 本应用的所有通知,避免用户需要逐条点击消除。
systemNotificationService.clearAllNotifications().catch((error) => {
console.warn('[NotificationBootstrap] clearAllNotifications failed:', error);
});
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';
// 使用 navigate 而非 push避免
// 1. 在已有聊天页上创建重复实例(用户需要退出两次)
// 2. 从帖子等页面打开聊天通知后返回到帖子(而非消息列表)
router.navigate(
hrefs.hrefChat({
conversationId,
userId: isGroup ? undefined : senderId,
isGroupChat: isGroup,
groupId: isGroup ? groupId : undefined,
groupName: isGroup ? groupName : undefined,
groupAvatar: isGroup ? groupAvatar : undefined,
})
);
} else {
router.navigate(hrefs.hrefNotifications());
}
} else {
router.navigate(hrefs.hrefNotifications());
notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener(
(response) => {
void response.notification.request.content.data;
}
});
);
const notificationReceivedSubscription = Notifications.addNotificationReceivedListener(
(notification) => {
void notification;
}
);
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;
}
function CallKeepBootstrap() {
useEffect(() => {
if (Platform.OS === 'web') return;
callKeepService.initialize({
onSystemAnswered: () => {
callStore.getState().handleSystemAnswer();
},
onSystemEnded: () => {
callStore.getState().endCall('ended');
},
onSystemMuted: (muted: boolean) => {
callStore.getState().handleSystemMuted(muted);
},
onIncomingCallFromPush: (session) => {
callStore.getState().handleIncomingFromPush(session);
},
onOutgoingStarted: () => {
// Outgoing call accepted by system — no action needed,
// the WS call_invited / call_accepted flow handles everything
},
});
return () => {
callKeepService.dispose();
};
}, []);
return null;
@@ -250,23 +139,45 @@ function CallKeepBootstrap() {
function ThemedStack() {
const router = useRouter();
const colors = useAppColors();
const resolved = useResolvedColorScheme();
return (
<>
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
<SystemChrome />
<EventSubscriber />
<NotificationBootstrap />
<APKUpdateBootstrap />
<CallKeepBootstrap />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(app)" options={{ headerShown: false }} />
<Stack.Screen name="privacy" options={{ headerShown: false }} />
<Stack.Screen name="terms" options={{ headerShown: false }} />
</Stack>
<SessionGate>
<NotificationBootstrap />
<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="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()} />,
}}
/>
<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()} />,
}}
/>
</Stack>
</SessionGate>
</>
);
}
@@ -277,16 +188,6 @@ 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>
@@ -296,9 +197,6 @@ export default function RootLayout() {
<ThemedStack />
<AppPromptBar />
<AppDialogHost />
<IncomingCallModal />
<CallScreen />
<FloatingCallWindow />
</QueryClientProvider>
</ThemedProviders>
</SafeAreaProvider>

View File

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

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
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

@@ -1,46 +0,0 @@
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}
/>
);
}

View File

@@ -1,5 +0,0 @@
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: 20 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 385 KiB

After

Width:  |  Height:  |  Size: 895 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 385 KiB

After

Width:  |  Height:  |  Size: 895 KiB

View File

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

View File

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

View File

@@ -1,46 +0,0 @@
/**
* Jest 配置
*
* 仅对消息状态核心逻辑store 幂等性 / 实时事件管道 / 未读计数原子性)做单测,
* 不覆盖 RN / Expo UI。所有平台依赖@/services/core、@/database、@/core/events、
* @/stores/auth、@/services/message在测试中通过 moduleNameMapper 映射到 mock。
*/
const { pathsToModuleNameMapper } = require('ts-jest');
const tsconfig = require('./tsconfig.json');
const tsconfigBase = require('expo/tsconfig.base.json');
// 合并 base + 项目 paths
const paths = {
...(tsconfigBase.compilerOptions.paths || {}),
...(tsconfig.compilerOptions.paths || {}),
};
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.test.ts'],
moduleNameMapper: {
// 平台依赖统一映射到 mock必须放在 @/* 通配之前,否则会被通配吞掉)
'^@/services/core$': '<rootDir>/src/stores/message/__tests__/mocks/coreMock.ts',
'^@/services/message$': '<rootDir>/src/stores/message/__tests__/mocks/messageServiceMock.ts',
'^@/database$': '<rootDir>/src/stores/message/__tests__/mocks/databaseMock.ts',
'^@/core/events/EventBus$': '<rootDir>/src/stores/message/__tests__/mocks/eventBusMock.ts',
'^@/stores/auth/authStore$': '<rootDir>/src/stores/message/__tests__/mocks/authStoreMock.ts',
'^@react-native-async-storage/async-storage$':
'<rootDir>/src/stores/message/__tests__/mocks/asyncStorageMock.ts',
// @/* 路径别名 → 真实 src通配兜底放最后
...pathsToModuleNameMapper(paths, { prefix: '<rootDir>/' }),
},
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: '<rootDir>/src/stores/message/__tests__/tsconfig.json',
isolatedModules: true,
},
],
},
clearMocks: true,
};

View File

@@ -1,72 +1,11 @@
/** @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');
// 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 = {
};
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.
@@ -79,4 +18,4 @@ config.server.enhanceMiddleware = (middleware) => {
};
};
module.exports = config;
module.exports = config;

View File

@@ -1,19 +0,0 @@
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;
}

7448
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,4 +0,0 @@
plugins {
kotlin("jvm") version "2.1.20" apply false
id("java-gradle-plugin")
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,394 @@
# 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

@@ -1 +0,0 @@
{"agcgw":{"url":"connect-drcn.dbankcloud.cn","backurl":"connect-drcn.hispace.hicloud.com","websocketurl":"connect-ws-drcn.hispace.dbankcloud.cn","websocketbackurl":"connect-ws-drcn.hispace.dbankcloud.com"},"agcgw_all":{"SG":"connect-dra.dbankcloud.cn","SG_back":"connect-dra.hispace.hicloud.com","CN":"connect-drcn.dbankcloud.cn","CN_back":"connect-drcn.hispace.hicloud.com","RU":"connect-drru.hispace.dbankcloud.ru","RU_back":"connect-drru.hispace.dbankcloud.cn","DE":"connect-dre.dbankcloud.cn","DE_back":"connect-dre.hispace.hicloud.com"},"websocketgw_all":{"SG":"connect-ws-dra.hispace.dbankcloud.cn","SG_back":"connect-ws-dra.hispace.dbankcloud.com","CN":"connect-ws-drcn.hispace.dbankcloud.cn","CN_back":"connect-ws-drcn.hispace.dbankcloud.com","RU":"connect-ws-drru.hispace.dbankcloud.ru","RU_back":"connect-ws-drru.hispace.dbankcloud.cn","DE":"connect-ws-dre.hispace.dbankcloud.cn","DE_back":"connect-ws-dre.hispace.dbankcloud.com"},"client":{"cp_id":"30086000618737115","product_id":"101653523864112478","client_id":"1949443370896080512","client_secret":"CA50EF8C69075FAEFECF2D9F0F3ED101DC3264E0BFE1CC5E5B0272E7FF8215A1","project_id":"101653523864112478","app_id":"117704941","api_key":"DgEDAIkxmMyXBT6vYYW8ZWWXBMJweDbdmmKLYo5MvgH2lV8jPSIZt5WVpjd0/nZ0dk9gAwHf+dWIySILr3QQd7W80H8NNvuCUAQPig==","package_name":"cn.qczlit.withyou"},"oauth_client":{"client_id":"117704941","client_type":1},"app_info":{"app_id":"117704941","package_name":"cn.qczlit.withyou"},"service":{"analytics":{"collector_url":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn","collector_url_cn":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn","collector_url_de":"datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn","collector_url_ru":"datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com","collector_url_sg":"datacollector-dra.dt.hicloud.com,datacollector-dra.dt.dbankcloud.cn","resource_id":"p1","channel_id":""},"ml":{"mlservice_url":"ml-api-drcn.ai.dbankcloud.com,ml-api-drcn.ai.dbankcloud.cn"},"cloudstorage":{"storage_url":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg":"https://ops-dra.agcstorage.link","storage_url_de":"https://ops-dre.agcstorage.link","storage_url_cn":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru_back":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg_back":"https://agc-storage-dra.cloud.huawei.asia","storage_url_de_back":"https://agc-storage-dre.cloud.huawei.eu","storage_url_cn_back":"https://agc-storage-drcn.cloud.huawei.com.cn"},"search":{"url":"https://search-drcn.cloud.huawei.com"},"edukit":{"edu_url":"edukit.cloud.huawei.com.cn","dh_url":"edukit.cloud.huawei.com.cn"}},"region":"CN","configuration_version":"3.0","appInfos":[{"package_name":"cn.qczlit.withyou","client":{"app_id":"117704941"},"oauth_client":{"client_id":"117704941","client_type":1},"app_info":{"app_id":"117704941","package_name":"cn.qczlit.withyou"}}]}

View File

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

View File

@@ -1,467 +0,0 @@
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;

View File

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

View File

@@ -1,295 +0,0 @@
const {
withAppBuildGradle,
withProjectBuildGradle,
withSettingsGradle,
withDangerousMod,
withAndroidManifest,
} = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
const withHuaweiPush = (config, options = {}) => {
const { jpushVersion = '6.1.0' } = options;
config = withSettingsGradle(config, (config) => {
const contents = config.modResults.contents;
// Add Huawei Maven repo to pluginManagement.repositories for buildscript resolution.
// NOTE: We intentionally do NOT add `plugins { id 'com.huawei.agconnect' ... }` here.
// The Huawei agcp plugin transitively depends on agconnect-apms-plugin:1.6.2.300,
// which depends on AGP 4.0.1. That old AGP references BuildCompletionListener (removed
// from Gradle 8.x+), causing NoClassDefFoundError at build time. Instead, the plugin
// is applied via buildscript classpath in build.gradle with the apms-plugin excluded.
if (!contents.includes('developer.huawei.com/repo')) {
const repoLine = ` maven { url 'https://developer.huawei.com/repo/' }`;
const hasPluginManagement = contents.includes('pluginManagement');
const hasRepositoriesInPM = /pluginManagement\s*\{[\s\S]*?repositories\s*\{/.test(contents);
if (hasRepositoriesInPM) {
config.modResults.contents = contents.replace(
/(pluginManagement\s*\{[\s\S]*?repositories\s*\{)/,
`$1\n ${repoLine}`
);
} else if (hasPluginManagement) {
config.modResults.contents = contents.replace(
/(pluginManagement\s*\{)/,
`$1\n repositories {\n gradlePluginPortal()\n google()\n mavenCentral()\n ${repoLine}\n }`
);
} else {
config.modResults.contents =
`pluginManagement {\n repositories {\n ${repoLine}\n }\n}\n\n` +
config.modResults.contents;
}
}
// Ensure Huawei Maven repo in dependencyResolutionManagement (Gradle 7+).
// CI build paths require this block (Gradle dependencyResolutionManagement
// takes precedence over allprojects.repositories when configured).
// Create the block if it doesn't exist, otherwise inject Huawei into it.
if (!config.modResults.contents.match(/dependencyResolutionManagement[\s\S]*developer\.huawei\.com\/repo/)) {
const depMgmtPattern = /dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{/;
if (depMgmtPattern.test(config.modResults.contents)) {
// Block exists — append Huawei to its repositories
config.modResults.contents = config.modResults.contents.replace(
depMgmtPattern,
`$&\n maven { url 'https://developer.huawei.com/repo/' }`
);
} else {
// Block doesn't exist — create it after the `plugins { ... }` block
// (or after pluginManagement if no plugins block) so it sits where
// Gradle expects it.
const block = `\ndependencyResolutionManagement {\n repositories {\n maven { url 'https://developer.huawei.com/repo/' }\n google()\n mavenCentral()\n maven { url 'https://www.jitpack.io' }\n }\n}\n`;
const pluginsBlockEnd = /(\nplugins\s*\{[\s\S]*?\n\})/;
if (pluginsBlockEnd.test(config.modResults.contents)) {
config.modResults.contents = config.modResults.contents.replace(
pluginsBlockEnd,
`$1\n${block}`
);
} else {
// Fallback: append to end
config.modResults.contents = config.modResults.contents + block;
}
}
}
return config;
});
config = withProjectBuildGradle(config, (config) => {
const contents = config.modResults.contents;
if (!contents.includes('developer.huawei.com/repo')) {
config.modResults.contents = contents.replace(
/(repositories\s*\{)/g,
(match) => {
return match + `\n maven { url 'https://developer.huawei.com/repo/' }`;
}
);
}
return config;
});
config = withProjectBuildGradle(config, (config) => {
const contents = config.modResults.contents;
// Add Huawei AGConnect classpath with apms-plugin excluded.
// The apms-plugin transitively depends on AGP 4.0.1 which is incompatible
// with Gradle 8.14+ (references removed BuildCompletionListener class).
if (!contents.includes('com.huawei.agconnect:agcp')) {
if (contents.includes('buildscript')) {
const depsPattern = /(buildscript\s*\{[\s\S]*?dependencies\s*\{)/;
if (depsPattern.test(contents)) {
config.modResults.contents = contents.replace(
depsPattern,
`$1\n classpath('com.huawei.agconnect:agcp:1.9.1.301') {\n exclude group: 'com.huawei.agconnect', module: 'agconnect-apms-plugin'\n }`
);
}
}
}
// Set explicit AGP version (needed by Huawei plugin validation)
if (!contents.includes('com.android.tools.build:gradle:8.12.0')) {
config.modResults.contents = config.modResults.contents.replace(
/classpath\(['"]com\.android\.tools\.build:gradle['"]\)/,
"classpath('com.android.tools.build:gradle:8.12.0')"
);
}
// Google Services plugin classpath — required for Firebase init via google-services.json.
if (!contents.includes('com.google.gms:google-services')) {
const depsPattern = /(buildscript\s*\{[\s\S]*?dependencies\s*\{[\s\S]*?classpath\(['"]org\.jetbrains\.kotlin:kotlin-gradle-plugin['"]\))/;
if (depsPattern.test(config.modResults.contents)) {
config.modResults.contents = config.modResults.contents.replace(
depsPattern,
`$1\n classpath('com.google.gms:google-services:4.4.4')`
);
}
}
if (!config.modResults.contents.includes('developer.huawei.com/repo')) {
const bsRepoPattern = /(buildscript\s*\{[\s\S]*?repositories\s*\{)/;
if (bsRepoPattern.test(config.modResults.contents)) {
config.modResults.contents = config.modResults.contents.replace(
bsRepoPattern,
`$1\n maven { url 'https://developer.huawei.com/repo/' }`
);
}
}
return config;
});
config = withAppBuildGradle(config, (config) => {
const contents = config.modResults.contents;
// Apply Google Services plugin (Firebase) before the Huawei plugin.
// The googleServicesFile in app.json only sets up the file copy + plugin
// when run through `expo prebuild`, but it does not coexist with this
// Huawei plugin reliably, so we apply it explicitly here.
if (!contents.includes('com.google.gms.google-services')) {
const reactApply = /apply plugin:\s*['"]com\.facebook\.react['"]\s*\n/;
if (reactApply.test(contents)) {
config.modResults.contents = contents.replace(
reactApply,
`apply plugin: "com.facebook.react"\napply plugin: 'com.google.gms.google-services'\n`
);
} else {
config.modResults.contents = `apply plugin: 'com.google.gms.google-services'\n` + contents;
}
}
if (!contents.includes('com.huawei.agconnect')) {
const applyPluginPattern = /apply plugin:\s*['"]com\.google\.gms\.google-services['"]/;
if (applyPluginPattern.test(contents)) {
config.modResults.contents = contents.replace(
applyPluginPattern,
`apply plugin: 'com.google.gms.google-services'\napply plugin: 'com.huawei.agconnect'`
);
} else {
const lastApplyPlugin = /apply plugin:[^\n]*(?:\n)/g;
const matches = contents.match(lastApplyPlugin);
if (matches && matches.length > 0) {
const lastMatch = matches[matches.length - 1];
const lastIndex = contents.lastIndexOf(lastMatch);
config.modResults.contents =
contents.slice(0, lastIndex + lastMatch.length) +
"apply plugin: 'com.huawei.agconnect'\n" +
contents.slice(lastIndex + lastMatch.length);
} else {
config.modResults.contents = contents + "\napply plugin: 'com.huawei.agconnect'\n";
}
}
}
if (!contents.includes('cn.jiguang.sdk.plugin:huawei')) {
const depsPattern = /dependencies\s*\{/;
if (depsPattern.test(config.modResults.contents)) {
config.modResults.contents = config.modResults.contents.replace(
depsPattern,
`dependencies {\n implementation 'cn.jiguang.sdk.plugin:huawei:${jpushVersion}'`
);
}
}
return config;
});
config = withDangerousMod(config, [
'android',
async (config) => {
// Ensure Huawei Maven repo is in allprojects.repositories (not just buildscript).
// Runtime dependencies like com.huawei.hms:push must be resolved via allprojects,
// and the earlier withProjectBuildGradle hook may be silently dropped in Expo SDK 56+.
const rootBuildGradlePath = path.join(config.modRequest.platformProjectRoot, 'build.gradle');
if (fs.existsSync(rootBuildGradlePath)) {
let rootGradle = fs.readFileSync(rootBuildGradlePath, 'utf-8');
const huaweiRepoLine = ` maven { url 'https://developer.huawei.com/repo/' }`;
let rootChanged = false;
// buildscript.repositories
if (!rootGradle.match(/buildscript\s*\{[\s\S]*?developer\.huawei\.com\/repo/)) {
const bsPattern = /(buildscript\s*\{[\s\S]*?repositories\s*\{)/;
if (bsPattern.test(rootGradle)) {
rootGradle = rootGradle.replace(bsPattern, `$1\n${huaweiRepoLine}`);
rootChanged = true;
}
}
// allprojects.repositories (runtime deps resolution)
if (!rootGradle.match(/allprojects\s*\{[\s\S]*?developer\.huawei\.com\/repo/)) {
const allProjectsPattern = /(allprojects\s*\{[\s\S]*?repositories\s*\{)/;
if (allProjectsPattern.test(rootGradle)) {
rootGradle = rootGradle.replace(allProjectsPattern, `$1\n${huaweiRepoLine}`);
rootChanged = true;
} else {
const allProjectsBlock = `\nallprojects {\n repositories {${huaweiRepoLine}\n }\n}\n`;
rootGradle = rootGradle + allProjectsBlock;
rootChanged = true;
}
}
if (rootChanged) {
fs.writeFileSync(rootBuildGradlePath, rootGradle);
console.log('[withHuaweiPush] ensured Huawei Maven repo in buildscript + allprojects');
}
}
const appDir = path.join(config.modRequest.platformProjectRoot, 'app');
const srcFile = path.join(config.modRequest.projectRoot, 'plugins', 'agconnect-services.json');
const destFile = path.join(appDir, 'agconnect-services.json');
if (fs.existsSync(srcFile)) {
fs.copyFileSync(srcFile, destFile);
console.log('[withHuaweiPush] copied agconnect-services.json to android/app/');
} else {
console.warn('[withHuaweiPush] agconnect-services.json not found at project root');
}
// Copy google-services.json to android/app/ so the Google Services
// Gradle plugin can find it. Without this, FCM / Firebase init fails
// at runtime with "default FirebaseApp is not initialized".
const gmsSrc = path.join(config.modRequest.projectRoot, 'google-services.json');
const gmsDest = path.join(appDir, 'google-services.json');
if (fs.existsSync(gmsSrc)) {
fs.copyFileSync(gmsSrc, gmsDest);
console.log('[withHuaweiPush] copied google-services.json to android/app/');
} else {
console.warn('[withHuaweiPush] google-services.json not found at project root');
}
// RN 0.85.3 ships Gradle 9.3.1 in its template, but Kotlin 2.1.20 is incompatible
// with Gradle 9.x (CompilerEnvironment ClassNotFoundException). Downgrade to 8.14.5.
const wrapperFile = path.join(config.modRequest.platformProjectRoot, 'gradle', 'wrapper', 'gradle-wrapper.properties');
if (fs.existsSync(wrapperFile)) {
let wrapper = fs.readFileSync(wrapperFile, 'utf8');
if (wrapper.includes('gradle-9.')) {
wrapper = wrapper.replace(/gradle-[\d.]+-bin\.zip/, 'gradle-8.14.5-bin.zip');
fs.writeFileSync(wrapperFile, wrapper);
console.log('[withHuaweiPush] downgraded Gradle to 8.14.5 for Kotlin 2.1.20 compatibility');
}
}
// Ensure local.properties with SDK path exists (gets cleared on prebuild --clean)
const localPropsFile = path.join(config.modRequest.platformProjectRoot, 'local.properties');
if (!fs.existsSync(localPropsFile)) {
const homeDir = process.env.USERPROFILE || process.env.HOME || '';
const sdkPath = path.join(homeDir, 'AppData', 'Local', 'Android', 'Sdk');
if (fs.existsSync(sdkPath)) {
fs.writeFileSync(localPropsFile, `sdk.dir=${sdkPath.replace(/\\/g, '\\\\')}\n`);
console.log('[withHuaweiPush] created local.properties with SDK path');
}
}
return config;
},
]);
config = withAndroidManifest(config, (config) => {
return config;
});
return config;
};
module.exports = withHuaweiPush;

View File

@@ -1,321 +0,0 @@
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 contents = config.modResults.contents;
const requiredPlaceholders = {
JPUSH_APPKEY: appKey,
JPUSH_CHANNEL: channel,
};
const manifestPlaceholderRegex = /manifestPlaceholders\s*=\s*\[([\s\S]*?)\]/;
const match = contents.match(manifestPlaceholderRegex);
if (match) {
const existingBlock = match[1];
const existingEntries = {};
const entryRegex = /(\w+)\s*:\s*"([^"]*)"/g;
let entryMatch;
while ((entryMatch = entryRegex.exec(existingBlock)) !== null) {
existingEntries[entryMatch[1]] = entryMatch[2];
}
for (const [key, value] of Object.entries(requiredPlaceholders)) {
existingEntries[key] = value;
}
const entriesStr = Object.entries(existingEntries)
.map(([k, v]) => `${k}: "${v}"`)
.join(',\n ');
const newBlock = `\n manifestPlaceholders = [\n ${entriesStr}\n ]`;
config.modResults.contents = contents.replace(manifestPlaceholderRegex, newBlock.trim());
} else if (contents.includes('REACT_NATIVE_RELEASE_LEVEL')) {
const entriesStr = Object.entries(requiredPlaceholders)
.map(([k, v]) => `${k}: "${v}"`)
.join(',\n ');
const block = '\n manifestPlaceholders = [\n ' + entriesStr + '\n ]';
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 initialization') && contents.includes('JPUSHService.registerDeviceToken')) {
return config;
}
const isSwift = contents.includes('class AppDelegate') || contents.includes('@main');
if (isSwift) {
let newContents = contents;
// Add UNUserNotifications import if needed
if (!newContents.includes('import UserNotifications')) {
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`
);
}
}
// Add UNUserNotificationCenterDelegate conformance if needed
if (!newContents.includes('UNUserNotificationCenterDelegate')) {
newContents = newContents.replace(
/class AppDelegate: ExpoAppDelegate \{/,
'class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate {'
);
}
// Swift: Add JPush init in application(_:didFinishLaunchingWithOptions:)
// Expo SDK 56+ uses JPUSHService.setup (not JCoreModule.setup)
if (!newContents.includes('JPUSHService.setup') && !newContents.includes('JCoreModule.setup')) {
const jpushInitBlock = `
// JPush initialization
#if DEBUG
JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)
#else
JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)
#endif
UNUserNotificationCenter.current().delegate = self`;
// Insert after the opening brace of didFinishLaunchingWithOptions
// The { may be on the same line as didFinishLaunchingWithOptions or on a subsequent line
if (newContents.includes('didFinishLaunchingWithOptions')) {
const lines = newContents.split('\n');
let foundDidFinish = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('didFinishLaunchingWithOptions')) {
foundDidFinish = true;
}
// Insert after the line that contains the opening brace of this method
if (foundDidFinish && lines[i].includes('{')) {
const indent = lines[i].match(/^(\s*)/)[1];
const initLines = jpushInitBlock.split('\n').map(l => l ? indent + l.trimStart() : l);
lines.splice(i + 1, 0, ...initLines);
newContents = lines.join('\n');
break;
}
}
}
} else if (newContents.includes('JCoreModule.setup') && !newContents.includes('JPUSHService.setup')) {
// Migrate from JCoreModule.setup to JPUSHService.setup
newContents = newContents.replace(/JCoreModule\.setup/g, 'JPUSHService.setup');
}
// Add delegate = self after init insertion if not present
if (!newContents.includes('UNUserNotificationCenter.current().delegate')) {
newContents = newContents.replace(
/(#endif\n)(\s+let delegate)/,
`$1 UNUserNotificationCenter.current().delegate = self\n$2`
);
}
// Swift: Add JPush notification delegate methods to AppDelegate class (before closing brace)
// These go in AppDelegate, NOT ReactNativeDelegate
const jpushSwiftMethods = `
// JPush: Register for remote notifications
override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
JPUSHService.registerDeviceToken(deviceToken)
}
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')) {
// Insert before the closing brace of the AppDelegate class
// Find the last '}' that closes the AppDelegate class (before ReactNativeDelegate)
const classEndPattern = /\n\}\n\nclass ReactNativeDelegate/;
if (classEndPattern.test(newContents)) {
newContents = newContents.replace(
classEndPattern,
`${jpushSwiftMethods}}\n\nclass ReactNativeDelegate`
);
} else {
// Fallback: insert before the last closing brace
const lastBraceIndex = newContents.lastIndexOf('}');
if (lastBraceIndex !== -1) {
newContents = newContents.slice(0, lastBraceIndex) + jpushSwiftMethods + '\n' + newContents.slice(lastBraceIndex);
}
}
}
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

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

View File

@@ -1,73 +0,0 @@
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
View,
StyleSheet,
@@ -172,7 +172,7 @@ export function AppDesktopShell() {
const handleTabChange = useCallback(
(href: string) => {
router.push(href);
router.replace(href);
},
[router]
);
@@ -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}></Text>}
{!isCollapsed && <Text style={styles.logoText}>BBS</Text>}
</View>
<ScrollView style={styles.sidebarContent} showsVerticalScrollIndicator={false}>
{NAV_ITEMS.map((item) => {

View File

@@ -1,124 +0,0 @@
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;
getBlocks: () => EditorBlock[];
uploadPendingImages: () => Promise<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,
getBlocks: editor.getBlocks,
uploadPendingImages: editor.uploadPendingImages,
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

@@ -1,80 +0,0 @@
import React from 'react';
import { View, TouchableOpacity, StyleSheet, Dimensions } 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}
/>
<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',
},
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

@@ -1,276 +0,0 @@
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

@@ -1,103 +0,0 @@
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;
mimeType?: string;
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

@@ -1,96 +0,0 @@
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,
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

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

View File

@@ -1,302 +0,0 @@
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 blocksRef = useRef<EditorBlock[]>(blocks);
blocksRef.current = blocks;
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 uploadPendingImages = useCallback(async (): Promise<boolean> => {
let allSuccess = true;
const uploadBlock = async () => {
const currentBlocks = blocksRef.current;
const pendingBlocks = currentBlocks.filter(
(b): b is ImageBlock => b.type === 'image' && !b.remoteUrl,
);
for (const block of pendingBlocks) {
try {
const uploadResult = await uploadService.uploadImage({
uri: block.localUri,
type: block.mimeType || 'image/jpeg',
});
if (uploadResult) {
setBlocks(prev =>
prev.map(b =>
b.id === block.id && b.type === 'image'
? {
...b,
remoteUrl: uploadResult.url,
width: uploadResult.width ?? b.width,
height: uploadResult.height ?? b.height,
}
: b,
),
);
} else {
allSuccess = false;
}
} catch {
allSuccess = false;
}
}
};
await uploadBlock();
return allSuccess;
}, []);
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;
const imageBlockId = generateBlockId();
const imageBlock: ImageBlock = {
id: imageBlockId,
type: 'image',
localUri: asset.uri,
mimeType: asset.mimeType,
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);
const newTextBlockId = generateBlockId();
const newTextBlock: TextBlock = {
id: newTextBlockId,
type: 'text',
text: textAfter,
activeMentions: mentionsAfter,
};
updated[insertAfterIndex] = {
...activeBlock,
text: textBefore,
activeMentions: mentionsBefore,
};
updated.splice(insertAfterIndex + 1, 0, imageBlock, newTextBlock);
insertAfterIndex = insertAfterIndex + 2;
setActiveBlockId(newTextBlockId);
requestAnimationFrame(() => focusBlock(newTextBlockId));
} else {
updated.splice(insertAfterIndex + 1, 0, imageBlock);
insertAfterIndex = insertAfterIndex + 1;
}
}
return updated;
});
},
[activeBlockId, focusBlock],
);
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 getBlocks = useCallback(() => blocks, [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,
getBlocks,
uploadPendingImages,
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 { formatChatTime } from '../../utils/formatTime';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
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: (comment: Comment) => void; // 点赞回调,传入评论对象
onLike: () => void;
floorNumber?: number; // 楼层号
isAuthor?: boolean; // 是否是楼主
replyToUser?: string; // 回复给哪位用户
@@ -29,7 +29,6 @@ interface CommentItemProps {
onDelete?: (comment: Comment) => void; // 删除评论的回调
onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图
currentUserId?: string; // 当前用户ID用于判断子评论作者
onReport?: (comment: Comment) => void; // 举报评论的回调
}
function createCommentItemStyles(colors: AppColors) {
@@ -40,6 +39,8 @@ function createCommentItemStyles(colors: AppColors) {
paddingBottom: spacing.md,
paddingHorizontal: spacing.md,
backgroundColor: colors.background.paper,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
},
content: {
flex: 1,
@@ -82,7 +83,7 @@ function createCommentItemStyles(colors: AppColors) {
paddingVertical: 0,
},
authorBadge: {
backgroundColor: `${colors.primary.main}18`,
backgroundColor: colors.primary.main,
},
adminBadge: {
backgroundColor: colors.error.main,
@@ -92,21 +93,11 @@ function createCommentItemStyles(colors: AppColors) {
fontSize: fontSizes.xs,
fontWeight: '600',
},
authorBadgeText: {
color: colors.primary.main,
fontSize: fontSizes.xs,
fontWeight: '700',
},
smallBadgeText: {
color: colors.text.inverse,
fontSize: 9,
fontWeight: '600',
},
smallAuthorBadgeText: {
color: colors.primary.main,
fontSize: 9,
fontWeight: '700',
},
timeText: {
fontSize: fontSizes.xs,
flexShrink: 0,
@@ -135,7 +126,7 @@ function createCommentItemStyles(colors: AppColors) {
actionButton: {
flexDirection: 'row',
alignItems: 'center',
marginRight: spacing.sm,
marginRight: spacing.lg,
paddingVertical: 4,
paddingRight: spacing.xs,
},
@@ -150,6 +141,7 @@ function createCommentItemStyles(colors: AppColors) {
paddingVertical: spacing.xs,
borderLeftWidth: 2,
borderLeftColor: colors.divider,
backgroundColor: colors.background.default,
},
subReplyItem: {
flexDirection: 'row',
@@ -160,8 +152,8 @@ function createCommentItemStyles(colors: AppColors) {
marginBottom: 0,
},
subReplyBody: {
fontSize: fontSizes.md,
lineHeight: 22,
fontSize: fontSizes.sm,
lineHeight: 20,
marginTop: 2,
},
subReplyContent: {
@@ -183,10 +175,10 @@ function createCommentItemStyles(colors: AppColors) {
paddingVertical: spacing.xs,
},
replyToText: {
fontSize: fontSizes.sm,
fontSize: fontSizes.xs,
},
replyToName: {
fontSize: fontSizes.sm,
fontSize: fontSizes.xs,
fontWeight: '500',
},
subReplyActions: {
@@ -222,11 +214,21 @@ 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 => {
@@ -312,7 +314,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
if (isAuthor) {
badges.push(
<View key="author" style={[styles.badge, styles.authorBadge]}>
<Text variant="caption" style={styles.authorBadgeText}></Text>
<Text variant="caption" style={styles.badgeText}></Text>
</View>
);
}
@@ -468,14 +470,14 @@ const CommentItem: React.FC<CommentItemProps> = ({
</Text>
{replyAuthorId === commentAuthorId && (
<View style={[styles.badge, styles.authorBadge, styles.smallBadge]}>
<Text variant="caption" style={styles.smallAuthorBadgeText}></Text>
<Text variant="caption" style={styles.smallBadgeText}></Text>
</View>
)}
{/* 显示回复引用aaa 回复 bbb */}
{targetNickname && (
<>
<Text variant="caption" color={colors.text.hint} style={styles.replyToText}>
{' '}{' '}
{' '}
</Text>
<Text variant="caption" color={colors.primary.main} style={styles.replyToName}>
{targetNickname}
@@ -485,7 +487,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
</View>
{/* 显示回复内容(如果有文字) */}
{reply.content ? (
<Text variant="body" color={colors.text.primary} selectable style={styles.subReplyBody}>
<Text variant="body" color={colors.text.primary} style={styles.subReplyBody}>
{reply.content}
</Text>
) : null}
@@ -493,30 +495,11 @@ 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={14} color={colors.text.hint} />
<MaterialCommunityIcons name="reply" size={12} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
</Text>
@@ -530,7 +513,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
>
<MaterialCommunityIcons
name={isDeleting ? 'loading' : 'delete-outline'}
size={14}
size={12}
color={colors.text.hint}
/>
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
@@ -538,17 +521,6 @@ 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>
@@ -592,7 +564,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
{renderBadges()}
<Text style={styles.metaDot}>·</Text>
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
{formatChatTime(comment.created_at || '')}
{formatTime(comment.created_at || '')}
</Text>
</View>
{renderFloorNumber()}
@@ -609,17 +581,9 @@ const CommentItem: React.FC<CommentItemProps> = ({
{/* 评论文本 - 非气泡样式 */}
<View style={styles.commentContent}>
{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>
)}
<Text variant="body" color={colors.text.primary} style={styles.text}>
{comment.content}
</Text>
</View>
{/* 评论图片 */}
@@ -628,7 +592,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
{/* 操作按钮 - 更紧凑 */}
<View style={styles.actions}>
{/* 点赞 */}
<TouchableOpacity style={styles.actionButton} onPress={() => onLike(comment)}>
<TouchableOpacity style={styles.actionButton} onPress={onLike}>
<MaterialCommunityIcons
name={comment.is_liked ? 'heart' : 'heart-outline'}
size={14}
@@ -651,19 +615,6 @@ 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,14 +16,13 @@ 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, ImageGrid } from '../../common';
import { ImageGridItem, SmartImage } 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 PostContentRenderer from '../PostContentRenderer';
import { useResponsive } from '../../../hooks';
import PostImages from './components/PostImages';
import { useResponsive } from '../../../hooks/useResponsive';
import { formatPostCreatedAtString } from '../../../core/entities/Post';
/** 列表相对时间:独立定时刷新,避免外层 PostCard.memo 挡住「分钟前」更新 */
@@ -54,13 +53,6 @@ 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');
@@ -92,7 +84,6 @@ 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;
@@ -116,7 +107,6 @@ 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;
@@ -216,9 +206,6 @@ function createPostCardStyles(colors: AppColors) {
marginBottom: spacing.xs,
lineHeight: fontSizes.md * 1.45,
},
contentRenderer: {
marginBottom: spacing.xs,
},
expandBtn: {
marginBottom: spacing.sm,
},
@@ -255,7 +242,6 @@ function createPostCardStyles(colors: AppColors) {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: spacing.sm,
},
actionsLeading: {
flexDirection: 'row',
@@ -294,10 +280,10 @@ function createPostCardStyles(colors: AppColors) {
marginLeft: 4,
},
activeActionText: {
color: colors.primary.main,
color: colors.error.main,
},
activeActionTextMerged: {
color: colors.primary.main,
color: colors.error.main,
fontSize: fontSizes.sm,
marginLeft: 4,
},
@@ -388,10 +374,6 @@ function createPostCardStyles(colors: AppColors) {
fontSize: fontSizes.sm,
marginLeft: 4,
},
highlight: {
backgroundColor: `${colors.warning.main}40`,
color: colors.text.primary,
},
});
}
@@ -409,7 +391,6 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
isAuthor = false,
index,
style,
highlightKeyword,
} = normalizedProps;
const colors = useAppColors();
@@ -434,22 +415,6 @@ 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' });
@@ -479,29 +444,17 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
);
};
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 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 formatNumber = (num: number): string => {
if (num >= 10000) return `${(num / 10000).toFixed(1)}w`;
@@ -510,13 +463,6 @@ 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;
@@ -524,60 +470,22 @@ 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, 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}`;
};
}, [isWideScreen, isDesktop, isTablet]);
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 && displayContent.length > getResponsiveMaxLength();
const shouldTruncate = !showGrid && !isCompact && rawContent.length > getResponsiveMaxLength();
const content = useMemo(
() => (showGrid || isCompact ? displayContent : getTruncatedContent(displayContent)),
[displayContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
() => (showGrid || isCompact ? rawContent : getTruncatedContent(rawContent)),
[rawContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
);
const renderImages = () => {
@@ -599,24 +507,10 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
}
return (
<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}
<PostImages
images={post.images || []}
displayMode="list"
usePreview={true}
onImagePress={handleImagePress}
/>
);
};
@@ -662,7 +556,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
) : (
<View style={styles.gridNoImagePreview}>
<Text style={styles.gridNoImageText} numberOfLines={6}>
{highlightKeyword ? <HighlightText text={contentPreview} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : contentPreview}
{contentPreview}
</Text>
</View>
)}
@@ -676,7 +570,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
{!!post.title && (
<Text style={styles.gridTitleMain} numberOfLines={hasImage ? 2 : 3}>
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : post.title}
{post.title}
</Text>
)}
@@ -695,14 +589,8 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
<Text style={styles.gridUsername} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
</TouchableOpacity>
<View style={styles.gridLikeArea}>
<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>
<MaterialCommunityIcons name="heart-outline" size={14} color={colors.text.secondary} />
<Text style={styles.gridLikeCount}>{formatNumber(post.likes_count || 0)}</Text>
</View>
</View>
</TouchableOpacity>
@@ -748,7 +636,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
{!!post.title && (
<Text style={styles.title} numberOfLines={isCompact ? 2 : undefined}>
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : post.title}
{post.title}
</Text>
)}
@@ -758,7 +646,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
style={styles.content}
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
>
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : content}
{content}
</Text>
{shouldTruncate && (
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
@@ -768,7 +656,6 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
</>
)}
{/* 仅当 segments 中不含 image 时才渲染独立的图片区域 */}
{renderImages()}
{renderTopComment()}
@@ -790,9 +677,9 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
<View style={styles.actionButtons}>
<TouchableOpacity style={styles.actionBtn} onPress={handleLike}>
<MaterialCommunityIcons
name={post.is_liked ? 'thumb-up' : 'thumb-up-outline'}
name={post.is_liked ? 'heart' : 'heart-outline'}
size={18}
color={post.is_liked ? colors.primary.main : colors.text.secondary}
color={post.is_liked ? colors.error.main : colors.text.secondary}
/>
<Text style={post.is_liked ? styles.activeActionTextMerged : styles.actionText}>
{post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'}
@@ -807,7 +694,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
</TouchableOpacity>
<TouchableOpacity style={styles.actionBtn} onPress={handleBookmark}>
<MaterialCommunityIcons
name={post.is_favorited ? 'star' : 'star-outline'}
name={post.is_favorited ? 'bookmark' : 'bookmark-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';
import { useResponsive } from '../../../../hooks/useResponsive';
import { ImageGrid, ImageGridItem } from '../../../common';
import { PostImagesProps } from '../types';

View File

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

View File

@@ -1,299 +0,0 @@
/**
* 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} highlightStyle={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} highlightStyle={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} highlightStyle={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

@@ -1,439 +0,0 @@
/**
* PostMentionInput - 帖子内容输入框(支持 @提及)
* 复用关注列表加载逻辑
* 检测输入中的 @ 字符,弹出关注者列表供选择
*/
import React, { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } 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';
}
export interface PostMentionInputHandle {
focus: () => void;
blur: () => void;
}
const PostMentionInput = forwardRef<PostMentionInputHandle, PostMentionInputProps>(({
value,
onChangeText,
onSegmentsChange,
placeholder = '说点什么...',
maxLength = 2000,
style,
minHeight = 100,
autoExpand = false,
onPostRefPasted,
onSubmitEditing,
returnKeyType = 'default',
}, ref) => {
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);
// 暴露 focus / blur 方法给父组件,用于在模式切换(如点击回复)后自动唤起键盘
useImperativeHandle(ref, () => ({
focus: () => {
// 展开动画/挂载需要时间,延迟一帧后再聚焦,避免在折叠态未真正展开时调用无效
requestAnimationFrame(() => {
setTimeout(() => {
inputRef.current?.focus();
}, 50);
});
},
blur: () => {
inputRef.current?.blur();
},
}), []);
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

@@ -1,125 +0,0 @@
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';
import * as hrefs from '../../navigation/hrefs';
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(hrefs.hrefPostDetail(data.post_id));
}
}, [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,13 +7,12 @@ 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');
@@ -134,85 +133,22 @@ 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,
},
});
}
// Web 端不支持扫码组件
const QRCodeScannerWeb: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
const themeColors = useAppColors();
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
useEffect(() => {
if (visible) {
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 }) => {
export const QRCodeScanner: 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 [permission, requestPermission] = useCameraPermissions();
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);
});
if (visible) {
setScanned(false);
if (!permission?.granted) {
requestPermission();
}
}
}, [visible]);
@@ -221,7 +157,7 @@ const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose })
setScanned(true);
onClose();
if (data.startsWith('withyou://qrcode/login')) {
if (data.startsWith('carrotbbs://qrcode/login')) {
const sessionId = extractSessionId(data);
if (sessionId) {
router.push(hrefs.hrefQrLoginConfirm(sessionId));
@@ -242,7 +178,7 @@ const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose })
}
};
if (!cameraModule || permissionStatus !== 'granted') {
if (!permission?.granted) {
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={styles.container}>
@@ -256,16 +192,7 @@ const QRCodeScannerNative: 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={() => {
if (cameraModule) {
cameraModule.Camera.requestCameraPermissionsAsync().then((result) => {
setPermissionStatus(result.granted ? 'granted' : 'denied');
});
}
}}
>
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
<Text style={styles.permissionButtonText}></Text>
</TouchableOpacity>
</View>
@@ -274,8 +201,6 @@ const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose })
);
}
const { CameraView } = cameraModule;
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={styles.container}>
@@ -318,9 +243,4 @@ const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose })
);
};
// 根据平台导出不同组件
export const QRCodeScanner: React.FC<QRCodeScannerProps> = Platform.OS === 'web'
? QRCodeScannerWeb
: QRCodeScannerNative;
export default QRCodeScanner;

View File

@@ -1,493 +0,0 @@
/**
* 举报对话框组件
* 支持举报帖子、评论、消息
* 提供友好的用户界面和流畅的交互体验
*/
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

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

View File

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

View File

@@ -1,115 +0,0 @@
/**
* SearchHeader 搜索顶栏组件
* 统一的「搜索框 + 取消按钮」顶栏,用于内嵌式搜索页面。
*
* - 内置 Android 硬件返回键拦截:存在 onCancel 时,按下返回键调用 onCancel
* 而非退出 App解决 Tab 根页面内嵌搜索时返回键直接退出的问题)。
* - 样式与 HomeScreen 搜索页保持一致。
*/
import React, { useEffect, useMemo } from 'react';
import { View, TouchableOpacity, StyleSheet, BackHandler, StyleProp, ViewStyle } from 'react-native';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import { Text } from '../common';
import SearchBar from './SearchBar';
interface SearchHeaderProps {
value: string;
onChangeText: (text: string) => void;
onSubmit: () => void;
/** 取消回调:同时用于取消按钮与硬件返回键。必传以启用内嵌模式返回拦截。 */
onCancel: () => void;
placeholder?: string;
/** 透传给 SearchBar 的 compact 模式 */
compact?: boolean;
/** 搜索框最大宽度(宽屏限宽) */
searchBarMaxWidth?: number;
/** 水平外边距,默认 spacing.md */
horizontalPadding?: number;
/** 顶部内边距,默认 spacing.sm */
paddingTop?: number;
style?: StyleProp<ViewStyle>;
}
function createSearchHeaderStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
},
searchShell: {
flex: 1,
},
cancelButton: {
borderRadius: borderRadius.full,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
cancelText: {
fontSize: fontSizes.md,
fontWeight: '600',
},
});
}
const SearchHeader: React.FC<SearchHeaderProps> = ({
value,
onChangeText,
onSubmit,
onCancel,
placeholder,
compact = false,
searchBarMaxWidth,
horizontalPadding = spacing.md,
paddingTop = spacing.sm,
style,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createSearchHeaderStyles(colors), [colors]);
// 内嵌模式:拦截 Android 物理返回键,调用 onCancel 而非退出 App
useEffect(() => {
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
onCancel();
return true; // 阻止默认行为(退出 App
});
return () => backHandler.remove();
}, [onCancel]);
return (
<View
style={[
styles.container,
{
paddingTop,
paddingHorizontal: horizontalPadding,
paddingBottom: spacing.xs,
},
style,
]}
>
<View style={[styles.searchShell, searchBarMaxWidth != null && { maxWidth: searchBarMaxWidth }]}>
<SearchBar
value={value}
onChangeText={onChangeText}
onSubmit={onSubmit}
placeholder={placeholder}
autoFocus
compact={compact}
/>
</View>
<TouchableOpacity
style={[styles.cancelButton, { marginLeft: spacing.sm }]}
onPress={onCancel}
activeOpacity={0.85}
>
<Text variant="body" color={colors.primary.main} style={styles.cancelText}>
</Text>
</TouchableOpacity>
</View>
);
};
export default SearchHeader;

View File

@@ -1,55 +0,0 @@
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

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

View File

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

View File

@@ -10,7 +10,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import Text from '../common/Text';
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern' | 'home';
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern';
interface TabBarProps {
tabs: string[];
@@ -20,8 +20,6 @@ interface TabBarProps {
rightContent?: ReactNode;
variant?: TabBarVariant;
icons?: string[];
/** home 变体的字号,默认 18HomeScreen 可传 20 */
fontSize?: number;
style?: StyleProp<ViewStyle>;
}
@@ -137,21 +135,26 @@ function createTabBarStyles(colors: AppColors) {
modernContainer: {
flexDirection: 'row',
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
alignItems: 'center',
paddingRight: spacing.xs,
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,
},
modernTab: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.lg,
position: 'relative',
backgroundColor: 'transparent',
},
modernTabActive: {
backgroundColor: 'transparent',
backgroundColor: colors.primary.main + '15',
},
modernTabContent: {
flexDirection: 'row',
@@ -166,44 +169,15 @@ function createTabBarStyles(colors: AppColors) {
fontSize: fontSizes.md,
},
modernTabTextActive: {
fontWeight: '600',
fontWeight: '700',
},
modernTabIndicator: {
position: 'absolute',
bottom: 0,
left: '25%',
right: '25%',
bottom: 4,
width: 20,
height: 3,
backgroundColor: colors.primary.main,
borderTopLeftRadius: borderRadius.sm,
borderTopRightRadius: borderRadius.sm,
},
// home 变体:与 HomeScreen/SearchScreen 一致的文字下划线风格
homeContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
},
homeTab: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.sm,
marginRight: 20,
borderBottomWidth: 2,
borderBottomColor: 'transparent',
},
homeTabActive: {
borderBottomColor: colors.text.primary,
},
homeTabText: {
fontSize: fontSizes.xl,
fontWeight: '400',
color: colors.text.hint,
},
homeTabTextActive: {
fontSize: fontSizes.xl,
fontWeight: '700',
color: colors.text.primary,
borderRadius: borderRadius.full,
},
});
}
@@ -216,7 +190,6 @@ const TabBar: React.FC<TabBarProps> = ({
rightContent,
variant = 'default',
icons,
fontSize,
style,
}) => {
const colors = useAppColors();
@@ -233,7 +206,7 @@ const TabBar: React.FC<TabBarProps> = ({
key={index}
style={[styles.modernTab, isActive && styles.modernTabActive]}
onPress={() => onTabChange(index)}
activeOpacity={0.7}
activeOpacity={0.85}
>
<View style={styles.modernTabContent}>
{icon && (
@@ -308,29 +281,6 @@ const TabBar: React.FC<TabBarProps> = ({
);
}
if (variant === 'home') {
const homeTextStyle = [
isActive ? styles.homeTabTextActive : styles.homeTabText,
fontSize != null ? { fontSize } : undefined,
];
return (
<TouchableOpacity
key={index}
style={[styles.homeTab, isActive && styles.homeTabActive]}
onPress={() => onTabChange(index)}
activeOpacity={0.7}
>
<Text
variant="body"
color={isActive ? colors.text.primary : colors.text.hint}
style={homeTextStyle}
>
{tab}
</Text>
</TouchableOpacity>
);
}
return (
<TouchableOpacity
key={index}
@@ -359,8 +309,6 @@ const TabBar: React.FC<TabBarProps> = ({
return styles.segmentedContainer;
case 'modern':
return styles.modernContainer;
case 'home':
return styles.homeContainer;
default:
return styles.container;
}
@@ -385,4 +333,4 @@ const TabBar: React.FC<TabBarProps> = ({
);
};
export default React.memo(TabBar);
export default TabBar;

View File

@@ -1,382 +0,0 @@
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

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

View File

@@ -18,16 +18,7 @@ export { default as CommentItem } from './CommentItem';
export { default as UserProfileHeader } from './UserProfileHeader';
export { default as SystemMessageItem } from './SystemMessageItem';
export { default as SearchBar } from './SearchBar';
export { default as SearchHeader } from './SearchHeader';
export { default as TabBar } from './TabBar';
export { default as VoteCard } from './VoteCard';
export { default as VoteEditor } from './VoteEditor';
export { default as VotePreview } from './VotePreview';
export { default as PostContentRenderer } from './PostContentRenderer';
export { default as PostMentionInput } from './PostMentionInput';
export type { PostMentionInputHandle } from './PostMentionInput';
export { default as BlockEditor } from './BlockEditor';
export type { BlockEditorHandle } from './BlockEditor';
export { default as ReportDialog } from './ReportDialog';
export { default as ShareSheet } from './ShareSheet';
export { TradeCard } from './TradeCard/TradeCard';

View File

@@ -1,460 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
StatusBar,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Track, LocalVideoTrack, RemoteVideoTrack } from 'livekit-client';
let VideoView: React.ComponentType<any> | null = null;
try {
VideoView = require('@livekit/react-native').VideoView;
} catch {
// WebRTC native module not available (e.g. Expo Go)
}
type VideoTrack = LocalVideoTrack | RemoteVideoTrack;
import { callStore } from '../../stores/call';
import { liveKitService } from '@/services/livekit';
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 toggleMute = callStore((s) => s.toggleMute);
const toggleSpeaker = callStore((s) => s.toggleSpeaker);
const toggleVideo = callStore((s) => s.toggleVideo);
const isMinimized = callStore((s) => s.isMinimized);
const toggleMinimize = callStore((s) => s.toggleMinimize);
const [remoteVideoTrack, setRemoteVideoTrack] = useState<VideoTrack | null>(null);
const [localVideoTrack, setLocalVideoTrack] = useState<VideoTrack | null>(null);
// Event-driven video track subscription (no polling)
useEffect(() => {
const unsubs: (() => void)[] = [];
// Sync current tracks immediately
const syncTracks = () => {
const lp = liveKitService.localParticipant;
if (lp) {
const videoPub = lp.getTrackPublication(Track.Source.Camera);
setLocalVideoTrack((videoPub?.track as VideoTrack | undefined) ?? null);
}
liveKitService.remoteParticipants.forEach((participant) => {
const videoPub = participant.getTrackPublication(Track.Source.Camera);
if (videoPub?.track) {
setRemoteVideoTrack(videoPub.track as VideoTrack);
}
});
};
syncTracks();
// Remote video track events
unsubs.push(
liveKitService.on('trackSubscribed', ({ track }) => {
if (track.kind === Track.Kind.Video) {
setRemoteVideoTrack(track as VideoTrack);
}
})
);
unsubs.push(
liveKitService.on('trackUnsubscribed', ({ track }) => {
if (track.kind === Track.Kind.Video) {
setRemoteVideoTrack(null);
}
})
);
unsubs.push(
liveKitService.on('trackMuted', ({ publication }) => {
if (publication.source === Track.Source.Camera) {
setRemoteVideoTrack(null);
}
})
);
unsubs.push(
liveKitService.on('trackUnmuted', ({ publication }) => {
if (publication.source === Track.Source.Camera && publication.track) {
setRemoteVideoTrack(publication.track as VideoTrack);
}
})
);
// Local video track events (replace 500ms polling)
unsubs.push(
liveKitService.on('localTrackPublished', ({ publication }) => {
if (publication.source === Track.Source.Camera && publication.track) {
setLocalVideoTrack(publication.track as VideoTrack);
}
})
);
unsubs.push(
liveKitService.on('localTrackUnpublished', ({ publication }) => {
if (publication.source === Track.Source.Camera) {
setLocalVideoTrack(null);
}
})
);
return () => {
unsubs.forEach((unsub) => unsub());
};
}, []);
// Don't render full screen when minimized or no call
if (!currentCall || isMinimized) return null;
const getStatusText = (): string => {
switch (currentCall.status) {
case 'calling':
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 showRemoteVideo = !!remoteVideoTrack;
const showLocalVideo = currentCall?.isVideoEnabled && !!localVideoTrack;
const isVideoCallActive = showRemoteVideo || showLocalVideo;
return (
<View style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<View style={styles.background} />
{/* Remote video - full screen */}
{showRemoteVideo && remoteVideoTrack && VideoView && (
<VideoView
videoTrack={remoteVideoTrack}
style={styles.fullScreenVideo}
objectFit="cover"
/>
)}
{/* Local video - picture in picture */}
{showLocalVideo && localVideoTrack && VideoView && (
<View style={styles.localVideoContainer}>
<VideoView
videoTrack={localVideoTrack}
style={styles.localVideo}
objectFit="cover"
mirror={true}
/>
</View>
)}
{/* Top area: minimize button */}
<View style={styles.topBar}>
<TouchableOpacity
style={styles.topButton}
onPress={toggleMinimize}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
</TouchableOpacity>
</View>
{/* Center: Peer info - only show when no video */}
{!isVideoCallActive && (
<View style={styles.centerArea}>
<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={toggleMute}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, currentCall.isMuted && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={currentCall.isMuted ? 'microphone-off' : 'microphone'}
size={26}
color={currentCall.isMuted ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, currentCall.isMuted && styles.controlLabelActive]}>
{currentCall.isMuted ? '取消静音' : '静音'}
</Text>
</TouchableOpacity>
{/* Video toggle */}
<TouchableOpacity
style={[styles.controlButton, currentCall.isVideoEnabled && styles.controlButtonActive]}
onPress={toggleVideo}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, currentCall.isVideoEnabled && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={currentCall.isVideoEnabled ? 'video' : 'video-off'}
size={26}
color={currentCall.isVideoEnabled ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, currentCall.isVideoEnabled && styles.controlLabelActive]}>
{currentCall.isVideoEnabled ? '关闭摄像头' : '打开摄像头'}
</Text>
</TouchableOpacity>
{/* Speaker */}
<TouchableOpacity
style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]}
onPress={toggleSpeaker}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, currentCall.isSpeakerOn && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={currentCall.isSpeakerOn ? 'volume-high' : 'cellphone'}
size={26}
color={currentCall.isSpeakerOn ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, currentCall.isSpeakerOn && styles.controlLabelActive]}>
{currentCall.isSpeakerOn ? '免提' : '听筒'}
</Text>
</TouchableOpacity>
</View>
{/* End call */}
<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 styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFill,
backgroundColor: '#1A1A2E',
zIndex: 9999,
},
background: {
...StyleSheet.absoluteFill,
backgroundColor: '#1A1A2E',
},
topBar: {
position: 'absolute',
top: 50,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
height: 44,
zIndex: 100,
},
topButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
},
centerArea: {
position: 'absolute',
top: '28%',
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.absoluteFill,
backgroundColor: '#1A1A2E',
},
localVideoContainer: {
position: 'absolute',
top: 100,
right: 20,
width: 120,
height: 160,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#2A2A4E',
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.2)',
zIndex: 50,
},
localVideo: {
width: '100%',
height: '100%',
},
videoOverlayInfo: {
position: 'absolute',
top: 100,
left: 0,
right: 0,
alignItems: 'center',
zIndex: 50,
},
videoPeerName: {
fontSize: 18,
fontWeight: '600',
color: '#FFFFFF',
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
videoStatus: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.8)',
marginTop: 4,
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
controls: {
position: 'absolute',
bottom: 60,
left: 0,
right: 0,
alignItems: 'center',
zIndex: 100,
},
controlRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: 24,
marginBottom: 24,
},
controlButton: {
alignItems: 'center',
width: 70,
},
controlCircle: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(255, 255, 255, 0.12)',
justifyContent: 'center',
alignItems: 'center',
},
controlCircleActive: {
backgroundColor: '#FFFFFF',
},
controlButtonActive: {},
controlLabel: {
fontSize: 11,
color: 'rgba(255, 255, 255, 0.55)',
marginTop: 8,
textAlign: 'center',
},
controlLabelActive: {
color: '#FFFFFF',
fontWeight: '500',
},
endCallButton: {
alignItems: 'center',
},
endCallCircle: {
width: 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

@@ -1,424 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
StatusBar,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Track, LocalVideoTrack, RemoteVideoTrack } from 'livekit-client';
let VideoView: React.ComponentType<any> | null = null;
try {
VideoView = require('@livekit/react-native').VideoView;
} catch {
// @livekit/react-native not available on web
}
import { callStore } from '../../stores/call';
import { liveKitService } from '@/services/livekit';
type VideoTrack = LocalVideoTrack | RemoteVideoTrack;
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
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 toggleMute = callStore((s) => s.toggleMute);
const toggleSpeaker = callStore((s) => s.toggleSpeaker);
const toggleVideo = callStore((s) => s.toggleVideo);
const isMinimized = callStore((s) => s.isMinimized);
const toggleMinimize = callStore((s) => s.toggleMinimize);
const [remoteVideoTrack, setRemoteVideoTrack] = useState<VideoTrack | null>(null);
const [localVideoTrack, setLocalVideoTrack] = useState<VideoTrack | null>(null);
// Subscribe to LiveKit track events
useEffect(() => {
const unsubs: (() => void)[] = [];
unsubs.push(
liveKitService.on('trackSubscribed', ({ track }) => {
if (track.kind === Track.Kind.Video) {
setRemoteVideoTrack(track as VideoTrack);
}
})
);
unsubs.push(
liveKitService.on('trackUnsubscribed', ({ track }) => {
if (track.kind === Track.Kind.Video) {
setRemoteVideoTrack(null);
}
})
);
unsubs.push(
liveKitService.on('trackMuted', ({ publication }) => {
if (publication.source === Track.Source.Camera) {
setRemoteVideoTrack(null);
}
})
);
unsubs.push(
liveKitService.on('trackUnmuted', ({ publication }) => {
if (publication.source === Track.Source.Camera && publication.track) {
setRemoteVideoTrack(publication.track as VideoTrack);
}
})
);
const localPollInterval = setInterval(() => {
const lp = liveKitService.localParticipant;
if (lp) {
const videoPub = lp.getTrackPublication(Track.Source.Camera);
const track = (videoPub?.track ?? null) as VideoTrack | null;
setLocalVideoTrack((prev) => (prev === track ? prev : track));
} else {
setLocalVideoTrack((prev) => (prev === null ? prev : null));
}
}, 500);
return () => {
unsubs.forEach((unsub) => unsub());
clearInterval(localPollInterval);
};
}, []);
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 showRemoteVideo = !!remoteVideoTrack;
const showLocalVideo = currentCall?.isVideoEnabled && !!localVideoTrack;
const isVideoCallActive = showRemoteVideo || showLocalVideo;
return (
<View style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<View style={styles.background} />
{showRemoteVideo && remoteVideoTrack && VideoView && (
<VideoView
videoTrack={remoteVideoTrack}
style={styles.fullScreenVideo}
objectFit="cover"
/>
)}
{showLocalVideo && localVideoTrack && VideoView && (
<View style={styles.localVideoContainer}>
<VideoView
videoTrack={localVideoTrack}
style={styles.localVideo}
objectFit="cover"
mirror={true}
/>
</View>
)}
<View style={styles.topBar}>
<TouchableOpacity
style={styles.topButton}
onPress={toggleMinimize}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
</TouchableOpacity>
</View>
{!isVideoCallActive && (
<View style={styles.centerArea}>
<View style={styles.avatarOuter}>
{currentCall.peerAvatar ? (
<Image source={{ uri: currentCall.peerAvatar }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
</View>
<Text style={styles.peerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.status}>{getStatusText()}</Text>
</View>
)}
{isVideoCallActive && (
<View style={styles.videoOverlayInfo}>
<Text style={styles.videoPeerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.videoStatus}>{getStatusText()}</Text>
</View>
)}
<View style={styles.controls}>
<View style={styles.controlRow}>
<TouchableOpacity
style={[styles.controlButton, currentCall.isMuted && styles.controlButtonActive]}
onPress={toggleMute}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, currentCall.isMuted && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={currentCall.isMuted ? 'microphone-off' : 'microphone'}
size={26}
color={currentCall.isMuted ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, currentCall.isMuted && styles.controlLabelActive]}>
{currentCall.isMuted ? '取消静音' : '静音'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.controlButton, currentCall.isVideoEnabled && styles.controlButtonActive]}
onPress={toggleVideo}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, currentCall.isVideoEnabled && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={currentCall.isVideoEnabled ? 'video' : 'video-off'}
size={26}
color={currentCall.isVideoEnabled ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, currentCall.isVideoEnabled && styles.controlLabelActive]}>
{currentCall.isVideoEnabled ? '关闭摄像头' : '打开摄像头'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]}
onPress={toggleSpeaker}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, currentCall.isSpeakerOn && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={currentCall.isSpeakerOn ? 'volume-high' : 'cellphone'}
size={26}
color={currentCall.isSpeakerOn ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, currentCall.isSpeakerOn && styles.controlLabelActive]}>
{currentCall.isSpeakerOn ? '免提' : '听筒'}
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
style={styles.endCallButton}
onPress={() => endCall('hangup')}
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 styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFill,
backgroundColor: '#1A1A2E',
zIndex: 9999,
},
background: {
...StyleSheet.absoluteFill,
backgroundColor: '#1A1A2E',
},
topBar: {
position: 'absolute',
top: 50,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
height: 44,
zIndex: 100,
},
topButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
},
centerArea: {
position: 'absolute',
top: '28%',
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.absoluteFill,
backgroundColor: '#1A1A2E',
},
localVideoContainer: {
position: 'absolute',
top: 100,
right: 20,
width: 120,
height: 160,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#2A2A4E',
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.2)',
zIndex: 50,
},
localVideo: {
width: '100%',
height: '100%',
},
videoOverlayInfo: {
position: 'absolute',
top: 100,
left: 0,
right: 0,
alignItems: 'center',
zIndex: 50,
},
videoPeerName: {
fontSize: 18,
fontWeight: '600',
color: '#FFFFFF',
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
videoStatus: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.8)',
marginTop: 4,
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
controls: {
position: 'absolute',
bottom: 60,
left: 0,
right: 0,
alignItems: 'center',
zIndex: 100,
},
controlRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: 24,
marginBottom: 24,
},
controlButton: {
alignItems: 'center',
width: 70,
},
controlCircle: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(255, 255, 255, 0.12)',
justifyContent: 'center',
alignItems: 'center',
},
controlCircleActive: {
backgroundColor: '#FFFFFF',
},
controlButtonActive: {},
controlLabel: {
fontSize: 11,
color: 'rgba(255, 255, 255, 0.55)',
marginTop: 8,
textAlign: 'center',
},
controlLabelActive: {
color: '#FFFFFF',
fontWeight: '500',
},
endCallButton: {
alignItems: 'center',
},
endCallCircle: {
width: 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;

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