Compare commits
160 Commits
master
...
feature/pu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e79dbf655 | ||
|
|
a921aacefd | ||
|
|
96e8de18bf | ||
|
|
b2979311bb | ||
|
|
9b5e76b310 | ||
|
|
b05da4e4a6 | ||
|
|
641de98dc1 | ||
|
|
33a9c2fad1 | ||
|
|
2195fe2c2b | ||
|
|
03a735b6ac | ||
|
|
c06463f576 | ||
|
|
c46260e4c0 | ||
|
|
4c4d28f0e2 | ||
|
|
a31225dce2 | ||
|
|
b0b868593d | ||
|
|
ed06c10f25 | ||
|
|
ce56824c2e | ||
|
|
2ae5999940 | ||
|
|
edde8f1c30 | ||
|
|
1012337e57 | ||
|
|
f3d54a3f4c | ||
|
|
d8ef51fa13 | ||
|
|
97477c3471 | ||
|
|
1e05d2bd54 | ||
|
|
afbbee337d | ||
|
|
b15e0c0b0b | ||
|
|
5c81795d39 | ||
|
|
1f7e25349f | ||
|
|
49e94fc056 | ||
|
|
cabac94aae | ||
|
|
c6d65fe545 | ||
|
|
f79415591d | ||
|
|
3cc3695d0e | ||
|
|
765fd8cce9 | ||
|
|
2e2f6e3467 | ||
|
|
2e6912dddf | ||
|
|
d6a94c7b4d | ||
|
|
8ee6e77cb4 | ||
|
|
52d2581dda | ||
|
|
e7eae716be | ||
|
|
ad06881b85 | ||
|
|
f39288f401 | ||
|
|
70ab00795a | ||
|
|
72c30ed156 | ||
|
|
b86fe324f5 | ||
|
|
313820ed20 | ||
|
|
f4db4eb1ed | ||
|
|
7a17323e8d | ||
|
|
4fde3e403a | ||
|
|
fb67fb6d5b | ||
|
|
404b3fabe7 | ||
|
|
d8d2b03f94 | ||
|
|
ea2cf7bcff | ||
|
|
4308077232 | ||
|
|
cbe708b53b | ||
|
|
7e0436a799 | ||
|
|
48f31e6617 | ||
|
|
7c7aaf9108 | ||
|
|
1d9c312c6c | ||
|
|
d42083a4aa | ||
|
|
d0ea2c5aea | ||
|
|
81ee9ba75b | ||
|
|
d4c3e1f268 | ||
|
|
ea9e51b0b0 | ||
|
|
7f606e14dd | ||
|
|
64493baf7f | ||
|
|
d879eea7d1 | ||
|
|
e18cac2dca | ||
|
|
3196972596 | ||
|
|
f5f9c3a619 | ||
|
|
c0ebebe3da | ||
|
|
8a271b2376 | ||
|
|
4213d13b8f | ||
|
|
490a99ab3c | ||
|
|
0f289e3182 | ||
|
|
d2120a257d | ||
|
|
cf9feeae68 | ||
|
|
2432f7bc1e | ||
|
|
be0c304711 | ||
|
|
b7ce9e3b9a | ||
|
|
296e649fbb | ||
|
|
88d9ffe9da | ||
|
|
9745d2209f | ||
|
|
2015a1e9dd | ||
|
|
ba89b3dc94 | ||
|
|
0e4306c3ac | ||
|
|
f478fa3fd3 | ||
|
|
e0b43ce60d | ||
|
|
0e2945b86b | ||
|
|
e519346261 | ||
|
|
1989cc8519 | ||
|
|
6cbd2092ac | ||
|
|
5815f1a5f7 | ||
|
|
e9d7098ad0 | ||
|
|
2dc6936aac | ||
|
|
5fa5403d6a | ||
|
|
de0afa93a1 | ||
|
|
eccfd67550 | ||
|
|
6b91a7ead1 | ||
|
|
a6a4198ac5 | ||
|
|
19054d64b3 | ||
|
|
f16f001f6c | ||
|
|
ad19bc2af7 | ||
|
|
e0d28535f4 | ||
|
|
738e75a79d | ||
|
|
b2c3d5e54e | ||
|
|
eb4e1c080d | ||
|
|
dcae47218f | ||
|
|
437dab3b6e | ||
|
|
1c3797ea7d | ||
|
|
30c493c88f | ||
|
|
b16759a147 | ||
|
|
6910fdec70 | ||
|
|
c00b915e5f | ||
|
|
57d7c7405c | ||
|
|
4f31926eb5 | ||
|
|
fe6a03da5d | ||
|
|
2adc9360a5 | ||
|
|
6610d2f173 | ||
|
|
4b5ce1ba21 | ||
|
|
6f84e17772 | ||
|
|
9bbed8cf5e | ||
|
|
be8f6de8cf | ||
|
|
96a5207cf8 | ||
|
|
25194313ae | ||
|
|
445c1c5561 | ||
|
|
accf7c04e8 | ||
|
|
542d385d08 | ||
|
|
82c2970a85 | ||
|
|
189b977fac | ||
|
|
69717ea407 | ||
|
|
775deeb9c4 | ||
|
|
e8651215f7 | ||
|
|
72842352d9 | ||
|
|
c771bd9755 | ||
|
|
5614b4078a | ||
|
|
20e9d69540 | ||
|
|
259de04f3e | ||
|
|
94c11062f0 | ||
|
|
1bee7ea551 | ||
|
|
2ef267a897 | ||
|
|
774b5c4b47 | ||
|
|
e0ee29caf8 | ||
|
|
584d98307c | ||
| e969e5bad4 | |||
| ebb0c003e3 | |||
|
|
3c071957ce | ||
|
|
fa10ef5116 | ||
|
|
7c33409624 | ||
|
|
f6176c945b | ||
|
|
b19a2ced6f | ||
|
|
db7885086f | ||
|
|
ba99900624 | ||
|
|
4b89b50006 | ||
|
|
b6583e07c8 | ||
|
|
6d1514b2d1 | ||
|
|
d280ad1656 | ||
|
|
c903990aaf | ||
|
|
405cd271db | ||
|
|
9529ea39c4 |
15
.claude/settings.local.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); deps={**d.get\\('dependencies',{}\\), **d.get\\('devDependencies',{}\\)}; [print\\(f'{k}: {v}'\\) for k,v in deps.items\\(\\) if 'navigation' in k or 'tab' in k.lower\\(\\)]\")",
|
||||
"Bash(npx tsc *)",
|
||||
"Bash(echo \"EXIT: $?\")",
|
||||
"Bash(npm view *)",
|
||||
"Bash(npx patch-package *)",
|
||||
"Bash(rm -rf node_modules/expo-router)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(python -c \"import struct, os; paths=['D:/codes/carrot_bbs/frontend/assets/splash-icon.png','D:/codes/carrot_bbs/frontend/assets/icon.png','D:/codes/carrot_bbs/frontend/assets/android-icon-foreground.png','D:/codes/carrot_bbs/frontend/assets/android-icon-background.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png'];\\\\nfor p in paths:\\\\n try:\\\\n with open\\(p,'rb'\\) as f: data=f.read\\(33\\)\\\\n sig=data[:8]; w=h=None\\\\n if sig==b'\\\\\\\\x89PNG\\\\\\\\r\\\\\\\\n\\\\\\\\x1a\\\\\\\\n': w,h=struct.unpack\\('>II', data[16:24]\\)\\\\n print\\(f'{p} | exists={os.path.exists\\(p\\)} | bytes={os.path.getsize\\(p\\)} | png={sig==b\"\\\\\\\\x89PNG\\\\\\\\r\\\\\\\\n\\\\\\\\x1a\\\\\\\\n\"} | size={w}x{h}'\\)\\\\n except Exception as e:\\\\n print\\(f'{p} | ERROR {e}'\\)\")",
|
||||
"Bash(python -c 'import struct, os; paths=[\"D:/codes/carrot_bbs/frontend/assets/splash-icon.png\",\"D:/codes/carrot_bbs/frontend/assets/icon.png\",\"D:/codes/carrot_bbs/frontend/assets/android-icon-foreground.png\",\"D:/codes/carrot_bbs/frontend/assets/android-icon-background.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png\"]; sig_png=b\"\\\\x89PNG\\\\r\\\\n\\\\x1a\\\\n\"; [print\\(f\"{p} | bytes={os.path.getsize\\(p\\)} | png={open\\(p,\"rb\"\\).read\\(8\\)==sig_png} | size={struct.unpack\\(\">II\", open\\(p,\"rb\"\\).read\\(24\\)[16:24]\\) if open\\(p,\"rb\"\\).read\\(8\\)==sig_png else None}\"\\) for p in paths]')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,40 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Source control
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Expo caches
|
||||
.expo
|
||||
.expo-shared
|
||||
|
||||
# Build artifacts
|
||||
dist
|
||||
dist-web
|
||||
dist-android
|
||||
dist-ios
|
||||
dist-android-update.zip
|
||||
dist-ios-update.zip
|
||||
android
|
||||
ios
|
||||
web-build
|
||||
|
||||
# Native credentials (never ship into image)
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
|
||||
# Misc
|
||||
screenshots
|
||||
*.log
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
coverage
|
||||
__tests__
|
||||
|
||||
@@ -21,16 +21,18 @@ 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-android:
|
||||
ota:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_ota == 'true')
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [android, ios]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -39,7 +41,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '25.6.1'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -47,48 +48,48 @@ jobs:
|
||||
- name: Resolve runtime version
|
||||
id: runtime
|
||||
run: |
|
||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
|
||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.runtimeVersion")"
|
||||
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
||||
|
||||
- name: Export Android update bundle
|
||||
- name: Export update bundle
|
||||
run: |
|
||||
rm -rf dist-android dist-android-update.zip
|
||||
npx expo export --platform android --output-dir dist-android
|
||||
npx expo config --type public --json > dist-android/expoConfig.json
|
||||
rm -rf dist-${{ matrix.platform }} dist-${{ matrix.platform }}-update.zip
|
||||
npx expo export --platform ${{ matrix.platform }} --output-dir dist-${{ matrix.platform }}
|
||||
npx expo config --type public --json > dist-${{ matrix.platform }}/expoConfig.json
|
||||
|
||||
- name: Archive Android update bundle
|
||||
- name: Archive update bundle
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile('dist-android-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, _, files in os.walk('dist-android'):
|
||||
python -c "
|
||||
import os, zipfile
|
||||
p = '${{ matrix.platform }}'
|
||||
dist = f'dist-{p}'
|
||||
with zipfile.ZipFile(f'dist-{p}-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, _, files in os.walk(dist):
|
||||
for name in files:
|
||||
src = os.path.join(root, name)
|
||||
arc = os.path.relpath(src, 'dist-android')
|
||||
arc = os.path.relpath(src, dist)
|
||||
zf.write(src, arc)
|
||||
PY
|
||||
"
|
||||
|
||||
- name: Publish OTA
|
||||
env:
|
||||
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
||||
run: |
|
||||
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
||||
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${OTA_PLATFORM}" \
|
||||
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${{ matrix.platform }}" \
|
||||
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
||||
-H "Content-Type: application/zip" \
|
||||
--data-binary @"dist-android-update.zip"
|
||||
--data-binary @"dist-${{ matrix.platform }}-update.zip"
|
||||
|
||||
- name: Verify OTA manifest
|
||||
run: |
|
||||
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
|
||||
-H "expo-platform: ${OTA_PLATFORM}" \
|
||||
-H "expo-platform: ${{ matrix.platform }}" \
|
||||
-H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
|
||||
-H "expo-protocol-version: 1" \
|
||||
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\\\"id\\\":.*\\\"extra\\\":\\{.*\\}\\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
|
||||
LOCAL="$(python -c "import json; m=json.load(open('dist-android/metadata.json')); print(m['fileMetadata']['android']['bundle'].split('/')[-1])")"
|
||||
| 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])")"
|
||||
echo "Remote bundle: ${REMOTE}"
|
||||
echo "Local bundle: ${LOCAL}"
|
||||
test "${REMOTE}" = "${LOCAL}"
|
||||
@@ -98,12 +99,9 @@ jobs:
|
||||
container:
|
||||
image: reactnativecommunity/react-native-android:latest
|
||||
env:
|
||||
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=4 -Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
|
||||
_JAVA_OPTIONS: "-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
|
||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=false -Dorg.gradle.workers.max=1"
|
||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||
NODE_ENV: "production"
|
||||
NDK_NUM_JOBS: "4"
|
||||
CMAKE_BUILD_PARALLEL_LEVEL: "4"
|
||||
GRADLE_USER_HOME: /root/.gradle
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -121,50 +119,28 @@ 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 Aliyun Maven mirror
|
||||
- name: Configure Gradle with signing
|
||||
run: |
|
||||
cd android
|
||||
|
||||
# Update settings.gradle with Aliyun Maven mirror
|
||||
# Decode Android signing keystore
|
||||
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > app/withyou-release-key.keystore
|
||||
|
||||
# Update settings.gradle
|
||||
cat > settings.gradle << 'SETTINGS_EOF'
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven { url 'https://developer.huawei.com/repo/' }
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||
}
|
||||
def reactNativeGradlePlugin = new File(
|
||||
providers.exec {
|
||||
@@ -187,6 +163,16 @@ jobs:
|
||||
plugins {
|
||||
id("com.facebook.react.settings")
|
||||
id("expo-autolinking-settings")
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
maven { url 'https://developer.huawei.com/repo/' }
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url 'https://www.jitpack.io' }
|
||||
}
|
||||
}
|
||||
|
||||
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
||||
@@ -198,7 +184,7 @@ jobs:
|
||||
}
|
||||
expoAutolinking.useExpoModules()
|
||||
|
||||
rootProject.name = '萝卜社区'
|
||||
rootProject.name = '威友'
|
||||
|
||||
expoAutolinking.useExpoVersionCatalog()
|
||||
|
||||
@@ -206,21 +192,22 @@ jobs:
|
||||
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
||||
SETTINGS_EOF
|
||||
|
||||
# Update build.gradle with Aliyun Maven mirror
|
||||
# Update build.gradle
|
||||
cat > build.gradle << 'BUILD_EOF'
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
maven { url 'https://developer.huawei.com/repo/' }
|
||||
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.huawei.agconnect:agcp:1.9.1.301') {
|
||||
exclude group: 'com.huawei.agconnect', module: 'agconnect-apms-plugin'
|
||||
}
|
||||
classpath('com.google.gms:google-services:4.4.4')
|
||||
classpath('com.android.tools.build:gradle:8.12.0')
|
||||
classpath('com.facebook.react:react-native-gradle-plugin')
|
||||
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
|
||||
}
|
||||
@@ -228,12 +215,10 @@ jobs:
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
maven { url 'https://developer.huawei.com/repo/' }
|
||||
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' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,13 +226,14 @@ jobs:
|
||||
apply plugin: "com.facebook.react.rootproject"
|
||||
BUILD_EOF
|
||||
|
||||
# Update gradle.properties
|
||||
# Update gradle.properties (without secrets for better cache hit rate)
|
||||
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
|
||||
org.gradle.parallel=false
|
||||
org.gradle.configureondemand=false
|
||||
org.gradle.workers.max=1
|
||||
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
kotlin.daemon.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m
|
||||
android.enableJetifier=false
|
||||
android.useAndroidX=true
|
||||
hermesEnabled=true
|
||||
@@ -257,21 +243,72 @@ jobs:
|
||||
expo.gif.enabled=true
|
||||
expo.webp.enabled=true
|
||||
expo.webp.animated=false
|
||||
ndkVersion=27.1.12297006
|
||||
expo.useLegacyPackaging=false
|
||||
systemProp.org.gradle.internal.http.connectionTimeout=30000
|
||||
systemProp.org.gradle.internal.http.socketTimeout=30000
|
||||
PROPS_EOF
|
||||
|
||||
# Append signing properties (secrets appended, not cached)
|
||||
cat >> gradle.properties << SIGNING_PROPS
|
||||
MYAPP_UPLOAD_STORE_FILE=withyou-release-key.keystore
|
||||
MYAPP_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
MYAPP_UPLOAD_KEY_ALIAS=withyou-key
|
||||
MYAPP_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
SIGNING_PROPS
|
||||
|
||||
# Verify signing config in app/build.gradle
|
||||
echo "=== app/build.gradle signing section ==="
|
||||
grep -A 20 'signingConfigs' app/build.gradle || echo "signingConfigs not found"
|
||||
|
||||
- name: Build Android release APK (arm64 only)
|
||||
run: |
|
||||
cd android
|
||||
chmod +x gradlew
|
||||
./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=4 --parallel
|
||||
taskset -c 0-7 ./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=1 -Dkotlin.daemon.jvm.options="-Xmx2g,XX:MaxMetaspaceSize=1g"
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: carrot-bbs-android-release-apk
|
||||
name: withyou-android-release-apk
|
||||
path: android/app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
- name: Resolve runtime version
|
||||
id: runtime
|
||||
run: |
|
||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.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:
|
||||
@@ -314,8 +351,6 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
provenance: false
|
||||
cache-from: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache
|
||||
cache-to: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache,mode=max
|
||||
|
||||
- name: Show image tags
|
||||
run: |
|
||||
|
||||
10
.idea/.gitignore
generated
vendored
@@ -1,10 +0,0 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
# Screenshots
|
||||
screenshots/
|
||||
9
.idea/frontend.iml
generated
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
448
.idea/gradle.xml
generated
@@ -1,448 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@expo/dom-webview/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/@expo/dom-webview/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@expo/log-box/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/@expo/log-box/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@react-native-async-storage/async-storage/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/@react-native-async-storage/async-storage/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@react-native/gradle-plugin" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/@react-native/gradle-plugin" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-application/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-application/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-background-fetch/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-background-fetch/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-constants/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-constants/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-constants/scripts" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-constants/scripts" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-client/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-dev-client/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-launcher/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-dev-launcher/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-launcher/expo-dev-launcher-gradle-plugin" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-dev-launcher/expo-dev-launcher-gradle-plugin" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-menu-interface/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-dev-menu-interface/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-menu/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-dev-menu/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-eas-client/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-eas-client/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-file-system/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-file-system/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-font/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-font/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-haptics/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-haptics/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image-loader/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-image-loader/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image-picker/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-image-picker/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-image/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-json-utils/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-json-utils/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-linear-gradient/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-linear-gradient/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-linking/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-linking/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-manifests/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-manifests/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-media-library/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-media-library/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-autolinking/android/expo-gradle-plugin" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-modules-autolinking/android/expo-gradle-plugin" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-core/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-modules-core/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-core/expo-module-gradle-plugin" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-modules-core/expo-module-gradle-plugin" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-notifications/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-notifications/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-router/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-router/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-sqlite/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-sqlite/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-structured-headers/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-structured-headers/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-system-ui/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-system-ui/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-task-manager/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-task-manager/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates-interface/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-updates-interface/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-updates/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates/expo-updates-gradle-plugin" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-updates/expo-updates-gradle-plugin" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-video/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-video/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-asset/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-asset/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-keep-awake/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-keep-awake/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-gesture-handler/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native-gesture-handler/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-pager-view/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native-pager-view/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-reanimated/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native-reanimated/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-safe-area-context/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native-safe-area-context/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-screens/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native-screens/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-worklets/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native-worklets/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/unimodules-app-loader/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/unimodules-app-loader/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/misc.xml
generated
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/frontend.iml" filepath="$PROJECT_DIR$/.idea/frontend.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
376
.kilo/package-lock.json
generated
Normal file
@@ -0,0 +1,376 @@
|
||||
{
|
||||
"name": ".kilo",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@kilocode/plugin": "7.2.31"
|
||||
}
|
||||
},
|
||||
"node_modules/@kilocode/plugin": {
|
||||
"version": "7.2.31",
|
||||
"resolved": "https://registry.npmmirror.com/@kilocode/plugin/-/plugin-7.2.31.tgz",
|
||||
"integrity": "sha512-KmKTTIly7hRlJdXKhqZ/j/brvTPh0z0UTjWSjJWq5fqf4pATgYGn7G0g3ZjILnN7MUkkZXuljgqExTEeQJHGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "7.2.31",
|
||||
"effect": "4.0.0-beta.48",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.100",
|
||||
"@opentui/solid": ">=0.1.100"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@kilocode/sdk": {
|
||||
"version": "7.2.31",
|
||||
"resolved": "https://registry.npmmirror.com/@kilocode/sdk/-/sdk-7.2.31.tgz",
|
||||
"integrity": "sha512-Sx05yv+3TIlc6M4Ze+YGgKCLoIg8B0WRE15JXDriVncT+wz7M6+e+4mjNWkwfsuywdeOTYPfeFViqX8iO7ckKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
||||
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "4.0.0-beta.48",
|
||||
"resolved": "https://registry.npmmirror.com/effect/-/effect-4.0.0-beta.48.tgz",
|
||||
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"fast-check": "^4.6.0",
|
||||
"find-my-way-ts": "^0.1.6",
|
||||
"ini": "^6.0.0",
|
||||
"kubernetes-types": "^1.30.0",
|
||||
"msgpackr": "^1.11.9",
|
||||
"multipasta": "^0.2.7",
|
||||
"toml": "^4.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"yaml": "^2.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/fast-check/-/fast-check-4.7.0.tgz",
|
||||
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way-ts": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
|
||||
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/ini/-/ini-6.0.0.tgz",
|
||||
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/kubernetes-types": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmmirror.com/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
|
||||
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "1.11.12",
|
||||
"resolved": "https://registry.npmmirror.com/msgpackr/-/msgpackr-1.11.12.tgz",
|
||||
"integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
||||
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/multipasta": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmmirror.com/multipasta/-/multipasta-0.2.7.tgz",
|
||||
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/pure-rand/-/pure-rand-8.4.0.tgz",
|
||||
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/toml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/toml/-/toml-4.1.1.tgz",
|
||||
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-13.0.1.tgz",
|
||||
"integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.4.tgz",
|
||||
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
# 架构修复方案文档
|
||||
|
||||
## 1. 重构目标
|
||||
|
||||
### 1.1 核心原则
|
||||
- **单一职责**:每个模块只负责一个明确的功能
|
||||
- **依赖倒置**:高层模块不依赖低层模块,都依赖抽象
|
||||
- **开闭原则**:对扩展开放,对修改关闭
|
||||
|
||||
### 1.2 目标架构
|
||||
```
|
||||
src/
|
||||
├── core/ # 核心业务逻辑(框架无关)
|
||||
│ ├── entities/ # 业务实体
|
||||
│ ├── repositories/ # 仓库接口
|
||||
│ └── usecases/ # 业务用例
|
||||
├── data/ # 数据层
|
||||
│ ├── datasources/ # 数据源实现(API、DB、Cache)
|
||||
│ ├── repositories/ # 仓库实现
|
||||
│ └── mappers/ # 数据映射
|
||||
├── presentation/ # 表现层
|
||||
│ ├── components/ # UI组件
|
||||
│ ├── screens/ # 屏幕组件
|
||||
│ ├── stores/ # 状态管理
|
||||
│ └── hooks/ # 自定义hooks
|
||||
└── infrastructure/ # 基础设施
|
||||
├── navigation/ # 导航
|
||||
├── theme/ # 主题
|
||||
└── services/ # 外部服务
|
||||
```
|
||||
|
||||
## 2. 具体修复方案
|
||||
|
||||
### 2.1 messageManager.ts 重构方案
|
||||
|
||||
#### 现状问题
|
||||
- 1500+行,上帝对象
|
||||
- 混合了 WebSocket、数据库、状态管理、UI通知
|
||||
- 与SQLite深度耦合
|
||||
|
||||
#### 目标结构
|
||||
```
|
||||
src/data/
|
||||
├── datasources/
|
||||
│ ├── WebSocketClient.ts # WebSocket连接管理
|
||||
│ └── MessageDataSource.ts # 消息数据源
|
||||
├── repositories/
|
||||
│ └── MessageRepository.ts # 消息仓库实现
|
||||
└── mappers/
|
||||
└── MessageMapper.ts # 消息数据映射
|
||||
|
||||
src/core/
|
||||
├── entities/
|
||||
│ └── Message.ts # 消息实体
|
||||
├── repositories/
|
||||
│ └── IMessageRepository.ts # 仓库接口
|
||||
└── usecases/
|
||||
├── ProcessMessageUseCase.ts
|
||||
├── SyncMessagesUseCase.ts
|
||||
└── UpdateReadReceiptUseCase.ts
|
||||
|
||||
src/presentation/stores/
|
||||
└── messageStore.ts # 简化的消息状态管理
|
||||
```
|
||||
|
||||
#### 重构步骤
|
||||
1. 提取 WebSocketClient - 纯连接管理
|
||||
2. 创建 MessageRepository - 数据访问抽象
|
||||
3. 创建 UseCases - 业务逻辑
|
||||
4. 重写 messageStore - 仅负责UI状态
|
||||
5. 删除旧的 messageManager.ts
|
||||
|
||||
### 2.2 乐观更新工具函数方案
|
||||
|
||||
#### 现状问题
|
||||
- userStore.ts 中重复6+次相同模式
|
||||
- 代码冗余,难以维护
|
||||
|
||||
#### 目标API
|
||||
```typescript
|
||||
// utils/optimisticUpdate.ts
|
||||
export interface OptimisticUpdateOptions<T, R> {
|
||||
store: StoreApi<any>;
|
||||
getCurrentState: () => T;
|
||||
optimisticUpdate: (current: T) => T;
|
||||
apiCall: () => Promise<R>;
|
||||
onSuccess?: (result: R, current: T) => T;
|
||||
onError?: (error: any, original: T) => void;
|
||||
}
|
||||
|
||||
export async function optimisticUpdate<T, R>(
|
||||
options: OptimisticUpdateOptions<T, R>
|
||||
): Promise<R>
|
||||
```
|
||||
|
||||
#### 使用示例
|
||||
```typescript
|
||||
// 重构前(重复代码)
|
||||
set(state => ({ posts: updatedPosts }));
|
||||
try {
|
||||
const result = await postService.like(postId);
|
||||
// ...
|
||||
} catch (error) {
|
||||
set(state => ({ posts: originalPosts }));
|
||||
}
|
||||
|
||||
// 重构后(简洁)
|
||||
await optimisticUpdate({
|
||||
store,
|
||||
getCurrentState: () => store.getState().posts,
|
||||
optimisticUpdate: (posts) => updatePostLikes(posts, postId, true),
|
||||
apiCall: () => postService.like(postId),
|
||||
onSuccess: (result, posts) => updatePostWithServerData(posts, result),
|
||||
});
|
||||
```
|
||||
|
||||
### 2.3 useResponsive.ts 拆分方案
|
||||
|
||||
#### 现状问题
|
||||
- 485行,43个导出
|
||||
- 功能混杂:断点检测、响应式值计算、平台检测
|
||||
|
||||
#### 目标结构
|
||||
```
|
||||
src/presentation/hooks/responsive/
|
||||
├── useBreakpoint.ts # 断点检测
|
||||
├── useResponsiveValue.ts # 响应式值映射
|
||||
├── useOrientation.ts # 方向检测
|
||||
├── usePlatform.ts # 平台检测
|
||||
├── useScreenSize.ts # 屏幕尺寸
|
||||
└── index.ts # 统一导出
|
||||
```
|
||||
|
||||
#### 各Hook职责
|
||||
- `useBreakpoint`: 返回当前断点 (mobile/tablet/desktop/wide)
|
||||
- `useResponsiveValue`: 根据断点返回不同值
|
||||
- `useOrientation`: 返回 portrait/landscape
|
||||
- `usePlatform`: 返回 ios/android/web
|
||||
- `useScreenSize`: 返回 width/height
|
||||
|
||||
### 2.4 MainNavigator 解耦方案
|
||||
|
||||
#### 现状问题
|
||||
- 1118行,直接依赖store
|
||||
- 混合了导航配置和业务逻辑
|
||||
|
||||
#### 目标结构
|
||||
```
|
||||
src/infrastructure/navigation/
|
||||
├── MainNavigator.tsx # 简化后的导航器
|
||||
├── AuthNavigator.tsx # 认证导航
|
||||
├── TabNavigator.tsx # 标签导航
|
||||
├── navigationService.ts # 导航服务(解耦层)
|
||||
├── navigationTypes.ts # 类型定义
|
||||
└── hooks/
|
||||
└── useNavigationState.ts # 导航状态hook
|
||||
```
|
||||
|
||||
#### 解耦策略
|
||||
1. 创建 NavigationService - 提供导航操作的抽象
|
||||
2. Store通过NavigationService触发导航,而非直接操作
|
||||
3. MainNavigator只负责渲染,不处理业务逻辑
|
||||
|
||||
### 2.5 Repository 层创建方案
|
||||
|
||||
#### 现状问题
|
||||
- 服务层直接操作数据库
|
||||
- 没有明确的数据访问边界
|
||||
|
||||
#### 目标结构
|
||||
```
|
||||
src/data/repositories/
|
||||
├── MessageRepository.ts # 消息数据访问
|
||||
├── PostRepository.ts # 帖子数据访问
|
||||
├── UserRepository.ts # 用户数据访问
|
||||
└── interfaces/
|
||||
├── IMessageRepository.ts
|
||||
├── IPostRepository.ts
|
||||
└── IUserRepository.ts
|
||||
```
|
||||
|
||||
#### 职责划分
|
||||
- **Repository**: 数据访问抽象,定义接口
|
||||
- **DataSource**: 具体数据源实现(API、SQLite、Cache)
|
||||
- **Service**: 业务逻辑编排,使用Repository
|
||||
|
||||
## 3. 实施计划
|
||||
|
||||
### 阶段1:基础设施(第1-2天)
|
||||
- [ ] 创建目录结构
|
||||
- [ ] 实现乐观更新工具函数
|
||||
- [ ] 拆分 useResponsive hooks
|
||||
|
||||
### 阶段2:数据层重构(第3-5天)
|
||||
- [ ] 创建 Repository 接口和实现
|
||||
- [ ] 创建 DataSource 层
|
||||
- [ ] 迁移数据库操作到 Repository
|
||||
|
||||
### 阶段3:核心业务重构(第6-10天)
|
||||
- [ ] 重构 messageManager → UseCases + Store
|
||||
- [ ] 创建 WebSocketClient
|
||||
- [ ] 实现消息同步逻辑
|
||||
|
||||
### 阶段4:表现层重构(第11-12天)
|
||||
- [ ] 解耦 MainNavigator
|
||||
- [ ] 重构 userStore 使用乐观更新工具
|
||||
- [ ] 更新所有屏幕组件
|
||||
|
||||
### 阶段5:测试与优化(第13-14天)
|
||||
- [ ] 单元测试
|
||||
- [ ] 集成测试
|
||||
- [ ] 性能优化
|
||||
|
||||
## 4. 风险与应对
|
||||
|
||||
| 风险 | 影响 | 应对措施 |
|
||||
|-----|------|---------|
|
||||
| 重构引入bug | 高 | 每个模块重构后必须测试,保持API兼容 |
|
||||
| 开发时间超期 | 中 | 分阶段交付,优先核心功能 |
|
||||
| 团队成员不熟悉新架构 | 中 | 编写详细文档,代码审查 |
|
||||
| 性能下降 | 低 | 添加性能监控,及时优化 |
|
||||
|
||||
## 5. 验收标准
|
||||
|
||||
- [ ] messageManager.ts 被删除,功能拆分到多个小模块
|
||||
- [ ] userStore.ts 中无重复乐观更新代码
|
||||
- [ ] useResponsive.ts 拆分为5+个专注hook
|
||||
- [ ] MainNavigator.tsx 行数 < 300
|
||||
- [ ] 所有单元测试通过
|
||||
- [ ] 无TypeScript错误
|
||||
- [ ] 性能不劣化(启动时间、内存占用)
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS builder
|
||||
FROM node:25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -6,10 +6,11 @@ COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npx expo export --platform web --output-dir dist-web
|
||||
RUN node /app/node_modules/.bin/expo export --platform web --output-dir dist-web
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist-web /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
const appJson = require('./app.json');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const isDevVariant = process.env.APP_VARIANT === 'dev';
|
||||
const releaseApiBaseUrl = 'https://bbs.littlelan.cn/api/v1';
|
||||
const releaseApiBaseUrl = 'https://withyou.littlelan.cn/api/v1';
|
||||
const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
|
||||
const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1';
|
||||
|
||||
function 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) {
|
||||
@@ -23,27 +40,72 @@ 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: {
|
||||
policy: 'appVersion',
|
||||
},
|
||||
// runtimeVersion 用 build number(commit count + 偏移):单调递增、纯数字、与 version (语义版本) 解耦
|
||||
// 字符串形式,等价于 policy: 'custom'
|
||||
runtimeVersion: buildNumber,
|
||||
updates: {
|
||||
...(expo.updates || {}),
|
||||
url: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
||||
checkAutomatically: 'ON_LOAD',
|
||||
fallbackToCacheTimeout: 0,
|
||||
},
|
||||
ios: expo.ios,
|
||||
ios: {
|
||||
...expo.ios,
|
||||
buildNumber: buildNumber,
|
||||
},
|
||||
android: {
|
||||
...expo.android,
|
||||
package: 'skin.carrot.bbs',
|
||||
package: 'cn.qczlit.withyou',
|
||||
versionCode: parseInt(buildNumber, 10),
|
||||
},
|
||||
// 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',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
101
app.json
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "萝卜社区",
|
||||
"name": "威友",
|
||||
"slug": "qojo",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.1",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"scheme": "carrotbbs",
|
||||
"scheme": "withyou",
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
@@ -15,28 +15,31 @@
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"NSMicrophoneUsageDescription": "允许威友访问您的麦克风以进行语音通话",
|
||||
"NSCameraUsageDescription": "允许威友访问您的相机以进行视频通话",
|
||||
"UIBackgroundModes": [
|
||||
"fetch",
|
||||
"remote-notification",
|
||||
"fetch",
|
||||
"remote-notification"
|
||||
"audio"
|
||||
],
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
},
|
||||
"bundleIdentifier": "skin.carrot.bbs"
|
||||
"bundleIdentifier": "cn.qczlit.weiyou"
|
||||
},
|
||||
"android": {
|
||||
"googleServicesFile": "./google-services.json",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"backgroundColor": "#0570F9",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "skin.carrot.bbs",
|
||||
"versionCode": 6,
|
||||
"package": "cn.qczlit.withyou",
|
||||
"permissions": [
|
||||
"VIBRATE",
|
||||
"RECORD_AUDIO",
|
||||
"CAMERA",
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
"WAKE_LOCK",
|
||||
"READ_EXTERNAL_STORAGE",
|
||||
@@ -49,16 +52,32 @@
|
||||
"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.READ_MEDIA_AUDIO",
|
||||
"android.permission.ACCESS_NETWORK_STATE",
|
||||
"android.permission.ACCESS_WIFI_STATE",
|
||||
"android.permission.POST_NOTIFICATIONS"
|
||||
]
|
||||
},
|
||||
"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",
|
||||
{
|
||||
@@ -72,34 +91,29 @@
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "允许萝卜社区访问您的相机以扫描二维码"
|
||||
"cameraPermission": "允许威友访问您的相机以扫描二维码"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-notifications",
|
||||
"expo-background-task",
|
||||
{
|
||||
"sounds": []
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-background-fetch",
|
||||
{
|
||||
"minimumInterval": 900
|
||||
"minimumInterval": 15
|
||||
}
|
||||
],
|
||||
"./plugins/withRemoveAutoStart",
|
||||
[
|
||||
"expo-media-library",
|
||||
{
|
||||
"photosPermission": "允许萝卜社区访问您的照片以发布内容",
|
||||
"savePhotosPermission": "允许萝卜社区保存照片到您的相册",
|
||||
"isAccessMediaLocationEnabled": true
|
||||
"photosPermission": "允许威友访问您的照片以发布内容",
|
||||
"savePhotosPermission": "允许威友保存照片到您的相册",
|
||||
"isAccessMediaLocationEnabled": false
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "允许萝卜社区访问您的照片以选择图片",
|
||||
"cameraPermission": "允许萝卜社区访问您的相机以拍摄图片",
|
||||
"photosPermission": "允许威友访问您的照片以选择图片",
|
||||
"cameraPermission": "允许威友访问您的相机以拍摄图片",
|
||||
"colors": {
|
||||
"cropToolbarColor": "#111827",
|
||||
"cropToolbarIconColor": "#ffffff",
|
||||
@@ -124,13 +138,44 @@
|
||||
"proguardRules": "-keep class com.google.android.exoplayer2.** { *; }"
|
||||
}
|
||||
],
|
||||
"expo-font"
|
||||
"expo-font",
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"color": "#E6F4FE",
|
||||
"defaultChannel": "default",
|
||||
"enableBackgroundRemoteNotifications": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"./plugins/withJPush",
|
||||
{
|
||||
"appKey": "2cbe4da12e2c24aa3dca4610",
|
||||
"channel": "developer-default"
|
||||
}
|
||||
],
|
||||
"./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"
|
||||
],
|
||||
"jsEngine": "hermes",
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "65540196-d37d-437b-8496-227df0317069"
|
||||
}
|
||||
},
|
||||
"jpushAppKey": "2cbe4da12e2c24aa3dca4610",
|
||||
"jpushChannel": "developer-default"
|
||||
},
|
||||
"owner": "qojo"
|
||||
}
|
||||
|
||||
@@ -1,28 +1,67 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Platform, useWindowDimensions } from 'react-native';
|
||||
import { Tabs, usePathname } from 'expo-router';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { Platform, Pressable, useWindowDimensions } from 'react-native';
|
||||
import { Tabs, usePathname, useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import type { PressableProps } from 'react-native';
|
||||
|
||||
import { useAppColors, shadows } from '../../../src/theme';
|
||||
import { BREAKPOINTS } from '../../../src/hooks/useResponsive';
|
||||
import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores';
|
||||
import { BREAKPOINTS } from '../../../src/hooks';
|
||||
import { useHomeTabBarVisibilityStore, useTotalUnreadCount, useHomeTabPressStore } from '../../../src/stores';
|
||||
import { hrefHome } from '../../../src/navigation/hrefs';
|
||||
|
||||
const TAB_BAR_HEIGHT = 56;
|
||||
const TAB_BAR_FLOATING_MARGIN = 12;
|
||||
const TAB_BAR_MARGIN = 20;
|
||||
|
||||
const TabBarButton = ({ children, style, ...rest }: PressableProps) => (
|
||||
<Pressable
|
||||
{...rest}
|
||||
style={style}
|
||||
android_ripple={null}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
export default function TabsLayout() {
|
||||
const colors = useAppColors();
|
||||
const { width } = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const unreadCount = useTotalUnreadCount();
|
||||
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
||||
const triggerHomeTabPress = useHomeTabPressStore((s) => s.triggerPress);
|
||||
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 };
|
||||
@@ -65,6 +104,7 @@ export default function TabsLayout() {
|
||||
marginHorizontal: 2,
|
||||
paddingVertical: 1,
|
||||
},
|
||||
tabBarButton: (props) => <TabBarButton {...props} />,
|
||||
tabBarShowLabel: true,
|
||||
tabBarLabelStyle: { fontSize: 11, fontWeight: '600', marginTop: -1 },
|
||||
}}
|
||||
@@ -81,6 +121,9 @@ export default function TabsLayout() {
|
||||
/>
|
||||
),
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: handleHomeTabPress,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="apps"
|
||||
@@ -94,6 +137,9 @@ export default function TabsLayout() {
|
||||
/>
|
||||
),
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: handleAppsTabPress,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="messages"
|
||||
@@ -121,6 +167,9 @@ export default function TabsLayout() {
|
||||
/>
|
||||
),
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: handleProfileTabPress,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
11
app/(app)/(tabs)/apps/materials/_layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function MaterialsStackLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="subject" />
|
||||
<Stack.Screen name="detail" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
5
app/(app)/(tabs)/apps/materials/detail.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { MaterialDetailScreen } from '../../../../../src/screens/material';
|
||||
|
||||
export default function MaterialDetailRoute() {
|
||||
return <MaterialDetailScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/apps/materials/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { MaterialsScreen } from '../../../../../src/screens/material';
|
||||
|
||||
export default function MaterialsRoute() {
|
||||
return <MaterialsScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/apps/materials/subject.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SubjectMaterialsScreen } from '../../../../../src/screens/material';
|
||||
|
||||
export default function SubjectMaterialsRoute() {
|
||||
return <SubjectMaterialsScreen />;
|
||||
}
|
||||
@@ -1,41 +1,26 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
import { AppBackButton } from '../../../../src/components/common';
|
||||
import { useAppColors } from '../../../../src/theme';
|
||||
|
||||
export default function ProfileStackLayout() {
|
||||
const router = useRouter();
|
||||
const colors = useAppColors();
|
||||
|
||||
const headerOptions = useMemo(
|
||||
() => ({
|
||||
headerStyle: { backgroundColor: colors.background.paper },
|
||||
headerTintColor: colors.text.primary,
|
||||
headerTitleStyle: { fontWeight: '600' as const },
|
||||
headerShadowVisible: false,
|
||||
headerBackTitle: '',
|
||||
}),
|
||||
[colors]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
...headerOptions,
|
||||
headerBackVisible: false,
|
||||
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="settings" options={{ headerShown: true, title: '设置' }} />
|
||||
<Stack.Screen name="edit-profile" options={{ title: '编辑资料' }} />
|
||||
<Stack.Screen name="account-security" options={{ title: '账号安全' }} />
|
||||
<Stack.Screen name="my-posts" options={{ title: '我的帖子' }} />
|
||||
<Stack.Screen name="bookmarks" options={{ title: '收藏' }} />
|
||||
<Stack.Screen name="notification-settings" options={{ title: '通知设置' }} />
|
||||
<Stack.Screen name="blocked-users" options={{ title: '黑名单' }} />
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="edit-profile" />
|
||||
<Stack.Screen name="account-security" />
|
||||
<Stack.Screen name="my-posts" />
|
||||
<Stack.Screen name="bookmarks" />
|
||||
<Stack.Screen name="notification-settings" />
|
||||
<Stack.Screen name="blocked-users" />
|
||||
<Stack.Screen name="chat-settings" />
|
||||
<Stack.Screen name="about" />
|
||||
<Stack.Screen name="verification" />
|
||||
<Stack.Screen name="data-storage" />
|
||||
<Stack.Screen name="privacy-settings" />
|
||||
<Stack.Screen name="account-deletion" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
5
app/(app)/(tabs)/profile/about.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AboutScreen } from '../../../../src/screens/profile/AboutScreen';
|
||||
|
||||
export default function AboutRoute() {
|
||||
return <AboutScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/account-deletion.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AccountDeletionScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function AccountDeletionRoute() {
|
||||
return <AccountDeletionScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/chat-settings.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ChatSettingsScreen } from '../../../../src/screens/profile/ChatSettingsScreen';
|
||||
|
||||
export default function ChatSettingsRoute() {
|
||||
return <ChatSettingsScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/data-storage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DataStorageScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function DataStorageRoute() {
|
||||
return <DataStorageScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/privacy-settings.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PrivacySettingsScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function PrivacySettingsRoute() {
|
||||
return <PrivacySettingsScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/verification.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { VerificationSettingsScreen } from '@/screens/profile/VerificationSettingsScreen';
|
||||
|
||||
export default function VerificationSettingsPage() {
|
||||
return <VerificationSettingsScreen />;
|
||||
}
|
||||
@@ -1,19 +1,54 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, View } from 'react-native';
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
|
||||
import { messageManager, useAuthStore } from '../../src/stores';
|
||||
import { useRegisterPushDevice } from '../../src/hooks';
|
||||
import { hrefAuthLogin } from '../../src/navigation/hrefs';
|
||||
import { useAppColors } from '../../src/theme';
|
||||
|
||||
export default function AppLayout() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
|
||||
const userID = useAuthStore((s) => s.currentUser?.id);
|
||||
const colors = useAppColors();
|
||||
const [verified, setVerified] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 冷启动 token 校验:仅鉴权分组 (app) 需要。
|
||||
// 公开页(/privacy、/terms)和 (auth) 分组在根布局直接渲染,不受此校验影响,
|
||||
// 因此冷启动 401 不会把公开页顶到 /login。
|
||||
fetchCurrentUser().finally(() => setVerified(true));
|
||||
}, [fetchCurrentUser]);
|
||||
|
||||
useRegisterPushDevice(isAuthenticated, userID);
|
||||
|
||||
useEffect(() => {
|
||||
messageManager.initialize();
|
||||
}, []);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Redirect href="/login" />;
|
||||
}
|
||||
|
||||
// 持久化状态显示已登录则立即渲染(后台静默校验)
|
||||
if (isAuthenticated) {
|
||||
return <AppRouteStack />;
|
||||
}
|
||||
|
||||
// 未登录且校验未完成,显示 loading
|
||||
if (!verified) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 校验完成仍未登录,跳转登录页
|
||||
return <Redirect href={hrefAuthLogin()} />;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,56 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useWindowDimensions } from 'react-native';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, View, useWindowDimensions } from 'react-native';
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell';
|
||||
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
|
||||
import { BREAKPOINTS } from '../../src/hooks/useResponsive';
|
||||
import { BREAKPOINTS } from '../../src/hooks';
|
||||
import { messageManager, useAuthStore } from '../../src/stores';
|
||||
import { hrefAuthLogin } from '../../src/navigation/hrefs';
|
||||
import { useAppColors } from '../../src/theme';
|
||||
|
||||
export default function AppLayoutWeb() {
|
||||
const { width } = useWindowDimensions();
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
|
||||
const useDesktopShell = width >= BREAKPOINTS.desktop;
|
||||
const colors = useAppColors();
|
||||
const [verified, setVerified] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 冷启动 token 校验:仅鉴权分组 (app) 需要。
|
||||
// 公开页(/privacy、/terms)和 (auth) 分组在根布局直接渲染,不受此校验影响。
|
||||
fetchCurrentUser().finally(() => setVerified(true));
|
||||
}, [fetchCurrentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
messageManager.initialize();
|
||||
}, []);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Redirect href="/login" />;
|
||||
}
|
||||
|
||||
// 持久化状态显示已登录则立即渲染(后台静默校验)
|
||||
if (isAuthenticated) {
|
||||
if (useDesktopShell) {
|
||||
return <AppDesktopShell />;
|
||||
}
|
||||
|
||||
return <AppRouteStack />;
|
||||
}
|
||||
|
||||
// 未登录且校验未完成,显示 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()} />;
|
||||
}
|
||||
|
||||
5
app/(app)/chat/message-search.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { MessageSearchScreen } from '../../../src/screens/message';
|
||||
|
||||
export default function MessageSearchRoute() {
|
||||
return <MessageSearchScreen />;
|
||||
}
|
||||
5
app/(auth)/verification-form.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { VerificationFormScreen } from '@/screens/auth/VerificationFormScreen';
|
||||
|
||||
export default function VerificationFormPage() {
|
||||
return <VerificationFormScreen />;
|
||||
}
|
||||
5
app/(auth)/verification-guide.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { VerificationGuideScreen } from '@/screens/auth/VerificationGuideScreen';
|
||||
|
||||
export default function VerificationGuidePage() {
|
||||
return <VerificationGuideScreen />;
|
||||
}
|
||||
240
app/_layout.tsx
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import '../src/polyfills';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { AppState, AppStateStatus, Platform, View, ActivityIndicator } from 'react-native';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
@@ -6,21 +7,27 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { PaperProvider } from 'react-native-paper';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import * as SystemUI from 'expo-system-ui';
|
||||
import { useFonts } from 'expo-font';
|
||||
|
||||
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
|
||||
import { registerNotificationPresentationHandler } from '@/services/notification';
|
||||
import { EventSubscriber } from '../src/infrastructure/EventSubscriber';
|
||||
import {
|
||||
ThemeBootstrap,
|
||||
useAppColors,
|
||||
usePaperThemeFromStore,
|
||||
useResolvedColorScheme,
|
||||
} from '../src/theme';
|
||||
import { AppBackButton } from '../src/components/common';
|
||||
|
||||
import AppPromptBar from '../src/components/common/AppPromptBar';
|
||||
import AppDialogHost from '../src/components/common/AppDialogHost';
|
||||
import { installAlertOverride } from '../src/services/alertOverride';
|
||||
import { useAuthStore } from '../src/stores';
|
||||
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';
|
||||
|
||||
registerNotificationPresentationHandler();
|
||||
|
||||
@@ -48,6 +55,34 @@ if (Platform.OS === 'web' && typeof document !== 'undefined') {
|
||||
}
|
||||
*:focus { outline: none !important; }
|
||||
*:focus-visible { outline: none !important; }
|
||||
/* 修复移动端滑动问题 - 仅对根容器限制 */
|
||||
html, body {
|
||||
overscroll-behavior: none;
|
||||
touch-action: manipulation;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
/* React Native Web 生成的滚动容器 - 允许双向滑动 */
|
||||
[class*="css-view"] {
|
||||
touch-action: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
/* 水平滚动容器 */
|
||||
[data-horizontal-scroll="true"],
|
||||
div[style*="overflow-x"],
|
||||
div[style*="overflow: scroll"],
|
||||
div[style*="overflow: auto"] {
|
||||
touch-action: pan-x pan-y;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
/* 手势区域 - 允许滑动手势 */
|
||||
[data-gesture-area="true"] {
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
/* 图片查看器手势区域 */
|
||||
.react-native-image-viewer,
|
||||
[data-image-viewer="true"] {
|
||||
touch-action: pinch-zoom pan-x pan-y;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -66,43 +101,19 @@ 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 notificationResponseListener = useRef<Notifications.EventSubscription | null>(null);
|
||||
const permissionRequested = useRef(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const initNotifications = async () => {
|
||||
const { loadNotificationPreferences } = await import('../src/services/notificationPreferences');
|
||||
const { loadNotificationPreferences } = await import('@/services/notification');
|
||||
await loadNotificationPreferences();
|
||||
const { systemNotificationService } = await import('../src/services/systemNotificationService');
|
||||
const { systemNotificationService } = await import('@/services/notification');
|
||||
await systemNotificationService.initialize();
|
||||
const { initBackgroundService } = await import('../src/services/backgroundService');
|
||||
const { initBackgroundService } = await import('@/services/background');
|
||||
// 默认静默模式,不会注册后台任务,不会触发自启动
|
||||
await initBackgroundService();
|
||||
|
||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||
@@ -112,26 +123,116 @@ function NotificationBootstrap() {
|
||||
appState.current = nextAppState;
|
||||
});
|
||||
|
||||
notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener(
|
||||
(response) => {
|
||||
void response.notification.request.content.data;
|
||||
}
|
||||
);
|
||||
// Listen for JPush notification taps — navigate to the relevant screen
|
||||
jpushService.onNotification((message) => {
|
||||
console.log('[NotificationBootstrap] JPush notification:', message.notificationEventType, message);
|
||||
if (message.notificationEventType !== 'notificationOpened') return;
|
||||
|
||||
const notificationReceivedSubscription = Notifications.addNotificationReceivedListener(
|
||||
(notification) => {
|
||||
void notification;
|
||||
}
|
||||
const extras = message.extras || {};
|
||||
const notifType = extras.notification_type || message.notificationType;
|
||||
|
||||
if (notifType === 'chat_message' || notifType === 'chat') {
|
||||
const conversationId = extras.conversation_id || extras.conversationId;
|
||||
const senderId = extras.sender_id || extras.senderId;
|
||||
const conversationType = extras.conversation_type || extras.conversationType;
|
||||
const groupId = extras.group_id || extras.groupId;
|
||||
const groupName = extras.group_name || extras.groupName;
|
||||
const groupAvatar = extras.group_avatar || extras.groupAvatar;
|
||||
|
||||
if (conversationId) {
|
||||
const isGroup = conversationType === 'group';
|
||||
router.push(
|
||||
hrefs.hrefChat({
|
||||
conversationId,
|
||||
userId: isGroup ? undefined : senderId,
|
||||
isGroupChat: isGroup,
|
||||
groupId: isGroup ? groupId : undefined,
|
||||
groupName: isGroup ? groupName : undefined,
|
||||
groupAvatar: isGroup ? groupAvatar : undefined,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
router.push(hrefs.hrefNotifications());
|
||||
}
|
||||
} else {
|
||||
router.push(hrefs.hrefNotifications());
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -139,45 +240,23 @@ function NotificationBootstrap() {
|
||||
|
||||
function ThemedStack() {
|
||||
const router = useRouter();
|
||||
const colors = useAppColors();
|
||||
const resolved = useResolvedColorScheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
||||
<SystemChrome />
|
||||
<SessionGate>
|
||||
<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="post/[postId]"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: '',
|
||||
// 与 PostDetailScreen 的 SafeAreaView(background.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.Screen name="privacy" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="terms" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</SessionGate>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -188,6 +267,16 @@ function ThemedProviders({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const [fontsLoaded] = useFonts({});
|
||||
|
||||
if (!fontsLoaded) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
@@ -197,6 +286,9 @@ export default function RootLayout() {
|
||||
<ThemedStack />
|
||||
<AppPromptBar />
|
||||
<AppDialogHost />
|
||||
<IncomingCallModal />
|
||||
<CallScreen />
|
||||
<FloatingCallWindow />
|
||||
</QueryClientProvider>
|
||||
</ThemedProviders>
|
||||
</SafeAreaProvider>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
import { useAuthStore } from '../src/stores';
|
||||
import { hrefAuthWelcome, hrefHome } from '../src/navigation/hrefs';
|
||||
|
||||
export default function Index() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
if (isAuthenticated) {
|
||||
return <Redirect href="/home" />;
|
||||
return <Redirect href={hrefHome()} />;
|
||||
}
|
||||
return <Redirect href="/login" />;
|
||||
return <Redirect href={hrefAuthWelcome()} />;
|
||||
}
|
||||
|
||||
5
app/privacy.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PrivacyPolicyScreen } from '../src/screens/profile/PrivacyPolicyScreen';
|
||||
|
||||
export default function PrivacyRoute() {
|
||||
return <PrivacyPolicyScreen />;
|
||||
}
|
||||
5
app/terms.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TermsOfServiceScreen } from '../src/screens/profile/TermsOfServiceScreen';
|
||||
|
||||
export default function TermsRoute() {
|
||||
return <TermsOfServiceScreen />;
|
||||
}
|
||||
7
app/trade/[tradeId].tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { TradeDetailScreen } from '../../src/screens/trade/TradeDetailScreen';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
|
||||
export default function TradeDetailRoute() {
|
||||
const { tradeId } = useLocalSearchParams<{ tradeId: string }>();
|
||||
return <TradeDetailScreen tradeId={tradeId} />;
|
||||
}
|
||||
46
app/trade/edit/[tradeId].tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { CreateTradeScreen } from '../../../src/screens/trade/CreateTradeScreen';
|
||||
import { tradeService } from '../../../src/services/trade/tradeService';
|
||||
import { Loading } from '../../../src/components/common';
|
||||
import { View, Alert } from 'react-native';
|
||||
import type { TradeItemDetailDTO } from '../../../src/types/trade';
|
||||
|
||||
export default function EditTradeRoute() {
|
||||
const { tradeId } = useLocalSearchParams<{ tradeId: string }>();
|
||||
const router = useRouter();
|
||||
const [item, setItem] = useState<TradeItemDetailDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tradeId) return;
|
||||
tradeService.getTradeItem(tradeId)
|
||||
.then(data => setItem(data))
|
||||
.catch(() => Alert.alert('错误', '加载商品信息失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [tradeId]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const handleSuccess = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
if (loading) {
|
||||
return <Loading fullScreen />;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateTradeScreen
|
||||
onClose={handleClose}
|
||||
onSuccess={handleSuccess}
|
||||
editItem={item}
|
||||
/>
|
||||
);
|
||||
}
|
||||
5
app/welcome.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { WelcomeScreen } from '../src/screens/auth';
|
||||
|
||||
export default function WelcomeRoute() {
|
||||
return <WelcomeScreen />;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.7 KiB |
BIN
assets/icon.png
|
Before Width: | Height: | Size: 895 KiB After Width: | Height: | Size: 385 KiB |
|
Before Width: | Height: | Size: 895 KiB After Width: | Height: | Size: 385 KiB |
3
eas.json
@@ -27,6 +27,9 @@
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true,
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
},
|
||||
"ios": {
|
||||
"resourceClass": "m-medium"
|
||||
}
|
||||
|
||||
23
google-services.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "000000000000",
|
||||
"project_id": "withyou-dummy",
|
||||
"storage_bucket": "withyou-dummy.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
|
||||
"android_client_info": {
|
||||
"package_name": "cn.qczlit.withyou"
|
||||
}
|
||||
},
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDummyKeyForFirebaseInitOnly"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -1,11 +1,72 @@
|
||||
/** @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.
|
||||
|
||||
19
nginx.conf
Normal file
@@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
}
|
||||
6649
package-lock.json
generated
117
package.json
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "carrot_bbs",
|
||||
"name": "with_you",
|
||||
"version": "1.0.1",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
@@ -14,61 +14,78 @@
|
||||
"ios:submit": "eas submit --platform ios --profile production"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/ui": "~56.0.17",
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.15.2",
|
||||
"@react-navigation/material-top-tabs": "^7.4.16",
|
||||
"@react-navigation/native": "^7.1.31",
|
||||
"@react-navigation/native-stack": "^7.14.2",
|
||||
"@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",
|
||||
"@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.11",
|
||||
"expo-background-task": "~56.0.18",
|
||||
"expo-callkit-telecom": "^0.3.9",
|
||||
"expo-camera": "~56.0.8",
|
||||
"expo-constants": "~56.0.18",
|
||||
"expo-dev-client": "~56.0.20",
|
||||
"expo-document-picker": "~56.0.4",
|
||||
"expo-file-system": "~56.0.8",
|
||||
"expo-font": "~56.0.6",
|
||||
"expo-haptics": "~56.0.3",
|
||||
"expo-image": "~56.0.6",
|
||||
"expo-image-picker": "~56.0.17",
|
||||
"expo-intent-launcher": "~56.0.4",
|
||||
"expo-linear-gradient": "~56.0.4",
|
||||
"expo-linking": "~56.0.14",
|
||||
"expo-media-library": "~56.0.7",
|
||||
"expo-notifications": "~56.0.17",
|
||||
"expo-router": "~56.2.10",
|
||||
"expo-splash-screen": "~56.0.10",
|
||||
"expo-sqlite": "~56.0.5",
|
||||
"expo-status-bar": "~56.0.4",
|
||||
"expo-system-ui": "~56.0.5",
|
||||
"expo-task-manager": "~56.0.18",
|
||||
"expo-updates": "~56.0.19",
|
||||
"expo-video": "~56.1.3",
|
||||
"jcore-react-native": "^2.3.6",
|
||||
"jpush-react-native": "^3.2.7",
|
||||
"katex": "^0.17.0",
|
||||
"livekit-client": "^2.19.2",
|
||||
"markdown-it": "^14.2.0",
|
||||
"pako": "^2.1.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"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": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.85.3",
|
||||
"react-native-gesture-handler": "^3.0.0",
|
||||
"react-native-pager-view": "^8.0.2",
|
||||
"react-native-paper": "^5.15.3",
|
||||
"react-native-reanimated": "~4.3.1",
|
||||
"react-native-safe-area-context": "~5.8.0",
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-share": "^12.3.1",
|
||||
"react-native-sse": "^1.2.1",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webview": "13.16.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
"react-native-webview": "13.16.1",
|
||||
"react-native-worklets": "^0.8.3",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-native-community/cli": "^20.1.2",
|
||||
"@types/react": "~19.2.2",
|
||||
"@react-native-community/cli": "^20.1.3",
|
||||
"@types/pako": "^2.0.4",
|
||||
"@types/react": "~19.2.16",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"typescript": "~5.9.2"
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"private": true
|
||||
"private": true,
|
||||
"expo": {
|
||||
"install": {
|
||||
"exclude": [
|
||||
"expo-image"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
package/android/expo-gradle-plugin/build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
kotlin("jvm") version "2.1.20" apply false
|
||||
id("java-gradle-plugin")
|
||||
}
|
||||
22
package/gradle/libs.versions.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[versions]
|
||||
agp = "8.12.0"
|
||||
gson = "2.8.9"
|
||||
guava = "31.0.1-jre"
|
||||
javapoet = "1.13.0"
|
||||
junit = "4.13.2"
|
||||
kotlin = "2.1.20"
|
||||
assertj = "3.25.1"
|
||||
ktfmt = "0.22.0"
|
||||
|
||||
[libraries]
|
||||
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
||||
android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
|
||||
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
|
||||
guava = { module = "com.google.guava:guava", version.ref = "guava" }
|
||||
javapoet = { module = "com.squareup:javapoet", version.ref = "javapoet" }
|
||||
junit = {module = "junit:junit", version.ref = "junit" }
|
||||
assertj = { module = "org.assertj:assertj-core", version.ref = "assertj" }
|
||||
|
||||
[plugins]
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" }
|
||||
7
package/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
83
package/react-native-gradle-plugin/build.gradle.kts
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.ktfmt)
|
||||
id("java-gradle-plugin")
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
gradlePlugin {
|
||||
plugins {
|
||||
create("react") {
|
||||
id = "com.facebook.react"
|
||||
implementationClass = "com.facebook.react.ReactPlugin"
|
||||
}
|
||||
create("reactrootproject") {
|
||||
id = "com.facebook.react.rootproject"
|
||||
implementationClass = "com.facebook.react.ReactRootProjectPlugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group = "com.facebook.react"
|
||||
|
||||
dependencies {
|
||||
implementation(project(":shared"))
|
||||
|
||||
implementation(gradleApi())
|
||||
|
||||
// The KGP/AGP version is defined by React Native Gradle plugin.
|
||||
// Therefore we specify an implementation dep rather than a compileOnly.
|
||||
implementation(libs.kotlin.gradle.plugin)
|
||||
implementation(libs.android.gradle.plugin)
|
||||
|
||||
implementation(libs.gson)
|
||||
implementation(libs.guava)
|
||||
implementation(libs.javapoet)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.assertj)
|
||||
testImplementation(project(":shared-testutil"))
|
||||
}
|
||||
|
||||
// We intentionally don't build for Java 17 as users will see a cryptic bytecode version
|
||||
// error first. Instead we produce a Java 11-compatible Gradle Plugin, so that AGP can print their
|
||||
// nice message showing that JDK 11 (or 17) is required first
|
||||
java { targetCompatibility = JavaVersion.VERSION_11 }
|
||||
|
||||
kotlin { jvmToolchain(17) }
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
apiVersion.set(KotlinVersion.KOTLIN_1_8)
|
||||
// See comment above on JDK 11 support
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
allWarningsAsErrors.set(
|
||||
project.properties["enableWarningsAsErrors"]?.toString()?.toBoolean() ?: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
testLogging {
|
||||
exceptionFormat = TestExceptionFormat.FULL
|
||||
showExceptions = true
|
||||
showCauses = true
|
||||
showStackTraces = true
|
||||
}
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
# Messages模块与PostService/Manager架构对比分析报告
|
||||
|
||||
## 一、Messages模块架构分析
|
||||
|
||||
### 1.1 架构层次结构
|
||||
|
||||
Messages模块采用了**清晰的分层架构**,各层职责明确:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 表现层 (Screens) │
|
||||
│ ChatScreen, MessageListScreen, NotificationsScreen │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Hooks层 │
|
||||
│ useDifferentialMessages, useChatScreen │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 用例层 (UseCases) │
|
||||
│ ProcessMessageUseCase │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 领域层 (Entities) │
|
||||
│ Message, Conversation, GroupNotice │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 仓库层 (Repositories) │
|
||||
│ MessageRepository, IMessageRepository │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 数据源层 (DataSources) │
|
||||
│ SSEClient, LocalDataSource, ApiDataSource │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 核心组件详解
|
||||
|
||||
#### 1.2.1 领域实体层 (Core/Entities)
|
||||
- **Message.ts**: 定义消息、会话、群通知等核心领域模型
|
||||
- 包含工厂函数:`createMessage`, `createConversation`
|
||||
- 包含业务逻辑函数:`isMessageFromCurrentUser`, `shouldIncrementUnread`, `buildTextContent`
|
||||
|
||||
#### 1.2.2 用例层 (Core/UseCases)
|
||||
- **ProcessMessageUseCase**: 核心业务逻辑编排器
|
||||
- 订阅SSE事件(chat, group_message, read, recall, typing, group_notice等)
|
||||
- 消息去重处理(processedMessageIds Set)
|
||||
- 已读状态保护(pendingReadMap + 版本号机制)
|
||||
- 用户信息缓存与去重请求(pendingUserRequests Map)
|
||||
- 事件发布订阅模式(subscribers Set)
|
||||
|
||||
#### 1.2.3 仓库层 (Data/Repositories)
|
||||
- **IMessageRepository**: 定义数据访问接口
|
||||
- 消息操作:getMessages, saveMessage, updateMessageStatus, markAsRead
|
||||
- 会话操作:getConversations, saveConversation, updateUnreadCount
|
||||
- 同步操作:syncMessages, getServerLastSeq
|
||||
|
||||
- **MessageRepository**: 实现接口
|
||||
- 封装SQLite数据库操作
|
||||
- 消息与CachedMessage的转换
|
||||
|
||||
#### 1.2.4 映射器层 (Data/Mappers)
|
||||
- **MessageMapper**: 数据转换
|
||||
- `fromApiResponse`: API响应 → 应用模型
|
||||
- `fromDbRecord`: 数据库记录 → 应用模型
|
||||
- `toDbRecord`: 应用模型 → 数据库记录
|
||||
- `toApiRequest`: 应用模型 → API请求
|
||||
- 创建消息片段:`createTextSegment`, `createImageSegment`
|
||||
|
||||
#### 1.2.5 差异计算基础设施 (Infrastructure/Diff)
|
||||
- **MessageDiffCalculator**: 消息列表差异计算
|
||||
- 计算added, updated, deleted, moved, unchanged
|
||||
- 变化比例检测(changeRatio > 0.5 时触发重置)
|
||||
- 增量差异计算(基于缓存)
|
||||
|
||||
- **MessageUpdateBatcher**: 批量更新处理器
|
||||
- 16ms批量间隔(约60fps)
|
||||
- 去重和合并更新
|
||||
- 100ms最大等待时间
|
||||
- 统计信息:totalBatches, totalUpdates, averageBatchSize
|
||||
|
||||
- **types.ts**: 完整的类型定义
|
||||
- MessageUpdateType枚举:ADD, UPDATE, DELETE, MOVE, BATCH_*
|
||||
- DiffResult, DiffConfig等接口
|
||||
|
||||
#### 1.2.6 Hook层
|
||||
- **useDifferentialMessages**: 差异更新Hook
|
||||
- 集成DiffCalculator和Batcher
|
||||
- 自动处理批量更新
|
||||
- 提供flush, reset, forceUpdate方法
|
||||
- 支持配置:enableDiff, enableBatching, maxMessageCount, changeRatioThreshold
|
||||
|
||||
### 1.3 数据流
|
||||
|
||||
```
|
||||
SSE事件 → ProcessMessageUseCase → 事件发布 → useChatScreen → useDifferentialMessages
|
||||
↓ ↓
|
||||
MessageRepository 差异计算 + 批量处理
|
||||
↓ ↓
|
||||
SQLite数据库 优化后的消息列表
|
||||
```
|
||||
|
||||
### 1.4 状态管理方式
|
||||
|
||||
- **发布-订阅模式**:ProcessMessageUseCase作为事件中心
|
||||
- **本地SQLite缓存**:消息持久化存储
|
||||
- **内存状态**:通过Hook返回,组件直接消费
|
||||
- **差异更新**:通过MessageDiffCalculator + MessageUpdateBatcher减少重渲染
|
||||
|
||||
---
|
||||
|
||||
## 二、PostService/Manager架构分析
|
||||
|
||||
### 2.1 架构层次结构
|
||||
|
||||
Post模块采用了**扁平化架构**,Service和Manager层职责混合:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 表现层 (Screens) │
|
||||
│ HomeScreen, PostDetailScreen, CreatePostScreen │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Hooks层 │
|
||||
│ useCursorPagination (通用), usePrefetch │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Store层 (Zustand) │
|
||||
│ useUserStore (直接操作状态), postManager (缓存管理) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Service层 │
|
||||
│ postService (API调用 + 状态更新) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 数据源 │
|
||||
│ API (直接调用) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 核心组件详解
|
||||
|
||||
#### 2.2.1 Service层 (Services)
|
||||
- **postService.ts**: 帖子服务
|
||||
- CRUD操作:getPosts, getPost, createPost, updatePost, deletePost
|
||||
- 互动操作:likePost, unlikePost, favoritePost, unfavoritePost
|
||||
- 分页支持:传统分页 + 游标分页
|
||||
- **问题**:直接操作useUserStore(违反分层原则)
|
||||
|
||||
#### 2.2.2 Manager层 (Stores)
|
||||
- **postManager.ts**: 帖子缓存管理器
|
||||
- 继承CacheBus(发布订阅基类)
|
||||
- 内存缓存:listCache, detailCache
|
||||
- 请求去重:pendingRequests Map
|
||||
- TTL管理:LIST_TTL=30s, DETAIL_TTL=60s
|
||||
- 后台刷新机制
|
||||
|
||||
#### 2.2.3 Store层 (Zustand)
|
||||
- **useUserStore**: 用户状态存储
|
||||
- 直接存储posts数组
|
||||
- 帖子操作副作用直接修改状态
|
||||
- 混合了用户数据和帖子数据
|
||||
|
||||
#### 2.2.4 映射器层 (Data/Mappers)
|
||||
- **PostMapper**: 数据转换
|
||||
- `fromApiResponse`: API响应 → 应用模型
|
||||
- `fromApiResponseList`: 批量转换
|
||||
- `toApiRequest`: 应用模型 → API请求
|
||||
- 相比MessageMapper缺少数据库记录转换方法
|
||||
|
||||
### 2.3 数据流
|
||||
|
||||
```
|
||||
HomeScreen → useCursorPagination → postService.getPostsCursor
|
||||
↓
|
||||
useUserStore.setState(posts)
|
||||
↓
|
||||
PostCard组件渲染
|
||||
```
|
||||
|
||||
### 2.4 状态管理方式
|
||||
|
||||
- **Zustand Store**:集中式状态管理
|
||||
- **内存缓存**:postManager提供应用级缓存
|
||||
- **请求去重**:dedupe方法防止重复请求
|
||||
- **直接修改**:postService直接调用useUserStore.setState
|
||||
|
||||
---
|
||||
|
||||
## 三、主要架构差异
|
||||
|
||||
### 3.1 分层复杂度
|
||||
|
||||
| 维度 | Messages模块 | Post模块 |
|
||||
|------|-------------|---------|
|
||||
| 层次深度 | 6层(Entity→UseCase→Repository→Mapper→Hook→Screen) | 4层(Service→Store→Hook→Screen) |
|
||||
| 用例层 | 独立ProcessMessageUseCase | 无 |
|
||||
| 仓库接口 | IMessageRepository抽象接口 | 无 |
|
||||
| 基础设施 | Diff模块(Calculator + Batcher) | 无 |
|
||||
|
||||
### 3.2 数据持久化
|
||||
|
||||
| 维度 | Messages模块 | Post模块 |
|
||||
|------|-------------|---------|
|
||||
| 本地存储 | SQLite数据库 | 无 |
|
||||
| 缓存抽象 | MessageRepository封装 | postManager内存缓存 |
|
||||
| 离线支持 | 完整 | 不支持 |
|
||||
|
||||
### 3.3 实时更新
|
||||
|
||||
| 维度 | Messages模块 | Post模块 |
|
||||
|------|-------------|---------|
|
||||
| 推送机制 | SSE实时推送 | 轮询/下拉刷新 |
|
||||
| 事件订阅 | ProcessMessageUseCase发布订阅 | 无 |
|
||||
| 增量更新 | MessageDiffCalculator | 无 |
|
||||
|
||||
### 3.4 状态管理
|
||||
|
||||
| 维度 | Messages模块 | Post模块 |
|
||||
|------|-------------|---------|
|
||||
| 管理方式 | 发布订阅 + Hook局部状态 | Zustand全局Store |
|
||||
| 状态来源 | useChatScreen, useDifferentialMessages | useUserStore |
|
||||
| 批量更新 | MessageUpdateBatcher | 无 |
|
||||
| 差异检测 | MessageDiffCalculator | 无 |
|
||||
|
||||
### 3.5 依赖方向
|
||||
|
||||
**Messages模块(正确)**:
|
||||
```
|
||||
Hook → UseCase → Repository → DataSource
|
||||
↓
|
||||
Entity(不依赖外部)
|
||||
```
|
||||
|
||||
**Post模块(问题)**:
|
||||
```
|
||||
Service → Store(循环依赖风险)
|
||||
↓
|
||||
API直接调用
|
||||
```
|
||||
|
||||
### 3.6 核心问题总结
|
||||
|
||||
| # | 问题 | Messages | Post |
|
||||
|---|------|---------|------|
|
||||
| 1 | 违反分层原则 | 无 | postService直接操作useUserStore |
|
||||
| 2 | 缺少UseCase层 | ProcessMessageUseCase | 无业务逻辑编排 |
|
||||
| 3 | 缺少Repository抽象 | IMessageRepository | 无数据访问接口 |
|
||||
| 4 | 无差异更新 | useDifferentialMessages | 无 |
|
||||
| 5 | 无批量处理 | MessageUpdateBatcher | 无 |
|
||||
| 6 | 无本地持久化 | SQLite | 无 |
|
||||
| 7 | 无实时推送 | SSE | 无 |
|
||||
|
||||
---
|
||||
|
||||
## 四、对齐建议
|
||||
|
||||
### 4.1 架构重构目标
|
||||
|
||||
将Post模块对齐到Messages模块的架构模式:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 表现层 (Screens) │
|
||||
│ HomeScreen, PostDetailScreen │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Hooks层 │
|
||||
│ useDifferentialPosts (新增) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 用例层 (UseCases) │
|
||||
│ ProcessPostUseCase (新增) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 领域层 (Entities) │
|
||||
│ Post, PostComment (新增) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 仓库层 (Repositories) │
|
||||
│ PostRepository, IPostRepository (新增) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Service层 │
|
||||
│ postService (仅保留API调用,移除状态操作) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 具体改造项
|
||||
|
||||
#### 4.2.1 创建Post领域实体
|
||||
```typescript
|
||||
// src/core/entities/Post.ts
|
||||
export interface Post {
|
||||
id: string;
|
||||
authorId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
images: string[];
|
||||
likeCount: number;
|
||||
commentCount: number;
|
||||
// ... 其他字段
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2.2 创建ProcessPostUseCase
|
||||
```typescript
|
||||
// src/core/usecases/ProcessPostUseCase.ts
|
||||
class ProcessPostUseCase {
|
||||
// 帖子增删改查
|
||||
// 点赞/收藏逻辑
|
||||
// 事件发布订阅
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2.3 创建IPostRepository接口
|
||||
```typescript
|
||||
// src/data/repositories/interfaces/IPostRepository.ts
|
||||
interface IPostRepository {
|
||||
getPosts(type: string, page: number, pageSize: number): Promise<Post[]>;
|
||||
savePost(post: Post): Promise<void>;
|
||||
updatePost(postId: string, updates: Partial<Post>): Promise<void>;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2.4 创建PostRepository实现
|
||||
```typescript
|
||||
// src/data/repositories/PostRepository.ts
|
||||
// 封装SQLite操作,实现IPostRepository接口
|
||||
```
|
||||
|
||||
#### 4.2.5 创建useDifferentialPosts Hook
|
||||
```typescript
|
||||
// src/hooks/useDifferentialPosts.ts
|
||||
// 类似useDifferentialMessages,处理帖子列表差异更新
|
||||
```
|
||||
|
||||
#### 4.2.6 改造postService
|
||||
- 移除对useUserStore的直接依赖
|
||||
- 仅保留API调用职责
|
||||
|
||||
---
|
||||
|
||||
## 五、架构对比图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Messages模块 [理想架构]"
|
||||
E1[Entity<br/>Message.ts]
|
||||
U1[UseCase<br/>ProcessMessageUseCase]
|
||||
R1[Repository<br/>MessageRepository]
|
||||
M1[Mapper<br/>MessageMapper]
|
||||
H1[Hook<br/>useDifferentialMessages]
|
||||
D1[Diff基础设施<br/>DiffCalculator + Batcher]
|
||||
S1[(SQLite)]
|
||||
|
||||
E1 --> U1
|
||||
U1 --> R1
|
||||
U1 --> D1
|
||||
R1 --> S1
|
||||
M1 --> R1
|
||||
D1 --> H1
|
||||
H1 --> S1
|
||||
end
|
||||
|
||||
subgraph "Post模块 [当前架构]"
|
||||
E2[PostMapper]
|
||||
SV2[postService<br/>直接操作Store]
|
||||
ST2[useUserStore<br/>Zustand]
|
||||
H2[Hook<br/>useCursorPagination]
|
||||
PM2[postManager<br/>内存缓存]
|
||||
|
||||
E2 --> SV2
|
||||
SV2 --> ST2
|
||||
H2 --> SV2
|
||||
PM2 --> ST2
|
||||
end
|
||||
|
||||
style E1 fill:#90EE90
|
||||
style U1 fill:#90EE90
|
||||
style R1 fill:#90EE90
|
||||
style D1 fill:#90EE90
|
||||
style H1 fill:#90EE90
|
||||
style S1 fill:#90EE90
|
||||
|
||||
style SV2 fill:#FFB6C1
|
||||
style ST2 fill:#FFB6C1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、结论
|
||||
|
||||
Messages模块是一个**架构完善**的模块,具备:
|
||||
1. 清晰的分层架构
|
||||
2. 独立的业务逻辑编排层(UseCase)
|
||||
3. 完整的数据持久化(SQLite)
|
||||
4. 高效的差异更新机制
|
||||
5. 实时事件推送能力
|
||||
|
||||
Post模块当前存在以下问题:
|
||||
1. **违反分层原则**:Service直接操作Store
|
||||
2. **缺少UseCase层**:业务逻辑分散
|
||||
3. **无数据抽象**:缺少Repository接口
|
||||
4. **无差异更新**:每次全量更新导致性能问题
|
||||
5. **无本地持久化**:无法离线使用
|
||||
|
||||
建议按照对齐建议逐步重构Post模块,使其架构与Messages模块对齐。
|
||||
1
plugins/agconnect-services.json
Normal file
@@ -0,0 +1 @@
|
||||
{"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"}}]}
|
||||
40
plugins/withCmakeJobLimit.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const { withDangerousMod } = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const MAX_JOBS = '3';
|
||||
|
||||
// React Native's hermes-engine build hardcodes
|
||||
// Runtime.getRuntime().availableProcessors()
|
||||
// for `cmake --build -j`, ignoring the CMAKE_BUILD_PARALLEL_LEVEL env var.
|
||||
// On high-core CI runners this exhausts memory.
|
||||
// This plugin caps it during prebuild.
|
||||
const withCmakeJobLimit = (config) =>
|
||||
withDangerousMod(config, [
|
||||
'android',
|
||||
async (config) => {
|
||||
const root = config.modRequest.projectRoot;
|
||||
|
||||
const hermesPath = path.join(
|
||||
root,
|
||||
'node_modules',
|
||||
'react-native',
|
||||
'ReactAndroid',
|
||||
'hermes-engine',
|
||||
'build.gradle.kts',
|
||||
);
|
||||
if (fs.existsSync(hermesPath)) {
|
||||
let content = fs.readFileSync(hermesPath, 'utf-8');
|
||||
content = content.replace(
|
||||
/Runtime\.getRuntime\(\)\.availableProcessors\(\)\.toString\(\)/,
|
||||
`"${MAX_JOBS}"`,
|
||||
);
|
||||
fs.writeFileSync(hermesPath, content);
|
||||
console.log(`[withCmakeJobLimit] capped hermes-engine jobs to ${MAX_JOBS}`);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
|
||||
module.exports = withCmakeJobLimit;
|
||||
467
plugins/withForegroundService.js
Normal file
@@ -0,0 +1,467 @@
|
||||
const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const FOREGROUND_SERVICE_PERMISSION = 'android.permission.FOREGROUND_SERVICE';
|
||||
const FOREGROUND_SERVICE_DATA_SYNC = 'android.permission.FOREGROUND_SERVICE_DATA_SYNC';
|
||||
|
||||
/**
|
||||
* Expo Config Plugin:为 Android 添加前台服务保活
|
||||
*
|
||||
* 功能:
|
||||
* 1. 添加 FOREGROUND_SERVICE 相关权限
|
||||
* 2. 在 AndroidManifest 中声明 Service
|
||||
* 3. 生成 Kotlin 原生服务代码
|
||||
* 4. 生成 React Native Bridge 模块
|
||||
*
|
||||
* 参考:Element Android 的 GuardAndroidService
|
||||
*/
|
||||
const withForegroundService = (config, options = {}) => {
|
||||
const {
|
||||
notificationTitle = '威友',
|
||||
notificationBody = '正在后台同步消息',
|
||||
notificationIcon = 'ic_notification',
|
||||
channelId = 'withyou_sync_foreground',
|
||||
channelName = '消息同步',
|
||||
} = options;
|
||||
|
||||
// 1. 添加权限和 Service 声明到 AndroidManifest
|
||||
config = withAndroidManifest(config, (config) => {
|
||||
const manifest = config.modResults;
|
||||
|
||||
// 添加权限声明
|
||||
const permissions = [
|
||||
FOREGROUND_SERVICE_PERMISSION,
|
||||
FOREGROUND_SERVICE_DATA_SYNC,
|
||||
];
|
||||
|
||||
permissions.forEach((permission) => {
|
||||
const existingPermission = manifest.manifest['uses-permission']?.find(
|
||||
(p) => p.$['android:name'] === permission
|
||||
);
|
||||
if (!existingPermission) {
|
||||
if (!manifest.manifest['uses-permission']) {
|
||||
manifest.manifest['uses-permission'] = [];
|
||||
}
|
||||
manifest.manifest['uses-permission'].push({
|
||||
$: { 'android:name': permission },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 添加 Service 声明
|
||||
if (!manifest.manifest.application[0].service) {
|
||||
manifest.manifest.application[0].service = [];
|
||||
}
|
||||
|
||||
const existingService = manifest.manifest.application[0].service.find(
|
||||
(s) => s.$['android:name'] === '.SyncForegroundService'
|
||||
);
|
||||
|
||||
if (!existingService) {
|
||||
manifest.manifest.application[0].service.push({
|
||||
$: {
|
||||
'android:name': '.SyncForegroundService',
|
||||
'android:enabled': 'true',
|
||||
'android:exported': 'false',
|
||||
'android:foregroundServiceType': 'dataSync',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// 2. 生成 Kotlin 原生代码
|
||||
config = withDangerousMod(config, [
|
||||
'android',
|
||||
async (config) => {
|
||||
const projectRoot = config.modRequest.projectRoot;
|
||||
const packageName = config.android.package;
|
||||
const kotlinPath = path.join(
|
||||
projectRoot,
|
||||
'android/app/src/main/java',
|
||||
packageName.replace(/\./g, '/')
|
||||
);
|
||||
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(kotlinPath)) {
|
||||
fs.mkdirSync(kotlinPath, { recursive: true });
|
||||
}
|
||||
|
||||
// 生成 SyncForegroundService.kt
|
||||
const serviceCode = generateServiceCode(packageName, {
|
||||
notificationTitle,
|
||||
notificationBody,
|
||||
notificationIcon,
|
||||
channelId,
|
||||
channelName,
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(kotlinPath, 'SyncForegroundService.kt'),
|
||||
serviceCode
|
||||
);
|
||||
|
||||
// 生成 ForegroundServiceModule.kt (React Native Bridge)
|
||||
const moduleCode = generateModuleCode(packageName);
|
||||
fs.writeFileSync(
|
||||
path.join(kotlinPath, 'ForegroundServiceModule.kt'),
|
||||
moduleCode
|
||||
);
|
||||
|
||||
// 生成 ForegroundServicePackage.kt
|
||||
const packageCode = generatePackageCode(packageName);
|
||||
fs.writeFileSync(
|
||||
path.join(kotlinPath, 'ForegroundServicePackage.kt'),
|
||||
packageCode
|
||||
);
|
||||
|
||||
// 修改 MainApplication.kt 注册模块
|
||||
const mainAppPath = path.join(kotlinPath, 'MainApplication.kt');
|
||||
if (fs.existsSync(mainAppPath)) {
|
||||
let content = fs.readFileSync(mainAppPath, 'utf-8');
|
||||
|
||||
// 添加 import
|
||||
if (!content.includes('ForegroundServicePackage')) {
|
||||
content = content.replace(
|
||||
/package .*\n/,
|
||||
`$&\nimport ${packageName}.ForegroundServicePackage\n`
|
||||
);
|
||||
}
|
||||
|
||||
// 在 packages 列表中添加
|
||||
if (!content.includes('ForegroundServicePackage()')) {
|
||||
// 查找 PackageList(this).packages.apply 块
|
||||
content = content.replace(
|
||||
/(PackageList\(this\)\.packages\.apply\s*\{)/,
|
||||
`$1\n // 前台服务模块\n add(ForegroundServicePackage())`
|
||||
);
|
||||
}
|
||||
|
||||
fs.writeFileSync(mainAppPath, content);
|
||||
}
|
||||
|
||||
// 创建通知图标目录和文件
|
||||
const drawablePath = path.join(projectRoot, 'android/app/src/main/res/drawable');
|
||||
if (!fs.existsSync(drawablePath)) {
|
||||
fs.mkdirSync(drawablePath, { recursive: true });
|
||||
}
|
||||
|
||||
const iconPath = path.join(drawablePath, 'ic_notification.xml');
|
||||
if (!fs.existsSync(iconPath)) {
|
||||
fs.writeFileSync(iconPath, generateNotificationIcon());
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成前台服务 Kotlin 代码
|
||||
*/
|
||||
function generateServiceCode(packageName, options) {
|
||||
const { notificationTitle, notificationBody, notificationIcon, channelId, channelName } = options;
|
||||
|
||||
return `package ${packageName}
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* 前台同步服务
|
||||
*
|
||||
* 在通知栏显示常驻通知,防止应用进程被系统杀死
|
||||
* 类似 Element Android 的 GuardAndroidService 和 V2Ray 的保活机制
|
||||
*/
|
||||
class SyncForegroundService : Service() {
|
||||
|
||||
companion object {
|
||||
const val NOTIFICATION_ID = 1001
|
||||
const val CHANNEL_ID = "${channelId}"
|
||||
const val CHANNEL_NAME = "${channelName}"
|
||||
|
||||
const val ACTION_START = "${packageName}.foreground.START"
|
||||
const val ACTION_STOP = "${packageName}.foreground.STOP"
|
||||
const val ACTION_UPDATE = "${packageName}.foreground.UPDATE"
|
||||
|
||||
const val EXTRA_TITLE = "title"
|
||||
const val EXTRA_BODY = "body"
|
||||
|
||||
/**
|
||||
* 启动前台服务
|
||||
*/
|
||||
fun start(context: Context, title: String = "${notificationTitle}", body: String = "${notificationBody}") {
|
||||
val intent = Intent(context, SyncForegroundService::class.java).apply {
|
||||
action = ACTION_START
|
||||
putExtra(EXTRA_TITLE, title)
|
||||
putExtra(EXTRA_BODY, body)
|
||||
}
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("SyncForegroundService", "启动前台服务失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止前台服务
|
||||
*/
|
||||
fun stop(context: Context) {
|
||||
val intent = Intent(context, SyncForegroundService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新通知内容
|
||||
*/
|
||||
fun update(context: Context, title: String, body: String) {
|
||||
val intent = Intent(context, SyncForegroundService::class.java).apply {
|
||||
action = ACTION_UPDATE
|
||||
putExtra(EXTRA_TITLE, title)
|
||||
putExtra(EXTRA_BODY, body)
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private var notificationTitle: String = "${notificationTitle}"
|
||||
private var notificationBody: String = "${notificationBody}"
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> {
|
||||
notificationTitle = intent.getStringExtra(EXTRA_TITLE) ?: "${notificationTitle}"
|
||||
notificationBody = intent.getStringExtra(EXTRA_BODY) ?: "${notificationBody}"
|
||||
startForegroundCompat()
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
stopForegroundCompat()
|
||||
stopSelf()
|
||||
}
|
||||
ACTION_UPDATE -> {
|
||||
notificationTitle = intent.getStringExtra(EXTRA_TITLE) ?: notificationTitle
|
||||
notificationBody = intent.getStringExtra(EXTRA_BODY) ?: notificationBody
|
||||
updateNotification()
|
||||
}
|
||||
}
|
||||
// START_STICKY: 服务被杀后自动重启
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
/**
|
||||
* 启动前台服务(兼容 Android Q+)
|
||||
*/
|
||||
private fun startForegroundCompat() {
|
||||
val notification = buildNotification()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止前台服务(兼容 Android N+)
|
||||
*/
|
||||
private fun stopForegroundCompat() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建通知渠道
|
||||
*/
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_MIN // 最低重要性,不打扰用户
|
||||
).apply {
|
||||
description = "后台消息同步服务"
|
||||
setSound(null, null)
|
||||
setShowBadge(false)
|
||||
enableLights(false)
|
||||
enableVibration(false)
|
||||
}
|
||||
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建通知
|
||||
*/
|
||||
private fun buildNotification(): Notification {
|
||||
// 点击通知打开应用
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
launchIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(notificationTitle)
|
||||
.setContentText(notificationBody)
|
||||
.setSmallIcon(R.drawable.${notificationIcon})
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setOngoing(true) // 不可滑动清除
|
||||
.setShowWhen(false)
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新通知内容
|
||||
*/
|
||||
private fun updateNotification() {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.notify(NOTIFICATION_ID, buildNotification())
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 React Native Bridge 模块代码
|
||||
*/
|
||||
function generateModuleCode(packageName) {
|
||||
return `package ${packageName}
|
||||
|
||||
import android.content.Context
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.Promise
|
||||
|
||||
/**
|
||||
* React Native Bridge: 前台服务控制模块
|
||||
*/
|
||||
class ForegroundServiceModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = "ForegroundService"
|
||||
|
||||
/**
|
||||
* 启动前台服务
|
||||
*/
|
||||
@ReactMethod
|
||||
fun start(title: String, body: String, promise: Promise) {
|
||||
try {
|
||||
SyncForegroundService.start(reactApplicationContext, title, body)
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止前台服务
|
||||
*/
|
||||
@ReactMethod
|
||||
fun stop(promise: Promise) {
|
||||
try {
|
||||
SyncForegroundService.stop(reactApplicationContext)
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新通知内容
|
||||
*/
|
||||
@ReactMethod
|
||||
fun update(title: String, body: String, promise: Promise) {
|
||||
try {
|
||||
SyncForegroundService.update(reactApplicationContext, title, body)
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 React Native Package 代码
|
||||
*/
|
||||
function generatePackageCode(packageName) {
|
||||
return `package ${packageName}
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class ForegroundServicePackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(ForegroundServiceModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成通知图标 XML
|
||||
*/
|
||||
function generateNotificationIcon() {
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<!-- 背景圆 -->
|
||||
<path
|
||||
android:fillColor="#4CAF50"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
|
||||
<!-- 对勾符号 -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z"/>
|
||||
</vector>
|
||||
`;
|
||||
}
|
||||
|
||||
module.exports = withForegroundService;
|
||||
205
plugins/withHonorPush.js
Normal file
@@ -0,0 +1,205 @@
|
||||
const {
|
||||
withProjectBuildGradle,
|
||||
withSettingsGradle,
|
||||
withDangerousMod,
|
||||
} = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 荣耀厂商推送通道的 Expo config plugin.
|
||||
//
|
||||
// 客户端职责(依据极光官方文档 https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide):
|
||||
// 1) 添加 cn.jiguang.sdk.plugin:honor 依赖
|
||||
// 2) 注入 manifestPlaceholders: HONOR_APPID
|
||||
// 3) 添加荣耀 Maven 仓库 https://developer.hihonor.com/repo (v5.9.0+ 必需)
|
||||
// 4) 添加 Proguard 规则 (com.hihonor.push.** 保留 + 5 条 -keepattributes)
|
||||
//
|
||||
// 实现说明:app/build.gradle 改动用 withDangerousMod 直接读写磁盘文件。
|
||||
// 原因:Expo SDK 56+ 的 withAppBuildGradle hook 链中,第二个以后的 plugin 写回
|
||||
// modResults.contents 会被静默忽略,导致多个 vendor plugin 只能写入第一个的修改。
|
||||
// 仓库(settings.gradle / build.gradle)改动用 withProjectBuildGradle / withSettingsGradle
|
||||
// 没有这个问题。
|
||||
const withHonorPush = (config, options = {}) => {
|
||||
const {
|
||||
jpushVersion = '6.1.0',
|
||||
appId = '104562917',
|
||||
} = options;
|
||||
|
||||
const honorRepoUrl = 'https://developer.hihonor.com/repo';
|
||||
|
||||
// 1. settings.gradle: 添加荣耀 Maven 仓库
|
||||
config = withSettingsGradle(config, (config) => {
|
||||
let contents = config.modResults.contents;
|
||||
|
||||
if (!contents.includes('developer.hihonor.com/repo')) {
|
||||
const repoLine = ` maven { url '${honorRepoUrl}' }`;
|
||||
const hasRepositoriesInPM = /pluginManagement\s*\{[\s\S]*?repositories\s*\{/.test(contents);
|
||||
|
||||
if (hasRepositoriesInPM) {
|
||||
config.modResults.contents = contents.replace(
|
||||
/(pluginManagement\s*\{[\s\S]*?repositories\s*\{)/,
|
||||
`$1\n ${repoLine}`
|
||||
);
|
||||
} else if (contents.includes('pluginManagement')) {
|
||||
config.modResults.contents = contents.replace(
|
||||
/(pluginManagement\s*\{)/,
|
||||
`$1\n repositories {\n gradlePluginPortal()\n google()\n mavenCentral()\n ${repoLine}\n }`
|
||||
);
|
||||
} else {
|
||||
config.modResults.contents =
|
||||
`pluginManagement {\n repositories {\n ${repoLine}\n }\n}\n\n` +
|
||||
config.modResults.contents;
|
||||
}
|
||||
}
|
||||
|
||||
// Gradle 9+ layout
|
||||
if (
|
||||
!config.modResults.contents.match(
|
||||
/dependencyResolutionManagement[\s\S]*developer\.hihonor\.com\/repo/
|
||||
)
|
||||
) {
|
||||
const depMgmtPattern = /dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{/;
|
||||
if (depMgmtPattern.test(config.modResults.contents)) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
depMgmtPattern,
|
||||
`$&\n maven { url '${honorRepoUrl}' }`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// 2. 根 build.gradle: 添加荣耀 Maven 仓库到 buildscript.repositories
|
||||
config = withProjectBuildGradle(config, (config) => {
|
||||
let contents = config.modResults.contents;
|
||||
|
||||
if (!contents.includes('developer.hihonor.com/repo')) {
|
||||
const bsRepoPattern = /(buildscript\s*\{[\s\S]*?repositories\s*\{)/;
|
||||
if (bsRepoPattern.test(contents)) {
|
||||
contents = contents.replace(
|
||||
bsRepoPattern,
|
||||
`$1\n maven { url '${honorRepoUrl}' }`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
config.modResults.contents = contents;
|
||||
return config;
|
||||
});
|
||||
|
||||
// 3. app/build.gradle: 注入 AAR 依赖 + manifestPlaceholders (用 withDangerousMod 直接读文件)
|
||||
config = withDangerousMod(config, [
|
||||
'android',
|
||||
async (config) => {
|
||||
const appBuildGradlePath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
'app',
|
||||
'build.gradle'
|
||||
);
|
||||
const proguardFilePath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
'app',
|
||||
'proguard-rules.pro'
|
||||
);
|
||||
|
||||
// --- 3a. 处理 build.gradle ---
|
||||
if (fs.existsSync(appBuildGradlePath)) {
|
||||
let gradle = fs.readFileSync(appBuildGradlePath, 'utf-8');
|
||||
let changed = false;
|
||||
|
||||
// 注入 cn.jiguang.sdk.plugin:honor 依赖
|
||||
if (!gradle.includes('cn.jiguang.sdk.plugin:honor')) {
|
||||
const depsPattern = /dependencies\s*\{/;
|
||||
if (depsPattern.test(gradle)) {
|
||||
gradle = gradle.replace(
|
||||
depsPattern,
|
||||
`dependencies {\n implementation 'cn.jiguang.sdk.plugin:honor:${jpushVersion}'`
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 注入 manifestPlaceholders: HONOR_APPID
|
||||
const manifestPlaceholderRegex = /manifestPlaceholders\s*=\s*\[([\s\S]*?)\]/;
|
||||
const match = gradle.match(manifestPlaceholderRegex);
|
||||
|
||||
const requiredPlaceholders = {
|
||||
HONOR_APPID: appId,
|
||||
};
|
||||
|
||||
if (match) {
|
||||
const existingBlock = match[1];
|
||||
const existingEntries = {};
|
||||
const entryRegex = /(\w+)\s*:\s*"([^"]*)"/g;
|
||||
let entryMatch;
|
||||
while ((entryMatch = entryRegex.exec(existingBlock)) !== null) {
|
||||
existingEntries[entryMatch[1]] = entryMatch[2];
|
||||
}
|
||||
|
||||
let placeholdersChanged = false;
|
||||
for (const [key, value] of Object.entries(requiredPlaceholders)) {
|
||||
if (existingEntries[key] !== value) {
|
||||
existingEntries[key] = value;
|
||||
placeholdersChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (placeholdersChanged) {
|
||||
const entriesStr = Object.entries(existingEntries)
|
||||
.map(([k, v]) => `${k}: "${v}"`)
|
||||
.join(',\n ');
|
||||
const newBlock = `\n manifestPlaceholders = [\n ${entriesStr}\n ]`;
|
||||
gradle = gradle.replace(manifestPlaceholderRegex, newBlock.trim());
|
||||
changed = true;
|
||||
}
|
||||
} else if (gradle.includes('REACT_NATIVE_RELEASE_LEVEL')) {
|
||||
// 没有 manifestPlaceholders block 时新建
|
||||
const entriesStr = Object.entries(requiredPlaceholders)
|
||||
.map(([k, v]) => `${k}: "${v}"`)
|
||||
.join(',\n ');
|
||||
const block = `\n manifestPlaceholders = [\n ${entriesStr}\n ]\n`;
|
||||
const lines = gradle.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('REACT_NATIVE_RELEASE_LEVEL')) {
|
||||
lines.splice(i + 1, 0, block);
|
||||
break;
|
||||
}
|
||||
}
|
||||
gradle = lines.join('\n');
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(appBuildGradlePath, gradle);
|
||||
console.log('[withHonorPush] updated app/build.gradle (AAR + manifestPlaceholders)');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3b. 处理 proguard-rules.pro ---
|
||||
if (fs.existsSync(proguardFilePath)) {
|
||||
let proguard = fs.readFileSync(proguardFilePath, 'utf-8');
|
||||
if (!proguard.includes('-keep class com.hihonor.push.**')) {
|
||||
const honorRules = `
|
||||
# JPush Honor vendor channel — keep rules (https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide)
|
||||
-ignorewarnings
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes Exceptions
|
||||
-keepattributes InnerClasses
|
||||
-keepattributes Signature
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-keep class com.hihonor.push.**{*;}
|
||||
`;
|
||||
fs.writeFileSync(proguardFilePath, proguard + honorRules);
|
||||
console.log('[withHonorPush] appended Honor Proguard rules to proguard-rules.pro');
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
module.exports = withHonorPush;
|
||||
239
plugins/withHuaweiPush.js
Normal file
@@ -0,0 +1,239 @@
|
||||
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 9+)
|
||||
if (!config.modResults.contents.match(/dependencyResolutionManagement[\s\S]*developer\.huawei\.com\/repo/)) {
|
||||
const depMgmtPattern = /dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{/;
|
||||
if (depMgmtPattern.test(config.modResults.contents)) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
depMgmtPattern,
|
||||
`$&\n maven { url 'https://developer.huawei.com/repo/' }`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
config = withProjectBuildGradle(config, (config) => {
|
||||
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) => {
|
||||
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;
|
||||
321
plugins/withJPush.js
Normal file
@@ -0,0 +1,321 @@
|
||||
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;
|
||||
43
plugins/withJcorePatch.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const { withDangerousMod } = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Patch jcore-react-native's build.gradle to remove the legacy flatDir block
|
||||
// and jniLibs.srcDirs. The 'libs' directory no longer exists in the package —
|
||||
// dependencies are fetched from Maven — but the stale config causes Gradle
|
||||
// warnings and can stall dependency resolution on newer Gradle versions.
|
||||
const withJcorePatch = (config) =>
|
||||
withDangerousMod(config, [
|
||||
'android',
|
||||
async (config) => {
|
||||
const root = config.modRequest.projectRoot;
|
||||
const gradlePath = path.join(
|
||||
root,
|
||||
'node_modules',
|
||||
'jcore-react-native',
|
||||
'android',
|
||||
'build.gradle',
|
||||
);
|
||||
|
||||
if (!fs.existsSync(gradlePath)) return config;
|
||||
|
||||
let content = fs.readFileSync(gradlePath, 'utf-8');
|
||||
|
||||
// Remove flatDir block
|
||||
content = content.replace(
|
||||
/repositories\s*\{\s*flatDir\s*\{\s*dirs\s+['"]libs['"]\s*\}\s*\}/,
|
||||
'',
|
||||
);
|
||||
|
||||
// Remove jniLibs.srcDirs line and its enclosing sourceSets block if it becomes empty
|
||||
content = content.replace(
|
||||
/\s*sourceSets\s*\{\s*main\s*\{\s*jniLibs\.srcDirs\s*=\s*\['libs'\]\s*\}\s*\}/,
|
||||
'',
|
||||
);
|
||||
|
||||
fs.writeFileSync(gradlePath, content);
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
|
||||
module.exports = withJcorePatch;
|
||||
73
plugins/withMainActivityConfigChange.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const { withMainActivity } = require('@expo/config-plugins');
|
||||
|
||||
/**
|
||||
* 在 MainActivity 中添加 onConfigurationChanged 方法
|
||||
* 用于监听系统深色模式变化并通知 React Native
|
||||
*/
|
||||
const withMainActivityConfigChange = (config) => {
|
||||
return withMainActivity(config, async (config) => {
|
||||
const { contents } = config.modResults;
|
||||
|
||||
// 检查是否已经添加了 onConfigurationChanged
|
||||
if (contents.includes('onConfigurationChanged')) {
|
||||
return config;
|
||||
}
|
||||
|
||||
// 需要添加的 imports
|
||||
const importsToAdd = `import android.content.Intent
|
||||
import android.content.res.Configuration`;
|
||||
|
||||
// 需要添加的方法
|
||||
const methodToAdd = `
|
||||
/**
|
||||
* 监听系统配置变化(包括深色模式切换),并广播给 React Native
|
||||
* 这对于 Appearance API 正确检测系统主题至关重要
|
||||
*/
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
val intent = Intent("onConfigurationChanged")
|
||||
intent.putExtra("newConfig", newConfig)
|
||||
sendBroadcast(intent)
|
||||
}
|
||||
`;
|
||||
|
||||
let newContents = contents;
|
||||
|
||||
// 添加 imports(在已有 imports 后面)
|
||||
if (!newContents.includes('import android.content.Intent')) {
|
||||
// 找到最后一个 import 语句
|
||||
const importRegex = /import [^\n]+\n/g;
|
||||
const imports = newContents.match(importRegex);
|
||||
if (imports && imports.length > 0) {
|
||||
const lastImport = imports[imports.length - 1];
|
||||
const lastImportIndex = newContents.lastIndexOf(lastImport);
|
||||
const insertPosition = lastImportIndex + lastImport.length;
|
||||
newContents =
|
||||
newContents.slice(0, insertPosition) +
|
||||
importsToAdd +
|
||||
'\n' +
|
||||
newContents.slice(insertPosition);
|
||||
}
|
||||
}
|
||||
|
||||
// 在类体的开头添加 onConfigurationChanged 方法
|
||||
// 找到类定义后的第一个方法或属性
|
||||
const classBodyRegex = /class MainActivity\s*:\s*ReactActivity\s*\(\)\s*\{([\s\S]*)/;
|
||||
const match = newContents.match(classBodyRegex);
|
||||
if (match) {
|
||||
// 找到 onCreate 方法之前插入
|
||||
const onCreateIndex = newContents.indexOf('override fun onCreate');
|
||||
if (onCreateIndex !== -1) {
|
||||
newContents =
|
||||
newContents.slice(0, onCreateIndex) +
|
||||
methodToAdd +
|
||||
newContents.slice(onCreateIndex);
|
||||
}
|
||||
}
|
||||
|
||||
config.modResults.contents = newContents;
|
||||
return config;
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = withMainActivityConfigChange;
|
||||
144
plugins/withOppoPush.js
Normal file
@@ -0,0 +1,144 @@
|
||||
const { withDangerousMod } = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// OPPO 厂商推送通道的 Expo config plugin.
|
||||
//
|
||||
// 客户端职责(依据极光官方文档 https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide):
|
||||
// 1) 添加 cn.jiguang.sdk.plugin:oppo 依赖(5.9.0+ 自动包含 heytap 官方 aar)
|
||||
// 2) 注入 manifestPlaceholders: OPPO_APPKEY / OPPO_APPID / OPPO_APPSECRET(带 OP- 前缀)
|
||||
// 3) 添加 Proguard 规则 (coloros.mcsdk / heytap / mcs 三个包保留)
|
||||
//
|
||||
// 注意事项:
|
||||
// - 极光 JPUSH_CHANNEL 是「APK 分发渠道」统计字段,不是厂商通道开关,**不应覆盖**。
|
||||
// - OPPO 与小米/vivo/荣耀不同:客户端需要 3 个占位符(含 AppSecret),且必须带 `OP-` 前缀。
|
||||
// - OPPO AAR 在 JPush 5.9.0+ 通过 maven 自动拉取,不需要手动下载 aar 文件。
|
||||
// - OPPO SDK 3.1.0+ 依赖 gson 2.6.2 和 androidx.annotation 1.1.0,5.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;
|
||||
87
plugins/withRemoveAutoStart.js
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* withRemoveAutoStart — 移除应用退出后的自启动/关联启动行为
|
||||
*
|
||||
* 整改 2.2.6 APP频繁自启动和关联启动(存在风险)
|
||||
*
|
||||
* 问题:以下 SDK 注册了 BOOT_COMPLETED 等 intent-filter,
|
||||
* 导致设备重启时应用被自动唤醒:
|
||||
* - expo.modules.taskManager.TaskBroadcastReceiver
|
||||
* - expo.modules.notifications.service.NotificationsService
|
||||
* - androidx.work.impl.background.systemalarm.RescheduleReceiver
|
||||
* - androidx.work.impl.background.systemalarm.ConstraintProxy* (多个)
|
||||
*
|
||||
* 本插件在 final manifest 合并阶段移除这些 intent-filter action,
|
||||
* 从根本上消除应用退出后的自启动行为。
|
||||
*/
|
||||
|
||||
const { withAndroidManifest } = require('expo/config-plugins');
|
||||
|
||||
// 需要从 receivers 中移除的自启动相关 action
|
||||
const AUTO_START_ACTIONS = new Set([
|
||||
'android.intent.action.BOOT_COMPLETED',
|
||||
'android.intent.action.REBOOT',
|
||||
'android.intent.action.QUICKBOOT_POWERON',
|
||||
'com.htc.intent.action.QUICKBOOT_POWERON',
|
||||
]);
|
||||
|
||||
function removeAutoStartActionsFromManifest(androidManifest) {
|
||||
const app = androidManifest.manifest.application?.[0];
|
||||
if (!app) return androidManifest;
|
||||
|
||||
// 处理 <receiver> 标签
|
||||
const receivers = app.receiver || [];
|
||||
for (const receiver of receivers) {
|
||||
if (!receiver['intent-filter']) continue;
|
||||
|
||||
receiver['intent-filter'] = receiver['intent-filter'].map((filter) => {
|
||||
if (!filter.action) return filter;
|
||||
|
||||
const originalCount = filter.action.length;
|
||||
filter.action = filter.action.filter(
|
||||
(action) => !AUTO_START_ACTIONS.has(action.$?.['android:name'])
|
||||
);
|
||||
|
||||
if (filter.action.length !== originalCount) {
|
||||
const removed = originalCount - filter.action.length;
|
||||
console.log(
|
||||
`[withRemoveAutoStart] 移除了 ${removed} 个自启动 action (receiver)`
|
||||
);
|
||||
}
|
||||
|
||||
return filter;
|
||||
});
|
||||
|
||||
// 如果 intent-filter 中没有 action 了,移除整个 intent-filter
|
||||
receiver['intent-filter'] = receiver['intent-filter'].filter(
|
||||
(filter) => filter.action && filter.action.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
// 移除 RECEIVE_BOOT_COMPLETED 权限声明
|
||||
if (androidManifest.manifest['uses-permission']) {
|
||||
const originalPerms = androidManifest.manifest['uses-permission'].length;
|
||||
androidManifest.manifest['uses-permission'] = androidManifest.manifest[
|
||||
'uses-permission'
|
||||
].filter((perm) => {
|
||||
const name = perm.$?.['android:name'];
|
||||
return name !== 'android.permission.RECEIVE_BOOT_COMPLETED';
|
||||
});
|
||||
const removedPerms = originalPerms - androidManifest.manifest['uses-permission'].length;
|
||||
if (removedPerms > 0) {
|
||||
console.log(
|
||||
`[withRemoveAutoStart] 移除了 RECEIVE_BOOT_COMPLETED 权限声明 (${removedPerms} 处)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return androidManifest;
|
||||
}
|
||||
|
||||
function withRemoveAutoStart(config) {
|
||||
return withAndroidManifest(config, (config) => {
|
||||
config.modResults = removeAutoStartActionsFromManifest(config.modResults);
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = withRemoveAutoStart;
|
||||
129
plugins/withSigning.js
Normal file
@@ -0,0 +1,129 @@
|
||||
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;
|
||||
141
plugins/withVivoPush.js
Normal file
@@ -0,0 +1,141 @@
|
||||
const { withDangerousMod } = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// vivo 厂商推送通道的 Expo config plugin.
|
||||
//
|
||||
// 客户端职责(依据极光官方文档 https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide):
|
||||
// 1) 添加 cn.jiguang.sdk.plugin:vivo 依赖
|
||||
// 2) 注入 manifestPlaceholders: VIVO_APPKEY / VIVO_APPID(AAR 自带 vivo Receiver)
|
||||
// 3) 添加 Proguard 规则 (com.vivo.push.** + com.vivo.vms.** 保留)
|
||||
//
|
||||
// 注意事项:
|
||||
// - 极光 JPUSH_CHANNEL 是「APK 分发渠道」统计字段,不是厂商通道开关,**不应覆盖**。
|
||||
// - 客户端仅需要 VIVO_APPKEY / VIVO_APPID 两个占位符。
|
||||
// VIVO_APP_SECRET 只在极光控制台「集成设置」页面填写,客户端不需要。
|
||||
// - vivo AAR 已发布到 Maven Central,不需要额外 Maven 仓库。
|
||||
// - vivo 平台限制:若应用未上架 vivo 商店,测试推送时需在 vivo 推送平台添加测试设备,
|
||||
// 且需通过 API 指定 push_mode=1(测试推送)下发。
|
||||
//
|
||||
// 实现说明:用 withDangerousMod 直接读写磁盘文件。
|
||||
// 原因:Expo SDK 56+ 的 withAppBuildGradle hook 链中,第二个以后的 plugin 写回
|
||||
// modResults.contents 会被静默忽略,导致多个 vendor plugin 只能写入第一个的修改。
|
||||
// withDangerousMod 走文件系统,串行、可靠,幂等检查保证可重复执行 prebuild。
|
||||
const withVivoPush = (config, options = {}) => {
|
||||
const {
|
||||
jpushVersion = '6.1.0',
|
||||
appId = '106100295',
|
||||
appKey = '2110cb5f6dc5bd7abea82c38f8d0a1ec',
|
||||
} = options;
|
||||
|
||||
config = withDangerousMod(config, [
|
||||
'android',
|
||||
async (config) => {
|
||||
const appBuildGradlePath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
'app',
|
||||
'build.gradle'
|
||||
);
|
||||
const proguardFilePath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
'app',
|
||||
'proguard-rules.pro'
|
||||
);
|
||||
|
||||
// --- 1. 处理 build.gradle ---
|
||||
if (fs.existsSync(appBuildGradlePath)) {
|
||||
let gradle = fs.readFileSync(appBuildGradlePath, 'utf-8');
|
||||
let changed = false;
|
||||
|
||||
// 1a. 注入 AAR 依赖
|
||||
if (!gradle.includes('cn.jiguang.sdk.plugin:vivo')) {
|
||||
const depsPattern = /dependencies\s*\{/;
|
||||
if (depsPattern.test(gradle)) {
|
||||
gradle = gradle.replace(
|
||||
depsPattern,
|
||||
`dependencies {\n implementation 'cn.jiguang.sdk.plugin:vivo:${jpushVersion}'`
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 1b. 注入 manifestPlaceholders
|
||||
const manifestPlaceholderRegex = /manifestPlaceholders\s*=\s*\[([\s\S]*?)\]/;
|
||||
const match = gradle.match(manifestPlaceholderRegex);
|
||||
|
||||
const requiredPlaceholders = {
|
||||
VIVO_APPKEY: appKey,
|
||||
VIVO_APPID: appId,
|
||||
};
|
||||
|
||||
if (match) {
|
||||
const existingBlock = match[1];
|
||||
const existingEntries = {};
|
||||
const entryRegex = /(\w+)\s*:\s*"([^"]*)"/g;
|
||||
let entryMatch;
|
||||
while ((entryMatch = entryRegex.exec(existingBlock)) !== null) {
|
||||
existingEntries[entryMatch[1]] = entryMatch[2];
|
||||
}
|
||||
|
||||
let placeholdersChanged = false;
|
||||
for (const [key, value] of Object.entries(requiredPlaceholders)) {
|
||||
if (existingEntries[key] !== value) {
|
||||
existingEntries[key] = value;
|
||||
placeholdersChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (placeholdersChanged) {
|
||||
const entriesStr = Object.entries(existingEntries)
|
||||
.map(([k, v]) => `${k}: "${v}"`)
|
||||
.join(',\n ');
|
||||
const newBlock = `\n manifestPlaceholders = [\n ${entriesStr}\n ]`;
|
||||
gradle = gradle.replace(manifestPlaceholderRegex, newBlock.trim());
|
||||
changed = true;
|
||||
}
|
||||
} else if (gradle.includes('REACT_NATIVE_RELEASE_LEVEL')) {
|
||||
// 没有 manifestPlaceholders block 时新建
|
||||
const entriesStr = Object.entries(requiredPlaceholders)
|
||||
.map(([k, v]) => `${k}: "${v}"`)
|
||||
.join(',\n ');
|
||||
const block = `\n manifestPlaceholders = [\n ${entriesStr}\n ]\n`;
|
||||
const lines = gradle.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('REACT_NATIVE_RELEASE_LEVEL')) {
|
||||
lines.splice(i + 1, 0, block);
|
||||
break;
|
||||
}
|
||||
}
|
||||
gradle = lines.join('\n');
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(appBuildGradlePath, gradle);
|
||||
console.log('[withVivoPush] updated app/build.gradle (AAR + manifestPlaceholders)');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. 处理 proguard-rules.pro ---
|
||||
if (fs.existsSync(proguardFilePath)) {
|
||||
let proguard = fs.readFileSync(proguardFilePath, 'utf-8');
|
||||
if (!proguard.includes('-keep class com.vivo.push.**')) {
|
||||
const vivoRules = `
|
||||
# JPush vivo vendor channel — keep rules (https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide)
|
||||
-dontwarn com.vivo.push.**
|
||||
-keep class com.vivo.push.** { *; }
|
||||
-keep class com.vivo.vms.** { *; }
|
||||
`;
|
||||
fs.writeFileSync(proguardFilePath, proguard + vivoRules);
|
||||
console.log('[withVivoPush] appended vivo Proguard rules to proguard-rules.pro');
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
module.exports = withVivoPush;
|
||||
133
plugins/withXiaomiPush.js
Normal file
@@ -0,0 +1,133 @@
|
||||
const { withDangerousMod } = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 小米厂商推送通道的 Expo config plugin.
|
||||
//
|
||||
// 客户端职责(依据极光官方文档 https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide):
|
||||
// 1) 添加 cn.jiguang.sdk.plugin:xiaomi 依赖
|
||||
// 2) 注入 manifestPlaceholders: XIAOMI_APPKEY / XIAOMI_APPID
|
||||
// 3) 添加 Proguard 规则 (com.xiaomi.push.** 保留)
|
||||
//
|
||||
// 实现说明:用 withDangerousMod 直接读写磁盘文件。
|
||||
// 原因:Expo SDK 56+ 的 withAppBuildGradle hook 链中,第二个以后的 plugin 写回
|
||||
// modResults.contents 会被静默忽略(基于 withAndroidProjectBuildGradleBaseMod),
|
||||
// 导致多个 vendor plugin 只能写入第一个的修改。withDangerousMod 走文件系统,
|
||||
// 串行、可靠,幂等检查保证可重复执行 prebuild。
|
||||
const withXiaomiPush = (config, options = {}) => {
|
||||
const {
|
||||
jpushVersion = '6.1.0',
|
||||
appId = '2882303761520539252',
|
||||
appKey = '5792053978252',
|
||||
} = options;
|
||||
|
||||
config = withDangerousMod(config, [
|
||||
'android',
|
||||
async (config) => {
|
||||
const appBuildGradlePath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
'app',
|
||||
'build.gradle'
|
||||
);
|
||||
const proguardFilePath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
'app',
|
||||
'proguard-rules.pro'
|
||||
);
|
||||
|
||||
// --- 1. 处理 build.gradle ---
|
||||
if (fs.existsSync(appBuildGradlePath)) {
|
||||
let gradle = fs.readFileSync(appBuildGradlePath, 'utf-8');
|
||||
let changed = false;
|
||||
|
||||
// 1a. 注入 AAR 依赖
|
||||
if (!gradle.includes('cn.jiguang.sdk.plugin:xiaomi')) {
|
||||
const depsPattern = /dependencies\s*\{/;
|
||||
if (depsPattern.test(gradle)) {
|
||||
gradle = gradle.replace(
|
||||
depsPattern,
|
||||
`dependencies {\n implementation 'cn.jiguang.sdk.plugin:xiaomi:${jpushVersion}'`
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 1b. 注入 manifestPlaceholders
|
||||
const manifestPlaceholderRegex = /manifestPlaceholders\s*=\s*\[([\s\S]*?)\]/;
|
||||
const match = gradle.match(manifestPlaceholderRegex);
|
||||
|
||||
const requiredPlaceholders = {
|
||||
XIAOMI_APPKEY: appKey,
|
||||
XIAOMI_APPID: appId,
|
||||
};
|
||||
|
||||
if (match) {
|
||||
const existingBlock = match[1];
|
||||
const existingEntries = {};
|
||||
const entryRegex = /(\w+)\s*:\s*"([^"]*)"/g;
|
||||
let entryMatch;
|
||||
while ((entryMatch = entryRegex.exec(existingBlock)) !== null) {
|
||||
existingEntries[entryMatch[1]] = entryMatch[2];
|
||||
}
|
||||
|
||||
let placeholdersChanged = false;
|
||||
for (const [key, value] of Object.entries(requiredPlaceholders)) {
|
||||
if (existingEntries[key] !== value) {
|
||||
existingEntries[key] = value;
|
||||
placeholdersChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (placeholdersChanged) {
|
||||
const entriesStr = Object.entries(existingEntries)
|
||||
.map(([k, v]) => `${k}: "${v}"`)
|
||||
.join(',\n ');
|
||||
const newBlock = `\n manifestPlaceholders = [\n ${entriesStr}\n ]`;
|
||||
gradle = gradle.replace(manifestPlaceholderRegex, newBlock.trim());
|
||||
changed = true;
|
||||
}
|
||||
} else if (gradle.includes('REACT_NATIVE_RELEASE_LEVEL')) {
|
||||
// 没有 manifestPlaceholders block 时新建
|
||||
const entriesStr = Object.entries(requiredPlaceholders)
|
||||
.map(([k, v]) => `${k}: "${v}"`)
|
||||
.join(',\n ');
|
||||
const block = `\n manifestPlaceholders = [\n ${entriesStr}\n ]\n`;
|
||||
const lines = gradle.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('REACT_NATIVE_RELEASE_LEVEL')) {
|
||||
lines.splice(i + 1, 0, block);
|
||||
break;
|
||||
}
|
||||
}
|
||||
gradle = lines.join('\n');
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(appBuildGradlePath, gradle);
|
||||
console.log('[withXiaomiPush] updated app/build.gradle (AAR + manifestPlaceholders)');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. 处理 proguard-rules.pro ---
|
||||
if (fs.existsSync(proguardFilePath)) {
|
||||
let proguard = fs.readFileSync(proguardFilePath, 'utf-8');
|
||||
if (!proguard.includes('-keep class com.xiaomi.push.**')) {
|
||||
const xiaomiRules = `
|
||||
# JPush Xiaomi vendor channel — keep rules (https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide)
|
||||
-dontwarn com.xiaomi.push.**
|
||||
-keep class com.xiaomi.push.** { *; }
|
||||
`;
|
||||
fs.writeFileSync(proguardFilePath, proguard + xiaomiRules);
|
||||
console.log('[withXiaomiPush] appended Xiaomi Proguard rules to proguard-rules.pro');
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
module.exports = withXiaomiPush;
|
||||
@@ -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.replace(href);
|
||||
router.push(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}>胡萝卜BBS</Text>}
|
||||
{!isCollapsed && <Text style={styles.logoText}>威友</Text>}
|
||||
</View>
|
||||
<ScrollView style={styles.sidebarContent} showsVerticalScrollIndicator={false}>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
|
||||
124
src/components/business/BlockEditor/BlockEditor.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
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;
|
||||
80
src/components/business/BlockEditor/ImageBlockView.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
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;
|
||||
276
src/components/business/BlockEditor/TextBlockInput.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import Text from '../../common/Text';
|
||||
import Avatar from '../../common/Avatar';
|
||||
import { useAppColors, spacing, fontSizes, borderRadius } from '../../../theme';
|
||||
import { useAuthStore } from '../../../stores';
|
||||
import { authService } from '../../../services/auth';
|
||||
import type { TextBlock } from './blockEditorTypes';
|
||||
|
||||
interface MentionUser {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
interface TextBlockInputProps {
|
||||
block: TextBlock;
|
||||
isActive: boolean;
|
||||
placeholder: string;
|
||||
maxLength: number;
|
||||
onTextChange: (blockId: string, text: string, mentions: Map<number, { endIndex: number; userId: string; nickname: string }>) => void;
|
||||
onFocusBlock: (blockId: string) => void;
|
||||
onSelectMention: (blockId: string, userId: string, nickname: string, mentionStartIndex: number) => void;
|
||||
inputRef: (ref: TextInput | null) => void;
|
||||
style?: any;
|
||||
minHeight?: number;
|
||||
}
|
||||
|
||||
const TextBlockInput: React.FC<TextBlockInputProps> = ({
|
||||
block,
|
||||
isActive,
|
||||
placeholder,
|
||||
maxLength,
|
||||
onTextChange,
|
||||
onFocusBlock,
|
||||
onSelectMention,
|
||||
inputRef,
|
||||
style,
|
||||
minHeight,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const currentUser = useAuthStore(s => s.currentUser);
|
||||
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [suggestionMode, setSuggestionMode] = useState<'none' | 'mention'>('none');
|
||||
const [mentionQuery, setMentionQuery] = useState('');
|
||||
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
|
||||
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded && currentUser?.id) {
|
||||
loadFollowing();
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [currentUser?.id, loaded]);
|
||||
|
||||
const loadFollowing = async () => {
|
||||
if (!currentUser?.id) return;
|
||||
try {
|
||||
const list = await authService.getFollowingList(currentUser.id, 1, 200);
|
||||
setFollowingUsers(
|
||||
list.map(u => ({
|
||||
id: u.id,
|
||||
nickname: u.nickname || u.username || '',
|
||||
avatar: u.avatar || null,
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = followingUsers.filter(u =>
|
||||
u.nickname.toLowerCase().includes(mentionQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const handleChangeText = useCallback(
|
||||
(newText: string) => {
|
||||
const cursorPos = newText.length;
|
||||
|
||||
// Check for @ trigger
|
||||
let atStart = -1;
|
||||
for (let i = cursorPos - 1; i >= 0; i--) {
|
||||
if (newText[i] === '@') {
|
||||
atStart = i;
|
||||
break;
|
||||
}
|
||||
if (newText[i] === ' ' || newText[i] === '\n') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (atStart >= 0) {
|
||||
const query = newText.slice(atStart + 1, cursorPos);
|
||||
if (!query.includes(' ') && !query.includes('\n')) {
|
||||
setMentionQuery(query);
|
||||
setMentionStartIndex(atStart);
|
||||
setSuggestionMode('mention');
|
||||
} else {
|
||||
setSuggestionMode('none');
|
||||
}
|
||||
} else {
|
||||
setSuggestionMode('none');
|
||||
}
|
||||
|
||||
// Update mentions when text changes: remap existing mentions
|
||||
const newMentions = new Map<number, { endIndex: number; userId: string; nickname: string }>();
|
||||
// Simple approach: only keep mentions tracked by this block's own activeMentions
|
||||
// The parent handles the canonical mentions state
|
||||
block.activeMentions.forEach((mention, start) => {
|
||||
if (start < newText.length) {
|
||||
const mentionText = `@${mention.nickname} `;
|
||||
const expectedEnd = start + mentionText.length;
|
||||
// Check if the mention text is still intact
|
||||
if (newText.slice(start, expectedEnd) === mentionText) {
|
||||
newMentions.set(start, { ...mention, endIndex: expectedEnd });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onTextChange(block.id, newText, newMentions);
|
||||
},
|
||||
[block.id, block.activeMentions, onTextChange],
|
||||
);
|
||||
|
||||
const handleSelectMention = useCallback(
|
||||
(mentionUser: MentionUser) => {
|
||||
if (mentionStartIndex < 0) return;
|
||||
|
||||
const before = block.text.slice(0, mentionStartIndex);
|
||||
const mentionText = `@${mentionUser.nickname} `;
|
||||
const newText = before + mentionText;
|
||||
|
||||
const newMentions = new Map(block.activeMentions);
|
||||
newMentions.set(mentionStartIndex, {
|
||||
endIndex: before.length + mentionText.length,
|
||||
userId: mentionUser.id,
|
||||
nickname: mentionUser.nickname,
|
||||
});
|
||||
|
||||
// Also remove any mention that was partially covered
|
||||
const toRemove: number[] = [];
|
||||
newMentions.forEach((m, start) => {
|
||||
if (start !== mentionStartIndex && start >= mentionStartIndex && start < before.length + mentionText.length) {
|
||||
toRemove.push(start);
|
||||
}
|
||||
});
|
||||
toRemove.forEach(k => newMentions.delete(k));
|
||||
|
||||
onTextChange(block.id, newText, newMentions);
|
||||
setSuggestionMode('none');
|
||||
setMentionQuery('');
|
||||
setMentionStartIndex(-1);
|
||||
|
||||
onSelectMention(block.id, mentionUser.id, mentionUser.nickname, mentionStartIndex);
|
||||
},
|
||||
[block.id, block.text, mentionStartIndex, onTextChange, onSelectMention],
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={block.text}
|
||||
onChangeText={handleChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.text.hint}
|
||||
maxLength={maxLength}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
scrollEnabled={false}
|
||||
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
|
||||
onFocus={() => onFocusBlock(block.id)}
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
color: colors.text.primary,
|
||||
minHeight: minHeight,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
|
||||
{suggestionMode === 'mention' && isActive && filteredUsers.length > 0 && (
|
||||
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
nestedScrollEnabled
|
||||
style={styles.mentionList}
|
||||
>
|
||||
{filteredUsers.map(item => (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
style={[styles.mentionItem, { borderBottomColor: colors.divider }]}
|
||||
onPress={() => handleSelectMention(item)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Avatar source={item.avatar} size={36} name={item.nickname} />
|
||||
<View style={styles.mentionInfo}>
|
||||
<Text style={[styles.mentionName, { color: colors.text.primary }]}>
|
||||
{item.nickname}
|
||||
</Text>
|
||||
<Text style={[styles.mentionHint, { color: colors.text.hint }]}>
|
||||
点击提及
|
||||
</Text>
|
||||
</View>
|
||||
<MaterialCommunityIcons name="at" size={18} color={colors.primary.main} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{suggestionMode === 'mention' && isActive && filteredUsers.length === 0 && (
|
||||
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
|
||||
<Text style={[styles.emptyText, { color: colors.text.hint }]}>
|
||||
没有找到匹配的关注用户
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
input: {
|
||||
fontSize: fontSizes.md,
|
||||
paddingHorizontal: 0,
|
||||
paddingVertical: 0,
|
||||
textAlignVertical: 'top' as const,
|
||||
},
|
||||
mentionPanel: {
|
||||
borderWidth: 1,
|
||||
borderRadius: borderRadius.md,
|
||||
maxHeight: 200,
|
||||
marginHorizontal: spacing.sm,
|
||||
},
|
||||
mentionList: {
|
||||
maxHeight: 200,
|
||||
},
|
||||
mentionItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
mentionInfo: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.sm,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
mentionName: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '500',
|
||||
},
|
||||
mentionHint: {
|
||||
fontSize: fontSizes.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: fontSizes.sm,
|
||||
padding: spacing.md,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default TextBlockInput;
|
||||
103
src/components/business/BlockEditor/blockEditorTypes.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { MessageSegment, AtSegmentData } from '../../../types';
|
||||
|
||||
export interface TextBlock {
|
||||
id: string;
|
||||
type: 'text';
|
||||
text: string;
|
||||
activeMentions: Map<number, { endIndex: number; userId: string; nickname: string }>;
|
||||
}
|
||||
|
||||
export interface ImageBlock {
|
||||
id: string;
|
||||
type: 'image';
|
||||
localUri: string;
|
||||
remoteUrl?: string;
|
||||
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;
|
||||
}
|
||||
96
src/components/business/BlockEditor/blocksToSegments.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
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();
|
||||
}
|
||||
2
src/components/business/BlockEditor/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './BlockEditor';
|
||||
export type { BlockEditorHandle } from './BlockEditor';
|
||||
302
src/components/business/BlockEditor/useBlockEditor.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -6,19 +6,19 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { formatChatTime } from '../../utils/formatTime';
|
||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { Comment, CommentImage } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
import { CompactImageGrid, ImageGridItem } from '../common';
|
||||
import PostContentRenderer from './PostContentRenderer';
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: Comment;
|
||||
onUserPress: () => void;
|
||||
onReply: () => void;
|
||||
onLike: () => void;
|
||||
onLike: (comment: Comment) => void; // 点赞回调,传入评论对象
|
||||
floorNumber?: number; // 楼层号
|
||||
isAuthor?: boolean; // 是否是楼主
|
||||
replyToUser?: string; // 回复给哪位用户
|
||||
@@ -29,6 +29,7 @@ interface CommentItemProps {
|
||||
onDelete?: (comment: Comment) => void; // 删除评论的回调
|
||||
onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图
|
||||
currentUserId?: string; // 当前用户ID,用于判断子评论作者
|
||||
onReport?: (comment: Comment) => void; // 举报评论的回调
|
||||
}
|
||||
|
||||
function createCommentItemStyles(colors: AppColors) {
|
||||
@@ -126,7 +127,7 @@ function createCommentItemStyles(colors: AppColors) {
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.lg,
|
||||
marginRight: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
paddingRight: spacing.xs,
|
||||
},
|
||||
@@ -141,7 +142,6 @@ function createCommentItemStyles(colors: AppColors) {
|
||||
paddingVertical: spacing.xs,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: colors.divider,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
subReplyItem: {
|
||||
flexDirection: 'row',
|
||||
@@ -152,8 +152,8 @@ function createCommentItemStyles(colors: AppColors) {
|
||||
marginBottom: 0,
|
||||
},
|
||||
subReplyBody: {
|
||||
fontSize: fontSizes.sm,
|
||||
lineHeight: 20,
|
||||
fontSize: fontSizes.md,
|
||||
lineHeight: 22,
|
||||
marginTop: 2,
|
||||
},
|
||||
subReplyContent: {
|
||||
@@ -175,10 +175,10 @@ function createCommentItemStyles(colors: AppColors) {
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
replyToText: {
|
||||
fontSize: fontSizes.xs,
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
replyToName: {
|
||||
fontSize: fontSizes.xs,
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '500',
|
||||
},
|
||||
subReplyActions: {
|
||||
@@ -214,21 +214,11 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
onDelete,
|
||||
onImagePress,
|
||||
currentUserId,
|
||||
onReport,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
// 格式化时间
|
||||
const formatTime = (dateString: string): string => {
|
||||
try {
|
||||
return formatDistanceToNow(new Date(dateString), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num: number): string => {
|
||||
@@ -477,7 +467,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
{targetNickname && (
|
||||
<>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.replyToText}>
|
||||
{' '}回复
|
||||
{' '}回复{' '}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.primary.main} style={styles.replyToName}>
|
||||
{targetNickname}
|
||||
@@ -487,7 +477,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
</View>
|
||||
{/* 显示回复内容(如果有文字) */}
|
||||
{reply.content ? (
|
||||
<Text variant="body" color={colors.text.primary} style={styles.subReplyBody}>
|
||||
<Text variant="body" color={colors.text.primary} selectable style={styles.subReplyBody}>
|
||||
{reply.content}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -495,11 +485,30 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
{renderSubReplyImages(reply)}
|
||||
{/* 子评论操作按钮 */}
|
||||
<View style={styles.subReplyActions}>
|
||||
{/* 点赞按钮 */}
|
||||
<TouchableOpacity
|
||||
style={styles.subActionButton}
|
||||
onPress={() => onLike?.(reply)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={reply.is_liked ? 'heart' : 'heart-outline'}
|
||||
size={14}
|
||||
color={reply.is_liked ? colors.error.main : colors.text.hint}
|
||||
/>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={reply.is_liked ? colors.error.main : colors.text.hint}
|
||||
style={styles.subActionText}
|
||||
>
|
||||
{reply.likes_count > 0 ? formatNumber(reply.likes_count) : '赞'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.subActionButton}
|
||||
onPress={() => onReplyPress?.(reply)}
|
||||
>
|
||||
<MaterialCommunityIcons name="reply" size={12} color={colors.text.hint} />
|
||||
<MaterialCommunityIcons name="reply" size={14} color={colors.text.hint} />
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
|
||||
回复
|
||||
</Text>
|
||||
@@ -513,7 +522,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={isDeleting ? 'loading' : 'delete-outline'}
|
||||
size={12}
|
||||
size={14}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
|
||||
@@ -521,6 +530,17 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{!isSubReplyAuthor && onReport && (
|
||||
<TouchableOpacity
|
||||
style={styles.subActionButton}
|
||||
onPress={() => onReport(reply)}
|
||||
>
|
||||
<MaterialCommunityIcons name="flag-outline" size={14} color={colors.text.hint} />
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
|
||||
举报
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
@@ -564,7 +584,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
{renderBadges()}
|
||||
<Text style={styles.metaDot}>·</Text>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
|
||||
{formatTime(comment.created_at || '')}
|
||||
{formatChatTime(comment.created_at || '')}
|
||||
</Text>
|
||||
</View>
|
||||
{renderFloorNumber()}
|
||||
@@ -581,9 +601,17 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
|
||||
{/* 评论文本 - 非气泡样式 */}
|
||||
<View style={styles.commentContent}>
|
||||
<Text variant="body" color={colors.text.primary} style={styles.text}>
|
||||
{comment.segments && comment.segments.length > 0 ? (
|
||||
<PostContentRenderer
|
||||
content={comment.content}
|
||||
segments={comment.segments}
|
||||
textStyle={[styles.text, { color: colors.text.primary }]}
|
||||
/>
|
||||
) : (
|
||||
<Text variant="body" color={colors.text.primary} selectable style={styles.text}>
|
||||
{comment.content}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 评论图片 */}
|
||||
@@ -592,7 +620,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
{/* 操作按钮 - 更紧凑 */}
|
||||
<View style={styles.actions}>
|
||||
{/* 点赞 */}
|
||||
<TouchableOpacity style={styles.actionButton} onPress={onLike}>
|
||||
<TouchableOpacity style={styles.actionButton} onPress={() => onLike(comment)}>
|
||||
<MaterialCommunityIcons
|
||||
name={comment.is_liked ? 'heart' : 'heart-outline'}
|
||||
size={14}
|
||||
@@ -615,6 +643,19 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 举报按钮 - 只对非评论作者显示 */}
|
||||
{!isCommentAuthor && onReport && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => onReport(comment)}
|
||||
>
|
||||
<MaterialCommunityIcons name="flag-outline" size={14} color={colors.text.hint} />
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
|
||||
举报
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 - 只对评论作者显示 */}
|
||||
{isCommentAuthor && (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -16,13 +16,14 @@ import React, { useMemo, useState, memo, useEffect } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Alert, TextStyle } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { PostCardProps, PostCardAction } from './types';
|
||||
import { ImageGridItem, SmartImage } from '../../common';
|
||||
import { ImageGridItem, SmartImage, ImageGrid } from '../../common';
|
||||
import Text from '../../common/Text';
|
||||
import HighlightText from '../../common/HighlightText';
|
||||
import Avatar from '../../common/Avatar';
|
||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme';
|
||||
import { getPreviewImageUrl } from '../../../utils/imageHelper';
|
||||
import PostImages from './components/PostImages';
|
||||
import { useResponsive } from '../../../hooks/useResponsive';
|
||||
import PostContentRenderer from '../PostContentRenderer';
|
||||
import { useResponsive } from '../../../hooks';
|
||||
import { formatPostCreatedAtString } from '../../../core/entities/Post';
|
||||
|
||||
/** 列表相对时间:独立定时刷新,避免外层 PostCard.memo 挡住「分钟前」更新 */
|
||||
@@ -53,6 +54,13 @@ function imagesSignature(
|
||||
return images.map((i) => i.id).join('\u001f');
|
||||
}
|
||||
|
||||
function segmentSignature(
|
||||
segments: PostCardProps['post']['segments'] | undefined
|
||||
): string {
|
||||
if (!segments?.length) return '';
|
||||
return segments.map(s => `${s.type}:${s.type === 'image' ? (s.data as any)?.url || '' : ''}`).join('\u001f');
|
||||
}
|
||||
|
||||
function authorSignature(author: PostCardProps['post']['author']): string {
|
||||
if (!author) return '';
|
||||
return [author.id, author.nickname ?? '', author.avatar ?? ''].join('\u001f');
|
||||
@@ -84,6 +92,7 @@ function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolea
|
||||
if (prev.isAuthor !== next.isAuthor) return false;
|
||||
if (prev.style !== next.style) return false;
|
||||
if (featuresComparable(prev.features) !== featuresComparable(next.features)) return false;
|
||||
if (prev.highlightKeyword !== next.highlightKeyword) return false;
|
||||
|
||||
const a = prev.post;
|
||||
const b = next.post;
|
||||
@@ -107,6 +116,7 @@ function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolea
|
||||
if (!!a.is_favorited !== !!b.is_favorited) return false;
|
||||
if (authorSignature(a.author) !== authorSignature(b.author)) return false;
|
||||
if (imagesSignature(a.images) !== imagesSignature(b.images)) return false;
|
||||
if (segmentSignature(a.segments) !== segmentSignature(b.segments)) return false;
|
||||
if (topCommentSignature(a.top_comment) !== topCommentSignature(b.top_comment)) return false;
|
||||
if (channelSignature(a.channel) !== channelSignature(b.channel)) return false;
|
||||
|
||||
@@ -206,6 +216,9 @@ function createPostCardStyles(colors: AppColors) {
|
||||
marginBottom: spacing.xs,
|
||||
lineHeight: fontSizes.md * 1.45,
|
||||
},
|
||||
contentRenderer: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
expandBtn: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
@@ -242,6 +255,7 @@ function createPostCardStyles(colors: AppColors) {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
actionsLeading: {
|
||||
flexDirection: 'row',
|
||||
@@ -280,10 +294,10 @@ function createPostCardStyles(colors: AppColors) {
|
||||
marginLeft: 4,
|
||||
},
|
||||
activeActionText: {
|
||||
color: colors.error.main,
|
||||
color: colors.primary.main,
|
||||
},
|
||||
activeActionTextMerged: {
|
||||
color: colors.error.main,
|
||||
color: colors.primary.main,
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 4,
|
||||
},
|
||||
@@ -374,6 +388,10 @@ function createPostCardStyles(colors: AppColors) {
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 4,
|
||||
},
|
||||
highlight: {
|
||||
backgroundColor: `${colors.warning.main}40`,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -391,6 +409,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
isAuthor = false,
|
||||
index,
|
||||
style,
|
||||
highlightKeyword,
|
||||
} = normalizedProps;
|
||||
|
||||
const colors = useAppColors();
|
||||
@@ -415,6 +434,22 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
const rawContent = post.content ?? '';
|
||||
const { isDesktop, isTablet, isWideScreen, isLandscape } = useResponsive();
|
||||
|
||||
const displayContent = useMemo(() => {
|
||||
if (!post.segments?.length) return rawContent;
|
||||
const segs = post.segments;
|
||||
let result = '';
|
||||
for (const s of segs) {
|
||||
if (s.type === 'image') {
|
||||
result += '[图片]';
|
||||
} else if (s.type === 'text') {
|
||||
result += (s.data as any)?.text || (s.data as any)?.content || '';
|
||||
} else if (s.type === 'at') {
|
||||
result += `@${(s.data as any)?.nickname || '某人'}`;
|
||||
}
|
||||
}
|
||||
return result || rawContent;
|
||||
}, [post.segments, rawContent]);
|
||||
|
||||
const handleCardPress = () => emit({ type: 'press' });
|
||||
const handleUserPress = () => emit({ type: 'userPress' });
|
||||
const handleLike = () => emit({ type: post.is_liked ? 'unlike' : 'like' });
|
||||
@@ -444,7 +479,17 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const images: ImageGridItem[] = Array.isArray(post.images)
|
||||
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,
|
||||
@@ -454,7 +499,9 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
}))
|
||||
: [];
|
||||
: [],
|
||||
...segmentImages,
|
||||
];
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 10000) return `${(num / 10000).toFixed(1)}w`;
|
||||
@@ -463,6 +510,13 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
};
|
||||
|
||||
const getResponsiveMaxLength = () => {
|
||||
// 搜索高亮模式下展示更多内容
|
||||
if (highlightKeyword) {
|
||||
if (isWideScreen) return 500;
|
||||
if (isDesktop) return 400;
|
||||
if (isTablet) return 350;
|
||||
return 220;
|
||||
}
|
||||
if (isWideScreen) return 300;
|
||||
if (isDesktop) return 250;
|
||||
if (isTablet) return 200;
|
||||
@@ -470,22 +524,60 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
};
|
||||
|
||||
const contentNumberOfLines = useMemo(() => {
|
||||
if (highlightKeyword) {
|
||||
if (isWideScreen) return 12;
|
||||
if (isDesktop) return 10;
|
||||
if (isTablet) return 8;
|
||||
return 5;
|
||||
}
|
||||
if (isWideScreen) return 8;
|
||||
if (isDesktop) return 6;
|
||||
if (isTablet) return 5;
|
||||
return 3;
|
||||
}, [isWideScreen, isDesktop, isTablet]);
|
||||
}, [isWideScreen, isDesktop, isTablet, highlightKeyword]);
|
||||
|
||||
const getSearchExcerpt = (value: string, keyword: string): string => {
|
||||
const maxLength = getResponsiveMaxLength();
|
||||
if (value.length <= maxLength) return value;
|
||||
|
||||
const lowerValue = value.toLowerCase();
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
const index = lowerValue.indexOf(lowerKeyword);
|
||||
|
||||
if (index === -1) {
|
||||
return `${value.substring(0, maxLength)}...`;
|
||||
}
|
||||
|
||||
const context = Math.floor((maxLength - keyword.length) / 2);
|
||||
let start = Math.max(0, index - context);
|
||||
let end = Math.min(value.length, index + keyword.length + context);
|
||||
|
||||
if (start > 0) {
|
||||
while (start > 0 && value[start] !== ' ' && value[start] !== '\n') start--;
|
||||
if (value[start] === ' ' || value[start] === '\n') start++;
|
||||
}
|
||||
if (end < value.length) {
|
||||
while (end < value.length && value[end] !== ' ' && value[end] !== '\n') end++;
|
||||
}
|
||||
|
||||
const prefix = start > 0 ? '...' : '';
|
||||
const suffix = end < value.length ? '...' : '';
|
||||
return `${prefix}${value.substring(start, end)}${suffix}`;
|
||||
};
|
||||
|
||||
const getTruncatedContent = (value: string): string => {
|
||||
const maxLength = getResponsiveMaxLength();
|
||||
if (value.length <= maxLength || isExpanded) return value;
|
||||
if (highlightKeyword) {
|
||||
return getSearchExcerpt(value, highlightKeyword);
|
||||
}
|
||||
return `${value.substring(0, maxLength)}...`;
|
||||
};
|
||||
|
||||
const shouldTruncate = !showGrid && !isCompact && rawContent.length > getResponsiveMaxLength();
|
||||
const shouldTruncate = !showGrid && !isCompact && displayContent.length > getResponsiveMaxLength();
|
||||
const content = useMemo(
|
||||
() => (showGrid || isCompact ? rawContent : getTruncatedContent(rawContent)),
|
||||
[rawContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
|
||||
() => (showGrid || isCompact ? displayContent : getTruncatedContent(displayContent)),
|
||||
[displayContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
|
||||
);
|
||||
|
||||
const renderImages = () => {
|
||||
@@ -507,10 +599,24 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<PostImages
|
||||
images={post.images || []}
|
||||
displayMode="list"
|
||||
<ImageGrid
|
||||
images={images.map(img => ({
|
||||
id: img.id,
|
||||
url: img.url || '',
|
||||
thumbnail_url: img.thumbnail_url,
|
||||
preview_url: img.preview_url,
|
||||
preview_url_large: img.preview_url_large,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
}))}
|
||||
maxDisplayCount={isWideScreen ? 12 : 9}
|
||||
mode="auto"
|
||||
gap={isDesktop ? 4 : 2}
|
||||
borderRadius={isDesktop ? borderRadius.xl : borderRadius.md}
|
||||
showMoreOverlay={true}
|
||||
onImagePress={handleImagePress}
|
||||
displayMode="list"
|
||||
usePreview={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -556,7 +662,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
) : (
|
||||
<View style={styles.gridNoImagePreview}>
|
||||
<Text style={styles.gridNoImageText} numberOfLines={6}>
|
||||
{contentPreview}
|
||||
{highlightKeyword ? <HighlightText text={contentPreview} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : contentPreview}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -570,7 +676,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
|
||||
{!!post.title && (
|
||||
<Text style={styles.gridTitleMain} numberOfLines={hasImage ? 2 : 3}>
|
||||
{post.title}
|
||||
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : post.title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -589,8 +695,14 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
<Text style={styles.gridUsername} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.gridLikeArea}>
|
||||
<MaterialCommunityIcons name="heart-outline" size={14} color={colors.text.secondary} />
|
||||
<Text style={styles.gridLikeCount}>{formatNumber(post.likes_count || 0)}</Text>
|
||||
<MaterialCommunityIcons
|
||||
name={post.is_liked ? 'thumb-up' : 'thumb-up-outline'}
|
||||
size={14}
|
||||
color={post.is_liked ? colors.primary.main : colors.text.secondary}
|
||||
/>
|
||||
<Text style={[styles.gridLikeCount, post.is_liked ? { color: colors.primary.main } : {}]}>
|
||||
{formatNumber(post.likes_count || 0)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
@@ -636,7 +748,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
|
||||
{!!post.title && (
|
||||
<Text style={styles.title} numberOfLines={isCompact ? 2 : undefined}>
|
||||
{post.title}
|
||||
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : post.title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -646,7 +758,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
style={styles.content}
|
||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||
>
|
||||
{content}
|
||||
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : content}
|
||||
</Text>
|
||||
{shouldTruncate && (
|
||||
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
|
||||
@@ -656,6 +768,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 仅当 segments 中不含 image 时才渲染独立的图片区域 */}
|
||||
{renderImages()}
|
||||
{renderTopComment()}
|
||||
|
||||
@@ -677,9 +790,9 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
<View style={styles.actionButtons}>
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleLike}>
|
||||
<MaterialCommunityIcons
|
||||
name={post.is_liked ? 'heart' : 'heart-outline'}
|
||||
name={post.is_liked ? 'thumb-up' : 'thumb-up-outline'}
|
||||
size={18}
|
||||
color={post.is_liked ? colors.error.main : colors.text.secondary}
|
||||
color={post.is_liked ? colors.primary.main : colors.text.secondary}
|
||||
/>
|
||||
<Text style={post.is_liked ? styles.activeActionTextMerged : styles.actionText}>
|
||||
{post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'}
|
||||
@@ -694,7 +807,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleBookmark}>
|
||||
<MaterialCommunityIcons
|
||||
name={post.is_favorited ? 'bookmark' : 'bookmark-outline'}
|
||||
name={post.is_favorited ? 'star' : 'star-outline'}
|
||||
size={18}
|
||||
color={post.is_favorited ? colors.warning.main : colors.text.secondary}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { spacing, borderRadius } from '../../../../theme';
|
||||
import { useResponsive } from '../../../../hooks/useResponsive';
|
||||
import { useResponsive } from '../../../../hooks';
|
||||
import { ImageGrid, ImageGridItem } from '../../../common';
|
||||
import { PostImagesProps } from '../types';
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ export type PostCardActionType =
|
||||
| 'unbookmark' // 取消收藏
|
||||
| 'share' // 分享
|
||||
| 'imagePress' // 点击图片
|
||||
| 'delete'; // 删除
|
||||
| 'delete' // 删除
|
||||
| 'report'; // 举报
|
||||
|
||||
/**
|
||||
* Action payload 类型
|
||||
@@ -117,6 +118,9 @@ export interface PostCardProps {
|
||||
/** 索引(用于虚拟化列表优化) */
|
||||
index?: number;
|
||||
|
||||
/** 搜索高亮关键词 */
|
||||
highlightKeyword?: string;
|
||||
|
||||
/** 自定义样式 */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
@@ -163,7 +167,7 @@ export interface PostContentProps {
|
||||
* PostImages 子组件 Props
|
||||
*/
|
||||
export interface PostImagesProps {
|
||||
images: PostImageDTO[];
|
||||
images: (PostImageDTO | { id?: string; url: string; thumbnail_url?: string; preview_url?: string; preview_url_large?: string; width?: number; height?: number })[];
|
||||
displayMode: 'list' | 'grid';
|
||||
onImagePress: (images: ImageGridItem[], index: number) => void;
|
||||
}
|
||||
|
||||
299
src/components/business/PostContentRenderer.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* PostContentRenderer - 帖子内容渲染器
|
||||
* 根据 segments 渲染富文本内容(@提及、投票、内嵌图片等)
|
||||
* 降级:如果 segments 为空但 content 非空,直接显示纯文本
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, TextStyle, StyleProp, Dimensions } from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
|
||||
import Text from '../common/Text';
|
||||
import HighlightText from '../common/HighlightText';
|
||||
import { useAppColors } from '../../theme';
|
||||
import {
|
||||
MessageSegment,
|
||||
AtSegmentData,
|
||||
VoteSegmentData,
|
||||
PostRefSegmentData,
|
||||
ImageSegmentData,
|
||||
partitionSegments,
|
||||
} from '../../types';
|
||||
import PostRefCard from './PostRefCard';
|
||||
|
||||
interface PostContentRendererProps {
|
||||
content?: string;
|
||||
segments?: MessageSegment[];
|
||||
numberOfLines?: number;
|
||||
onMentionPress?: (userId: string) => void;
|
||||
onPostRefPress?: (postId: string) => void;
|
||||
onImagePress?: (url: string) => void;
|
||||
style?: any;
|
||||
textStyle?: any;
|
||||
highlightKeyword?: string;
|
||||
highlightStyle?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||
const INLINE_IMAGE_MAX_WIDTH = SCREEN_WIDTH - 32;
|
||||
|
||||
function hasInlineImages(segments?: MessageSegment[]): boolean {
|
||||
if (!segments) return false;
|
||||
return segments.some(s => s.type === 'image');
|
||||
}
|
||||
|
||||
const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
||||
content,
|
||||
segments,
|
||||
numberOfLines,
|
||||
onMentionPress,
|
||||
onPostRefPress,
|
||||
onImagePress,
|
||||
style,
|
||||
textStyle,
|
||||
highlightKeyword,
|
||||
highlightStyle,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const hasImages = hasInlineImages(segments);
|
||||
|
||||
if (!segments || segments.length === 0) {
|
||||
return (
|
||||
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
||||
{highlightKeyword && content ? (
|
||||
<HighlightText text={content} keyword={highlightKeyword} 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;
|
||||
439
src/components/business/PostMentionInput.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* 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;
|
||||
125
src/components/business/PostRefCard.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
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;
|
||||
@@ -7,12 +7,13 @@ import {
|
||||
Alert,
|
||||
Modal,
|
||||
Dimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
@@ -133,22 +134,85 @@ function createQrScannerStyles(colors: AppColors) {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
webNotSupportedContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.xl,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
webNotSupportedText: {
|
||||
marginTop: spacing.lg,
|
||||
fontSize: fontSizes.md,
|
||||
color: 'rgba(255,255,255,0.72)',
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||
const router = useRouter();
|
||||
// Web 端不支持扫码组件
|
||||
const QRCodeScannerWeb: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||
const themeColors = useAppColors();
|
||||
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [scanned, setScanned] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setScanned(false);
|
||||
if (!permission?.granted) {
|
||||
requestPermission();
|
||||
blurActiveElement();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<MaterialCommunityIcons name="close" size={24} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>扫码登录</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
<View style={styles.webNotSupportedContainer}>
|
||||
<MaterialCommunityIcons name="web-off" size={64} color="rgba(255,255,255,0.5)" />
|
||||
<Text style={styles.webNotSupportedText}>
|
||||
扫码登录功能暂不支持网页端使用{'\n'}
|
||||
请下载手机 App 体验完整功能
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.permissionButton} onPress={onClose}>
|
||||
<Text style={styles.permissionButtonText}>知道了</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// 原生端扫码组件
|
||||
const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||
const router = useRouter();
|
||||
const themeColors = useAppColors();
|
||||
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||
|
||||
// 动态导入 expo-camera,避免 Web 端加载
|
||||
const [cameraModule, setCameraModule] = useState<typeof import('expo-camera') | null>(null);
|
||||
const [permissionStatus, setPermissionStatus] = useState<'undetermined' | 'granted' | 'denied'>('undetermined');
|
||||
const [scanned, setScanned] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && Platform.OS !== 'web') {
|
||||
import('expo-camera').then((module) => {
|
||||
setCameraModule(module);
|
||||
module.Camera.getCameraPermissionsAsync().then((result) => {
|
||||
setPermissionStatus(result.granted ? 'granted' : result.canAskAgain ? 'undetermined' : 'denied');
|
||||
if (!result.granted && result.canAskAgain) {
|
||||
module.Camera.requestCameraPermissionsAsync().then((reqResult) => {
|
||||
setPermissionStatus(reqResult.granted ? 'granted' : 'denied');
|
||||
});
|
||||
}
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.warn('Failed to load expo-camera:', err);
|
||||
});
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
@@ -157,7 +221,7 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
setScanned(true);
|
||||
onClose();
|
||||
|
||||
if (data.startsWith('carrotbbs://qrcode/login')) {
|
||||
if (data.startsWith('withyou://qrcode/login')) {
|
||||
const sessionId = extractSessionId(data);
|
||||
if (sessionId) {
|
||||
router.push(hrefs.hrefQrLoginConfirm(sessionId));
|
||||
@@ -178,7 +242,7 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
}
|
||||
};
|
||||
|
||||
if (!permission?.granted) {
|
||||
if (!cameraModule || permissionStatus !== 'granted') {
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.container}>
|
||||
@@ -192,7 +256,16 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
<View style={styles.permissionContainer}>
|
||||
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
|
||||
<Text style={styles.permissionText}>需要相机权限才能扫码</Text>
|
||||
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
|
||||
<TouchableOpacity
|
||||
style={styles.permissionButton}
|
||||
onPress={() => {
|
||||
if (cameraModule) {
|
||||
cameraModule.Camera.requestCameraPermissionsAsync().then((result) => {
|
||||
setPermissionStatus(result.granted ? 'granted' : 'denied');
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={styles.permissionButtonText}>授予权限</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -201,6 +274,8 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
);
|
||||
}
|
||||
|
||||
const { CameraView } = cameraModule;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.container}>
|
||||
@@ -243,4 +318,9 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
);
|
||||
};
|
||||
|
||||
// 根据平台导出不同组件
|
||||
export const QRCodeScanner: React.FC<QRCodeScannerProps> = Platform.OS === 'web'
|
||||
? QRCodeScannerWeb
|
||||
: QRCodeScannerNative;
|
||||
|
||||
export default QRCodeScanner;
|
||||
|
||||
493
src/components/business/ReportDialog/ReportDialog.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* 举报对话框组件
|
||||
* 支持举报帖子、评论、消息
|
||||
* 提供友好的用户界面和流畅的交互体验
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Platform,
|
||||
KeyboardAvoidingView,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useAppColors } from '../../../theme';
|
||||
import { spacing, borderRadius, fontSizes, shadows } from '../../../theme';
|
||||
import { reportService, ReportReason, ReportTargetType, REPORT_REASONS } from '@/services/post';
|
||||
import { blurActiveElement } from '../../../infrastructure/platform';
|
||||
import Text from '../../common/Text';
|
||||
|
||||
export interface ReportDialogProps {
|
||||
visible: boolean;
|
||||
targetType: ReportTargetType;
|
||||
targetId: string;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
// 目标类型配置
|
||||
const TARGET_CONFIG: Record<ReportTargetType, { label: string; icon: string; color: string }> = {
|
||||
post: { label: '帖子', icon: 'file-document-outline', color: '#FF6B35' },
|
||||
comment: { label: '评论', icon: 'comment-text-outline', color: '#4CAF50' },
|
||||
message: { label: '消息', icon: 'message-text-outline', color: '#2196F3' },
|
||||
};
|
||||
|
||||
// 举报原因详细配置(带图标和说明)
|
||||
const REPORT_REASONS_DETAILED = [
|
||||
{ value: 'spam' as ReportReason, label: '垃圾广告', icon: 'email-newsletter', description: '包含广告、推广或重复内容' },
|
||||
{ value: 'inappropriate' as ReportReason, label: '违规内容', icon: 'alert-circle-outline', description: '包含违法、色情或暴力内容' },
|
||||
{ value: 'harassment' as ReportReason, label: '辱骂攻击', icon: 'account-off-outline', description: '包含人身攻击、骚扰或歧视' },
|
||||
{ value: 'misinformation' as ReportReason, label: '虚假信息', icon: 'alert-outline', description: '包含谣言、诈骗或误导信息' },
|
||||
{ value: 'other' as ReportReason, label: '其他原因', icon: 'dots-horizontal-circle-outline', description: '其他需要举报的情况' },
|
||||
];
|
||||
|
||||
const ReportDialog: React.FC<ReportDialogProps> = ({
|
||||
visible,
|
||||
targetType,
|
||||
targetId,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useStyles(colors);
|
||||
const [selectedReason, setSelectedReason] = useState<ReportReason | null>(null);
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const targetConfig = TARGET_CONFIG[targetType];
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
blurActiveElement();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 判断补充说明是否必填(选择"其他原因"时必填)
|
||||
const isDescriptionRequired = selectedReason === 'other';
|
||||
const descriptionPlaceholder = isDescriptionRequired
|
||||
? '请详细描述您举报的原因(必填)...'
|
||||
: '请提供更多详细信息(选填)...';
|
||||
|
||||
// 提交举报
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!selectedReason) {
|
||||
Alert.alert('提示', '请选择举报原因', [{ text: '确定' }]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDescriptionRequired && !description.trim()) {
|
||||
Alert.alert('提示', '请选择"其他原因"时,请详细描述举报原因', [{ text: '确定' }]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await reportService.createReport(
|
||||
targetType,
|
||||
targetId,
|
||||
selectedReason,
|
||||
description.trim() || undefined
|
||||
);
|
||||
|
||||
if (result) {
|
||||
Alert.alert('举报成功', '感谢您的反馈,我们会尽快处理', [
|
||||
{
|
||||
text: '确定',
|
||||
onPress: () => {
|
||||
onClose();
|
||||
onSuccess?.();
|
||||
},
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
Alert.alert('举报失败', '请稍后重试', [{ text: '确定' }]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submit report error:', error);
|
||||
Alert.alert('举报失败', '网络错误,请稍后重试', [{ text: '确定' }]);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [selectedReason, description, targetType, targetId, onClose, onSuccess]);
|
||||
|
||||
// 重置状态
|
||||
const handleClose = useCallback(() => {
|
||||
setSelectedReason(null);
|
||||
setDescription('');
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const selectedReasonData = useMemo(
|
||||
() => REPORT_REASONS_DETAILED.find(r => r.value === selectedReason),
|
||||
[selectedReason]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardView}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.container}>
|
||||
{/* 头部 */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerContent}>
|
||||
<View style={[styles.headerIcon, { backgroundColor: targetConfig.color + '15' }]}>
|
||||
<MaterialCommunityIcons
|
||||
name={targetConfig.icon as any}
|
||||
size={20}
|
||||
color={targetConfig.color}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.headerText}>
|
||||
<Text style={styles.title}>举报{targetConfig.label}</Text>
|
||||
<Text style={styles.subtitle}>请选择合适的举报原因</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={handleClose}
|
||||
disabled={submitting}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="close"
|
||||
size={24}
|
||||
color={colors.chat.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 内容 */}
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
{/* 举报原因选择 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>举报原因</Text>
|
||||
<View style={styles.reasonList}>
|
||||
{REPORT_REASONS_DETAILED.map((item) => (
|
||||
<TouchableOpacity
|
||||
key={item.value}
|
||||
style={[
|
||||
styles.reasonItem,
|
||||
selectedReason === item.value && [
|
||||
styles.reasonItemSelected,
|
||||
{ borderColor: colors.primary.main + '40' }
|
||||
],
|
||||
]}
|
||||
onPress={() => setSelectedReason(item.value)}
|
||||
disabled={submitting}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.reasonLeft}>
|
||||
<View
|
||||
style={[
|
||||
styles.reasonIcon,
|
||||
selectedReason === item.value && styles.reasonIconSelected,
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={item.icon as any}
|
||||
size={20}
|
||||
color={selectedReason === item.value ? colors.primary.main : colors.chat.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.reasonTextContainer}>
|
||||
<Text style={[
|
||||
styles.reasonLabel,
|
||||
...(selectedReason === item.value ? [styles.reasonLabelSelected] : []),
|
||||
]}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text style={styles.reasonDescription}>{item.description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.radio,
|
||||
selectedReason === item.value && styles.radioSelected,
|
||||
]}
|
||||
>
|
||||
{selectedReason === item.value && (
|
||||
<View style={styles.radioDot} />
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 补充说明 */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
补充说明{isDescriptionRequired ? ' *' : ''}
|
||||
</Text>
|
||||
<Text style={styles.charCount}>{description.length}/500</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.textInput,
|
||||
isDescriptionRequired && !description && styles.textInputError,
|
||||
]}
|
||||
placeholder={descriptionPlaceholder}
|
||||
placeholderTextColor={colors.chat.textPlaceholder}
|
||||
multiline
|
||||
maxLength={500}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
editable={!submitting}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={handleClose}
|
||||
disabled={submitting}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>取消</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.submitButton,
|
||||
(!selectedReason || submitting) && styles.submitButtonDisabled,
|
||||
]}
|
||||
onPress={handleSubmit}
|
||||
disabled={!selectedReason || submitting}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||
) : (
|
||||
<Text style={styles.submitButtonText}>提交举报</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// 创建样式
|
||||
const useStyles = (colors: ReturnType<typeof useAppColors>) =>
|
||||
StyleSheet.create({
|
||||
keyboardView: {
|
||||
flex: 1,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
container: {
|
||||
backgroundColor: colors.chat.card,
|
||||
borderRadius: borderRadius.xl,
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
maxHeight: '85%',
|
||||
...shadows.lg,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: spacing.lg,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
},
|
||||
headerIcon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: borderRadius.md,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerText: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: fontSizes.xl,
|
||||
fontWeight: '600',
|
||||
color: colors.chat.textPrimary,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.chat.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
closeButton: {
|
||||
padding: spacing.xs,
|
||||
borderRadius: borderRadius.full,
|
||||
},
|
||||
content: {
|
||||
padding: spacing.lg,
|
||||
paddingTop: 0,
|
||||
maxHeight: 400,
|
||||
},
|
||||
section: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
color: colors.chat.textPrimary,
|
||||
},
|
||||
reasonList: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
reasonItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: spacing.md,
|
||||
borderRadius: borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
backgroundColor: colors.chat.surfaceMuted,
|
||||
},
|
||||
reasonItemSelected: {
|
||||
backgroundColor: colors.primary.main + '08',
|
||||
borderWidth: 1,
|
||||
},
|
||||
reasonLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
gap: spacing.md,
|
||||
},
|
||||
reasonIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.chat.surfaceRaised,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
reasonIconSelected: {
|
||||
backgroundColor: colors.primary.main + '15',
|
||||
},
|
||||
reasonTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
reasonLabel: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '500',
|
||||
color: colors.chat.textPrimary,
|
||||
},
|
||||
reasonLabelSelected: {
|
||||
color: colors.primary.main,
|
||||
fontWeight: '600',
|
||||
},
|
||||
reasonDescription: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.chat.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
radio: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.chat.border,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
radioSelected: {
|
||||
borderColor: colors.primary.main,
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
radioDot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
textInput: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.chat.border,
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.md,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.chat.textPrimary,
|
||||
minHeight: 100,
|
||||
backgroundColor: colors.chat.surfaceMuted,
|
||||
},
|
||||
textInputError: {
|
||||
borderColor: colors.error.main,
|
||||
backgroundColor: colors.error.main + '08',
|
||||
},
|
||||
charCount: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.chat.textSecondary,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
padding: spacing.lg,
|
||||
gap: spacing.md,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.chat.borderLight,
|
||||
},
|
||||
cancelButton: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.chat.border,
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.chat.surfaceMuted,
|
||||
},
|
||||
cancelButtonText: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '500',
|
||||
color: colors.chat.textSecondary,
|
||||
},
|
||||
submitButton: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
},
|
||||
submitButtonDisabled: {
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
submitButtonText: {
|
||||
fontSize: fontSizes.md,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default ReportDialog;
|
||||
2
src/components/business/ReportDialog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './ReportDialog';
|
||||
export type { ReportDialogProps } from './ReportDialog';
|
||||
@@ -16,59 +16,52 @@ interface SearchBarProps {
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
autoFocus?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function createSearchBarStyles(colors: AppColors) {
|
||||
function createSearchBarStyles(colors: AppColors, compact: boolean) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
backgroundColor: `${colors.primary.main}08`,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.xs,
|
||||
height: 46,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
paddingHorizontal: compact ? spacing.sm : spacing.md,
|
||||
height: compact ? 38 : 48,
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
containerFocused: {
|
||||
borderColor: colors.primary.main,
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
borderColor: `${colors.primary.main}50`,
|
||||
backgroundColor: `${colors.primary.main}10`,
|
||||
},
|
||||
searchIconWrap: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
marginLeft: spacing.xs,
|
||||
marginRight: spacing.xs,
|
||||
width: compact ? 22 : 32,
|
||||
height: compact ? 22 : 32,
|
||||
marginLeft: compact ? 2 : 0,
|
||||
marginRight: compact ? 6 : spacing.sm,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.text.secondary}12`,
|
||||
backgroundColor: 'transparent',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
searchIconWrapFocused: {
|
||||
backgroundColor: `${colors.primary.main}1A`,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
fontSize: compact ? fontSizes.md : fontSizes.lg,
|
||||
color: colors.text.primary,
|
||||
paddingVertical: spacing.sm + 1,
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: compact ? 0 : spacing.sm,
|
||||
paddingHorizontal: compact ? 2 : 0,
|
||||
fontWeight: '500',
|
||||
},
|
||||
clearButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
marginHorizontal: spacing.xs,
|
||||
width: 22,
|
||||
height: 22,
|
||||
marginLeft: spacing.xs,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.text.secondary}14`,
|
||||
backgroundColor: `${colors.text.secondary}20`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@@ -83,9 +76,10 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
onFocus,
|
||||
onBlur,
|
||||
autoFocus = false,
|
||||
compact = false,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createSearchBarStyles(colors), [colors]);
|
||||
const styles = useMemo(() => createSearchBarStyles(colors, compact), [colors, compact]);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const handleFocus = () => {
|
||||
@@ -103,7 +97,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
<View style={[styles.searchIconWrap, isFocused && styles.searchIconWrapFocused]}>
|
||||
<MaterialCommunityIcons
|
||||
name="magnify"
|
||||
size={18}
|
||||
size={compact ? 16 : 18}
|
||||
color={isFocused ? colors.primary.main : colors.text.secondary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
55
src/components/business/ShareSheet/ShareSheet.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { Share as RNShare, Platform } from 'react-native';
|
||||
|
||||
export interface ShareSheetProps {
|
||||
visible: boolean;
|
||||
postUrl?: string;
|
||||
postTitle?: string;
|
||||
onClose: () => void;
|
||||
onShareAction?: (actionKey: string) => void;
|
||||
}
|
||||
|
||||
const ShareSheet: React.FC<ShareSheetProps> = ({
|
||||
visible,
|
||||
postUrl,
|
||||
postTitle,
|
||||
onClose,
|
||||
onShareAction,
|
||||
}) => {
|
||||
|
||||
const shareText = useMemo(() => {
|
||||
const title = postTitle || '威友帖子';
|
||||
return `${title} ${postUrl || ''}`;
|
||||
}, [postTitle, postUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const doShare = async () => {
|
||||
try {
|
||||
await RNShare.share(
|
||||
{
|
||||
message: shareText,
|
||||
url: Platform.OS === 'ios' ? postUrl : undefined,
|
||||
title: postTitle || '分享帖子',
|
||||
},
|
||||
{
|
||||
subject: postTitle,
|
||||
dialogTitle: '分享到',
|
||||
}
|
||||
);
|
||||
onShareAction?.('system_share');
|
||||
} catch {
|
||||
// User cancelled
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
doShare();
|
||||
}, [visible, shareText, postUrl, postTitle, onShareAction, onClose]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ShareSheet;
|
||||
2
src/components/business/ShareSheet/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './ShareSheet';
|
||||
export type { ShareSheetProps } from './ShareSheet';
|
||||
@@ -1,15 +1,14 @@
|
||||
/**
|
||||
* SystemMessageItem 系统消息项组件 - 美化版
|
||||
* SystemMessageItem 系统消息项组件 - 扁平化风格
|
||||
* 根据系统消息类型显示不同图标和内容
|
||||
* 采用卡片式设计,符合胡萝卜BBS整体风格
|
||||
* 与登录、注册、设置页面风格保持一致
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { formatChatTime } from '../../utils/formatTime';
|
||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
@@ -124,17 +123,13 @@ function createSystemMessageStyles(colors: AppColors) {
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: spacing.md,
|
||||
padding: 16,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginHorizontal: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
...shadows.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider || '#E5E5EA',
|
||||
},
|
||||
unreadContainer: {
|
||||
backgroundColor: colors.primary.light + '08',
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.primary.main,
|
||||
backgroundColor: colors.primary.main + '0A',
|
||||
},
|
||||
unreadIndicator: {
|
||||
position: 'absolute',
|
||||
@@ -143,19 +138,19 @@ function createSystemMessageStyles(colors: AppColors) {
|
||||
marginTop: -4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: borderRadius.full,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
iconContainer: {
|
||||
marginRight: spacing.md,
|
||||
marginRight: 14,
|
||||
},
|
||||
avatarWrapper: {
|
||||
position: 'relative',
|
||||
},
|
||||
iconWrapper: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: borderRadius.lg,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
@@ -163,9 +158,9 @@ function createSystemMessageStyles(colors: AppColors) {
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: borderRadius.full,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
@@ -180,17 +175,17 @@ function createSystemMessageStyles(colors: AppColors) {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
marginBottom: 4,
|
||||
},
|
||||
titleLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
marginRight: spacing.sm,
|
||||
marginRight: 8,
|
||||
},
|
||||
title: {
|
||||
fontWeight: '500',
|
||||
fontSize: fontSizes.md,
|
||||
fontSize: 15,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
unreadTitle: {
|
||||
@@ -200,10 +195,10 @@ function createSystemMessageStyles(colors: AppColors) {
|
||||
statusBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
marginLeft: spacing.xs,
|
||||
borderRadius: 4,
|
||||
marginLeft: 6,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 10,
|
||||
@@ -211,55 +206,59 @@ function createSystemMessageStyles(colors: AppColors) {
|
||||
marginLeft: 2,
|
||||
},
|
||||
time: {
|
||||
fontSize: fontSizes.sm,
|
||||
fontSize: 13,
|
||||
flexShrink: 0,
|
||||
color: colors.text.hint,
|
||||
},
|
||||
messageContent: {
|
||||
lineHeight: 20,
|
||||
fontSize: fontSizes.sm,
|
||||
fontSize: 14,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
extraInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
backgroundColor: colors.primary.light + '12',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.md,
|
||||
marginTop: 8,
|
||||
backgroundColor: colors.background.default,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 8,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
extraText: {
|
||||
marginLeft: spacing.xs,
|
||||
marginLeft: 6,
|
||||
flex: 1,
|
||||
fontWeight: '500',
|
||||
fontSize: 13,
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
marginTop: 12,
|
||||
gap: 10,
|
||||
},
|
||||
actionBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
gap: spacing.xs,
|
||||
gap: 4,
|
||||
},
|
||||
rejectBtn: {
|
||||
borderColor: `${colors.error.main}55`,
|
||||
backgroundColor: `${colors.error.main}18`,
|
||||
borderColor: colors.error.main + '40',
|
||||
backgroundColor: colors.error.light + '20',
|
||||
},
|
||||
approveBtn: {
|
||||
borderColor: `${colors.success.main}55`,
|
||||
backgroundColor: `${colors.success.main}18`,
|
||||
borderColor: colors.success.main + '40',
|
||||
backgroundColor: colors.success.light + '20',
|
||||
},
|
||||
actionBtnText: {
|
||||
fontWeight: '500',
|
||||
fontSize: 13,
|
||||
},
|
||||
arrowContainer: {
|
||||
marginLeft: spacing.sm,
|
||||
marginLeft: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 20,
|
||||
@@ -329,18 +328,6 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createSystemMessageStyles(colors), [colors]);
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (dateString: string): string => {
|
||||
try {
|
||||
return formatDistanceToNow(new Date(dateString), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const { icon, color, bgColor } = getSystemMessageIcon(message.system_type, colors);
|
||||
const title = getSystemMessageTitle(message);
|
||||
const { extra_data } = message;
|
||||
@@ -441,7 +428,7 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
{getStatusBadge()}
|
||||
</View>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.time}>
|
||||
{formatTime(message.created_at)}
|
||||
{formatChatTime(message.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -135,26 +135,21 @@ function createTabBarStyles(colors: AppColors) {
|
||||
modernContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.xl,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginVertical: spacing.md,
|
||||
padding: spacing.xs,
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
alignItems: 'center',
|
||||
paddingRight: spacing.xs,
|
||||
},
|
||||
modernTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingVertical: spacing.md,
|
||||
position: 'relative',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
modernTabActive: {
|
||||
backgroundColor: colors.primary.main + '15',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
modernTabContent: {
|
||||
flexDirection: 'row',
|
||||
@@ -169,15 +164,17 @@ function createTabBarStyles(colors: AppColors) {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
modernTabTextActive: {
|
||||
fontWeight: '700',
|
||||
fontWeight: '600',
|
||||
},
|
||||
modernTabIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
width: 20,
|
||||
bottom: 0,
|
||||
left: '25%',
|
||||
right: '25%',
|
||||
height: 3,
|
||||
backgroundColor: colors.primary.main,
|
||||
borderRadius: borderRadius.full,
|
||||
borderTopLeftRadius: borderRadius.sm,
|
||||
borderTopRightRadius: borderRadius.sm,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -206,7 +203,7 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
key={index}
|
||||
style={[styles.modernTab, isActive && styles.modernTabActive]}
|
||||
onPress={() => onTabChange(index)}
|
||||
activeOpacity={0.85}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.modernTabContent}>
|
||||
{icon && (
|
||||
@@ -333,4 +330,4 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default TabBar;
|
||||
export default React.memo(TabBar);
|
||||
|
||||
382
src/components/business/TradeCard/TradeCard.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
import React, { memo } from 'react';
|
||||
import { View, TouchableOpacity, Image, StyleSheet, Pressable } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useAppColors, spacing, borderRadius, fontSizes, type AppColors } from '../../../theme';
|
||||
import { Text } from '../../common';
|
||||
import type { TradeItemDTO } from '../../../types/trade';
|
||||
import { TRADE_CATEGORY_MAP, TRADE_CONDITION_MAP, TRADE_TYPE_MAP } from '../../../types/trade';
|
||||
|
||||
export interface TradeCardProps {
|
||||
item: TradeItemDTO;
|
||||
variant?: 'list' | 'grid';
|
||||
onPress?: (id: string) => void;
|
||||
onFavorite?: (id: string) => void;
|
||||
}
|
||||
|
||||
function formatPrice(price?: number | null): string {
|
||||
if (price == null) return '面议';
|
||||
return `¥${price.toFixed(price % 1 === 0 ? 0 : 2)}`;
|
||||
}
|
||||
|
||||
function createTradeCardStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
gridCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 6,
|
||||
elevation: 3,
|
||||
},
|
||||
listCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
padding: spacing.md,
|
||||
},
|
||||
imageContainer: {
|
||||
position: 'relative',
|
||||
aspectRatio: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
minHeight: 140,
|
||||
},
|
||||
listImageContainer: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.background.default,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
// 包邮/标签 badge 放在图片左下角,黄色背景
|
||||
shippingBadge: {
|
||||
position: 'absolute',
|
||||
bottom: 6,
|
||||
left: 6,
|
||||
paddingHorizontal: 5,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: '#FFE066',
|
||||
},
|
||||
shippingBadgeText: {
|
||||
color: '#5C4B00',
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
},
|
||||
typeBadge: {
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
left: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
sellBadge: {
|
||||
backgroundColor: `${colors.success.main}CC`,
|
||||
},
|
||||
buyBadge: {
|
||||
backgroundColor: `${colors.primary.main}CC`,
|
||||
},
|
||||
typeBadgeText: {
|
||||
color: '#fff',
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
statusBadge: {
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
right: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: `${colors.warning.main}BB`,
|
||||
},
|
||||
statusBadgeText: {
|
||||
color: '#fff',
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
gridContent: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
title: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '500',
|
||||
color: colors.text.primary,
|
||||
lineHeight: 20,
|
||||
flex: 1,
|
||||
},
|
||||
priceInline: {
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '700',
|
||||
color: '#FF4D4F',
|
||||
flexShrink: 0,
|
||||
},
|
||||
priceRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
gap: 4,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
price: {
|
||||
fontSize: fontSizes['2xl'],
|
||||
fontWeight: '700',
|
||||
color: '#FF4D4F',
|
||||
},
|
||||
originalPrice: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.text.hint,
|
||||
textDecorationLine: 'line-through',
|
||||
},
|
||||
negotiable: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '600',
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
wantCount: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.text.hint,
|
||||
marginLeft: 4,
|
||||
},
|
||||
tagsRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 4,
|
||||
marginTop: 2,
|
||||
},
|
||||
tag: {
|
||||
paddingHorizontal: 5,
|
||||
paddingVertical: 1,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: `${colors.primary.main}10`,
|
||||
},
|
||||
tagText: {
|
||||
fontSize: 10,
|
||||
color: colors.primary.main,
|
||||
},
|
||||
conditionTag: {
|
||||
backgroundColor: `${colors.success.main}10`,
|
||||
},
|
||||
conditionTagText: {
|
||||
fontSize: 10,
|
||||
color: colors.success.main,
|
||||
},
|
||||
// 促销标签样式(如"24小时发货")
|
||||
promoTag: {
|
||||
paddingHorizontal: 5,
|
||||
paddingVertical: 1,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: '#FFF2F0',
|
||||
borderWidth: 0.5,
|
||||
borderColor: '#FFCCC7',
|
||||
},
|
||||
promoTagText: {
|
||||
fontSize: 10,
|
||||
color: '#FF4D4F',
|
||||
},
|
||||
bottomRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
authorRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
flex: 1,
|
||||
},
|
||||
avatar: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
authorName: {
|
||||
fontSize: 11,
|
||||
color: colors.text.secondary,
|
||||
flexShrink: 1,
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
statItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
statText: {
|
||||
fontSize: 11,
|
||||
color: colors.text.hint,
|
||||
},
|
||||
favButton: {
|
||||
padding: 2,
|
||||
},
|
||||
listContent: {
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
listLayout: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
},
|
||||
imagePlaceholder: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const TradeCardBase: React.FC<TradeCardProps> = ({ item, variant = 'list', onPress, onFavorite }) => {
|
||||
const colors = useAppColors();
|
||||
const styles = createTradeCardStyles(colors);
|
||||
|
||||
const firstImage = item.images?.[0];
|
||||
const isSell = item.trade_type === 'sell';
|
||||
const isActive = item.status === 'active';
|
||||
const conditionLabel = item.condition ? TRADE_CONDITION_MAP[item.condition as keyof typeof TRADE_CONDITION_MAP] : null;
|
||||
const categoryLabel = TRADE_CATEGORY_MAP[item.category] || item.category;
|
||||
|
||||
const renderImage = (containerStyle: any, imageStyle: any) => (
|
||||
<View style={containerStyle}>
|
||||
{firstImage ? (
|
||||
<Image
|
||||
source={{ uri: firstImage.preview_url || firstImage.url }}
|
||||
style={imageStyle}
|
||||
defaultSource={undefined}
|
||||
/>
|
||||
) : (
|
||||
<View style={[imageStyle, styles.imagePlaceholder]}>
|
||||
<MaterialCommunityIcons name="image-outline" size={32} color={colors.text.hint} />
|
||||
</View>
|
||||
)}
|
||||
{/* 包邮标签 */}
|
||||
{item.is_free_shipping && (
|
||||
<View style={styles.shippingBadge}>
|
||||
<Text style={styles.shippingBadgeText}>包邮</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={[styles.typeBadge, isSell ? styles.sellBadge : styles.buyBadge]}>
|
||||
<Text style={styles.typeBadgeText}>{TRADE_TYPE_MAP[item.trade_type]}</Text>
|
||||
</View>
|
||||
{!isActive && (
|
||||
<View style={styles.statusBadge}>
|
||||
<Text style={styles.statusBadgeText}>
|
||||
{item.status === 'sold' ? '已售' : item.status === 'bought' ? '已收' : item.status === 'offline' ? '下架' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderTitleRow = () => (
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.title} numberOfLines={2}>{item.title}</Text>
|
||||
<Text style={styles.priceInline}>
|
||||
{item.price != null ? formatPrice(item.price) : '面议'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderTags = () => {
|
||||
if (!conditionLabel && !isSell && !item.is_quick_ship) return null;
|
||||
return (
|
||||
<View style={styles.tagsRow}>
|
||||
{conditionLabel && isSell && (
|
||||
<View style={[styles.tag, styles.conditionTag]}>
|
||||
<Text style={styles.conditionTagText}>{conditionLabel}</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.is_quick_ship && (
|
||||
<View style={styles.promoTag}>
|
||||
<Text style={styles.promoTagText}>24小时发货</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBottom = () => (
|
||||
<View style={styles.bottomRow}>
|
||||
<View style={styles.authorRow}>
|
||||
{item.author?.avatar ? (
|
||||
<Image source={{ uri: item.author.avatar }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={[styles.avatar, { backgroundColor: colors.primary.main + '33' }]} />
|
||||
)}
|
||||
<Text style={styles.authorName} numberOfLines={1}>{item.author?.nickname || '匿名'}</Text>
|
||||
</View>
|
||||
<View style={styles.statsRow}>
|
||||
{(item.good_reputation || 0) > 0 && (
|
||||
<View style={[styles.promoTag, { backgroundColor: '#FFF7E6', borderColor: '#FFD591' }]}>
|
||||
<Text style={[styles.promoTagText, { color: '#FA8C16' }]}>回头客好评过百</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.tag}>
|
||||
<Text style={styles.tagText}>{categoryLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (variant === 'grid') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.gridCard}
|
||||
onPress={() => onPress?.(item.id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{renderImage(styles.imageContainer, styles.image)}
|
||||
<View style={styles.gridContent}>
|
||||
{renderTitleRow()}
|
||||
{renderTags()}
|
||||
{renderBottom()}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
// grid variant is used in masonry layout; list variant below is unused but kept for compatibility
|
||||
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.listCard}
|
||||
onPress={() => onPress?.(item.id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.listLayout}>
|
||||
{renderImage(styles.listImageContainer, styles.image)}
|
||||
<View style={styles.listContent}>
|
||||
{renderTitleRow()}
|
||||
{renderTags()}
|
||||
{renderBottom()}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export const TradeCard = memo(TradeCardBase);
|
||||
|
||||
export default TradeCard;
|
||||
@@ -1,50 +1,52 @@
|
||||
/**
|
||||
* UserProfileHeader 用户资料头部组件 - 美化版(响应式适配)
|
||||
* UserProfileHeader 用户资料头部组件 - Twitter/X 风格
|
||||
* 显示用户封面、头像、昵称、简介、关注/粉丝数
|
||||
* 采用现代卡片式设计,渐变封面,悬浮头像
|
||||
* 采用 Twitter/X 扁平化设计,左对齐布局
|
||||
* 支持互关状态显示
|
||||
* 在宽屏下显示更大的头像和封面
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState, useRef, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Image,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
Pressable,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
|
||||
import { User } from '../../types';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import { User, UserIdentity, VerificationStatus } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
import Button from '../common/Button';
|
||||
import Avatar from '../common/Avatar';
|
||||
import { ImageGallery } from '../common';
|
||||
import type { GalleryImageItem } from '../common';
|
||||
import { useResponsive } from '../../hooks';
|
||||
|
||||
interface UserProfileHeaderProps {
|
||||
user: User;
|
||||
isCurrentUser?: boolean;
|
||||
isBlocked?: boolean;
|
||||
onFollow: () => void;
|
||||
onSettings?: () => void;
|
||||
onEditProfile?: () => void;
|
||||
onMessage?: () => void;
|
||||
onMore?: () => void; // 点击更多按钮
|
||||
onPostsPress?: () => void; // 点击帖子数(可选)
|
||||
onFollowingPress?: () => void; // 点击关注数
|
||||
onFollowersPress?: () => void; // 点击粉丝数
|
||||
onAvatarPress?: () => void; // 点击头像编辑按钮
|
||||
onBlock?: () => void;
|
||||
onFollowingPress?: () => void;
|
||||
onFollowersPress?: () => void;
|
||||
onAvatarPress?: () => void;
|
||||
}
|
||||
|
||||
const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
||||
user,
|
||||
isCurrentUser = false,
|
||||
isBlocked = false,
|
||||
onFollow,
|
||||
onSettings,
|
||||
onEditProfile,
|
||||
onMessage,
|
||||
onMore,
|
||||
onPostsPress,
|
||||
onBlock,
|
||||
onFollowingPress,
|
||||
onFollowersPress,
|
||||
onAvatarPress,
|
||||
@@ -52,110 +54,146 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createUserProfileHeaderStyles(colors), [colors]);
|
||||
const { isWideScreen, isDesktop, width } = useResponsive();
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
|
||||
const moreButtonRef = useRef<View>(null);
|
||||
const [galleryVisible, setGalleryVisible] = useState(false);
|
||||
const [galleryImages, setGalleryImages] = useState<GalleryImageItem[]>([]);
|
||||
const [galleryIndex, setGalleryIndex] = useState(0);
|
||||
|
||||
// 格式化数字
|
||||
const formatCount = (count: number | undefined): string => {
|
||||
if (count === undefined || count === null) {
|
||||
return '0';
|
||||
}
|
||||
if (count >= 10000) {
|
||||
return `${(count / 10000).toFixed(1)}w`;
|
||||
}
|
||||
if (count >= 1000) {
|
||||
return `${(count / 1000).toFixed(1)}k`;
|
||||
}
|
||||
if (count === undefined || count === null) return '0';
|
||||
if (count >= 10000) return `${(count / 10000).toFixed(1)}w`;
|
||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
|
||||
return count.toString();
|
||||
};
|
||||
|
||||
// 获取帖子数量
|
||||
const getPostsCount = (): number => {
|
||||
return user.posts_count ?? 0;
|
||||
};
|
||||
const getPostsCount = (): number => user.posts_count ?? 0;
|
||||
const getFollowersCount = (): number => user.followers_count ?? 0;
|
||||
const getFollowingCount = (): number => user.following_count ?? 0;
|
||||
const getIsFollowing = (): boolean => user.is_following ?? false;
|
||||
const getIsFollowingMe = (): boolean => user.is_following_me ?? false;
|
||||
|
||||
// 获取粉丝数量
|
||||
const getFollowersCount = (): number => {
|
||||
return user.followers_count ?? 0;
|
||||
};
|
||||
|
||||
// 获取关注数量
|
||||
const getFollowingCount = (): number => {
|
||||
return user.following_count ?? 0;
|
||||
};
|
||||
|
||||
// 检查是否关注
|
||||
const getIsFollowing = (): boolean => {
|
||||
return user.is_following ?? false;
|
||||
};
|
||||
|
||||
// 检查对方是否关注了我
|
||||
const getIsFollowingMe = (): boolean => {
|
||||
return user.is_following_me ?? false;
|
||||
};
|
||||
|
||||
// 获取按钮配置(类似B站的互关逻辑)
|
||||
const getButtonConfig = (): { title: string; variant: 'primary' | 'outline'; icon?: string } => {
|
||||
const getButtonConfig = (): { title: string; variant: 'primary' | 'outline' | 'ghost'; icon?: string } => {
|
||||
const isFollowing = getIsFollowing();
|
||||
const isFollowingMe = getIsFollowingMe();
|
||||
|
||||
if (isFollowing && isFollowingMe) {
|
||||
// 已互关
|
||||
return { title: '互相关注', variant: 'outline', icon: 'account-check' };
|
||||
} else if (isFollowing) {
|
||||
// 已关注但对方未回关
|
||||
return { title: '已关注', variant: 'outline', icon: 'check' };
|
||||
} else if (isFollowingMe) {
|
||||
// 对方关注了我,但我没关注对方 - 显示回关
|
||||
return { title: '回关', variant: 'primary', icon: 'plus' };
|
||||
} else {
|
||||
// 互不关注
|
||||
return { title: '关注', variant: 'primary', icon: 'plus' };
|
||||
}
|
||||
};
|
||||
|
||||
// 根据屏幕尺寸计算封面高度
|
||||
const coverHeight = isDesktop ? 240 : isWideScreen ? 200 : (width * 9) / 16;
|
||||
const coverHeight = isDesktop ? 280 : isWideScreen ? 240 : (width * 10) / 16;
|
||||
const avatarSize = isDesktop ? 140 : isWideScreen ? 120 : 88;
|
||||
|
||||
// 根据屏幕尺寸计算头像大小
|
||||
const avatarSize = isDesktop ? 120 : isWideScreen ? 100 : 90;
|
||||
const handleMorePress = useCallback(() => {
|
||||
moreButtonRef.current?.measure((_fx, _fy, _w, h, px, py) => {
|
||||
const screenWidth = Dimensions.get('window').width;
|
||||
const menuWidth = 180;
|
||||
let x = px + _w / 2 - menuWidth / 2;
|
||||
if (x + menuWidth > screenWidth - 12) {
|
||||
x = screenWidth - menuWidth - 12;
|
||||
}
|
||||
if (x < 12) x = 12;
|
||||
setMenuPosition({ x, y: py + h + 4 });
|
||||
setMenuVisible(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renderStatItem = ({
|
||||
value,
|
||||
label,
|
||||
onPress,
|
||||
}: {
|
||||
value: string;
|
||||
label: string;
|
||||
onPress?: () => void;
|
||||
}) => {
|
||||
const content = (
|
||||
<View style={styles.statContent}>
|
||||
<Text variant="h3" style={styles.statNumber}>{value}</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.statLabel}>
|
||||
{label}
|
||||
</Text>
|
||||
const handleBlockPress = useCallback(() => {
|
||||
setMenuVisible(false);
|
||||
onBlock?.();
|
||||
}, [onBlock]);
|
||||
|
||||
const openImageGallery = useCallback((images: GalleryImageItem[], index: number = 0) => {
|
||||
setGalleryImages(images);
|
||||
setGalleryIndex(index);
|
||||
setGalleryVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleCoverPress = useCallback(() => {
|
||||
if (user.cover_url) {
|
||||
openImageGallery([{ id: 'cover', url: user.cover_url }], 0);
|
||||
}
|
||||
}, [user.cover_url, openImageGallery]);
|
||||
|
||||
const handleAvatarPressPreview = useCallback(() => {
|
||||
if (user.avatar) {
|
||||
openImageGallery([{ id: 'avatar', url: user.avatar }], 0);
|
||||
}
|
||||
}, [user.avatar, openImageGallery]);
|
||||
|
||||
const renderStatLink = (count: number, label: string, onPress?: () => void) => {
|
||||
if (!onPress) {
|
||||
return (
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statNumber}>{formatCount(count)}</Text>
|
||||
<Text style={styles.statLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (onPress) {
|
||||
}
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.statItem, styles.statItemTouchable]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
{content}
|
||||
<TouchableOpacity style={styles.statItem} onPress={onPress} activeOpacity={0.7}>
|
||||
<Text style={styles.statNumber}>{formatCount(count)}</Text>
|
||||
<Text style={styles.statLabel}>{label}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return <View style={styles.statItem}>{content}</View>;
|
||||
};
|
||||
|
||||
const renderDropdownMenu = () => (
|
||||
<Modal
|
||||
visible={menuVisible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setMenuVisible(false)}
|
||||
>
|
||||
<Pressable style={styles.menuBackdrop} onPress={() => setMenuVisible(false)}>
|
||||
<View
|
||||
style={[
|
||||
styles.menuContainer,
|
||||
{ left: menuPosition.x, top: menuPosition.y },
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.menuItem}
|
||||
onPress={handleBlockPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={isBlocked ? 'account-check-outline' : 'account-off-outline'}
|
||||
size={18}
|
||||
color={isBlocked ? colors.success.main : colors.error.main}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.menuItemText,
|
||||
{ color: isBlocked ? colors.success.main : colors.error.main },
|
||||
]}
|
||||
>
|
||||
{isBlocked ? '取消拉黑' : '拉黑用户'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 渐变封面背景 */}
|
||||
{/* 全宽封面背景 */}
|
||||
<View style={[styles.coverContainer, { height: coverHeight }]}>
|
||||
<View style={styles.coverTouchable}>
|
||||
<TouchableOpacity
|
||||
style={styles.coverTouchable}
|
||||
onPress={handleCoverPress}
|
||||
activeOpacity={0.9}
|
||||
disabled={!user.cover_url}
|
||||
>
|
||||
{user.cover_url ? (
|
||||
<Image
|
||||
source={{ uri: user.cover_url }}
|
||||
@@ -163,157 +201,141 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<LinearGradient
|
||||
colors={['#FF8F66', '#FF6B35', '#E5521D']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
/>
|
||||
)}
|
||||
<View style={[styles.coverFallback, { backgroundColor: colors.primary.main }]}>
|
||||
<MaterialCommunityIcons name="image-outline" size={48} color="rgba(255,255,255,0.4)" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 设置按钮 */}
|
||||
{/* 顶部导航栏按钮 */}
|
||||
<View style={styles.topBar}>
|
||||
{isCurrentUser && onSettings && (
|
||||
<TouchableOpacity style={styles.settingsButton} onPress={onSettings}>
|
||||
<MaterialCommunityIcons name="cog-outline" size={22} color={colors.text.inverse} />
|
||||
<TouchableOpacity style={styles.iconButton} onPress={onSettings}>
|
||||
<MaterialCommunityIcons name="cog-outline" size={20} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 装饰性波浪 */}
|
||||
<View style={styles.waveDecoration}>
|
||||
<View style={styles.wave} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 用户信息卡片 */}
|
||||
<View style={[
|
||||
styles.profileCard,
|
||||
isWideScreen && styles.profileCardWide,
|
||||
]}>
|
||||
{/* 悬浮头像 */}
|
||||
<View style={[
|
||||
styles.avatarWrapper,
|
||||
isWideScreen && styles.avatarWrapperWide,
|
||||
]}>
|
||||
<View style={styles.avatarContainer}>
|
||||
{/* 用户信息区 */}
|
||||
<View style={styles.infoSection}>
|
||||
{/* 头像与操作按钮行 */}
|
||||
<View style={styles.avatarActionRow}>
|
||||
<View style={[styles.avatarWrapper, { marginTop: -avatarSize / 2 }]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.avatarContainer, { width: avatarSize, height: avatarSize }]}
|
||||
onPress={isCurrentUser && onAvatarPress ? onAvatarPress : handleAvatarPressPreview}
|
||||
activeOpacity={0.9}
|
||||
disabled={isCurrentUser ? !onAvatarPress : !user.avatar}
|
||||
>
|
||||
<Avatar
|
||||
source={user.avatar}
|
||||
size={avatarSize}
|
||||
size={avatarSize - 6}
|
||||
name={user.nickname}
|
||||
/>
|
||||
{isCurrentUser && onAvatarPress && (
|
||||
<TouchableOpacity style={styles.editAvatarButton} onPress={onAvatarPress}>
|
||||
<View style={styles.editAvatarButton} pointerEvents="none">
|
||||
<MaterialCommunityIcons name="camera" size={14} color={colors.text.inverse} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 用户名和简介 */}
|
||||
<View style={styles.userInfo}>
|
||||
<Text variant="h2" style={[
|
||||
styles.nickname,
|
||||
isWideScreen ? styles.nicknameWide : {},
|
||||
]}>
|
||||
{user.nickname}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.username}>
|
||||
@{user.username}
|
||||
</Text>
|
||||
|
||||
{user.bio ? (
|
||||
<Text variant="body" color={colors.text.secondary} style={[
|
||||
styles.bio,
|
||||
isWideScreen ? styles.bioWide : {},
|
||||
]}>
|
||||
{user.bio}
|
||||
</Text>
|
||||
<View style={styles.actionButtons}>
|
||||
{isCurrentUser ? (
|
||||
<TouchableOpacity style={styles.editProfileButton} onPress={onEditProfile} activeOpacity={0.8}>
|
||||
<Text style={styles.editProfileText}>编辑资料</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<Text variant="body" color={colors.text.hint} style={styles.bioPlaceholder}>
|
||||
这个人很懒,还没有写简介~
|
||||
<View style={styles.buttonRow}>
|
||||
<View ref={moreButtonRef} collapsable={false}>
|
||||
<TouchableOpacity style={styles.iconButtonSmall} onPress={handleMorePress}>
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={20} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.iconButtonSmall} onPress={onMessage}>
|
||||
<MaterialCommunityIcons name="email-outline" size={18} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.followBtn,
|
||||
getButtonConfig().variant === 'outline' && styles.followBtnOutline,
|
||||
getButtonConfig().variant === 'primary' && styles.followBtnPrimary,
|
||||
]}
|
||||
onPress={onFollow}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.followBtnText,
|
||||
...(getButtonConfig().variant === 'primary' ? [styles.followBtnTextPrimary] : []),
|
||||
]}
|
||||
>
|
||||
{getButtonConfig().title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 个人信息标签 */}
|
||||
{/* 用户名 */}
|
||||
<View style={styles.nameSection}>
|
||||
<View style={styles.nicknameRow}>
|
||||
<Text style={styles.nickname}>{user.nickname}</Text>
|
||||
<VerificationBadge identity={user.identity} verificationStatus={user.verification_status} />
|
||||
</View>
|
||||
<Text style={styles.username}>@{user.username}</Text>
|
||||
</View>
|
||||
|
||||
{/* 简介 */}
|
||||
{user.bio ? (
|
||||
<Text style={styles.bio}>{user.bio}</Text>
|
||||
) : null}
|
||||
|
||||
{/* 元信息 */}
|
||||
<View style={styles.metaInfo}>
|
||||
{user.location?.trim() ? (
|
||||
<View style={styles.metaTag}>
|
||||
<MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.primary.main} style={styles.metaTagText}>
|
||||
{user.location}
|
||||
</Text>
|
||||
<View style={styles.metaItem}>
|
||||
<MaterialCommunityIcons name="map-marker-outline" size={14} color={colors.text.hint} />
|
||||
<Text style={styles.metaText}>{user.location}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{user.website?.trim() ? (
|
||||
<View style={styles.metaTag}>
|
||||
<MaterialCommunityIcons name="link-variant" size={12} color={colors.info.main} />
|
||||
<Text variant="caption" color={colors.info.main} style={styles.metaTagText}>
|
||||
{user.website.replace(/^https?:\/\//, '')}
|
||||
</Text>
|
||||
<View style={styles.metaItem}>
|
||||
<MaterialCommunityIcons name="link-variant" size={14} color={colors.info.main} />
|
||||
<Text style={[styles.metaText, styles.metaLink]}>{user.website.replace(/^https?:\/\//, '')}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.metaTag}>
|
||||
<MaterialCommunityIcons name="calendar-outline" size={12} color={colors.text.secondary} />
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.metaTagText}>
|
||||
加入于 {new Date(user.created_at || Date.now()).getFullYear()}年
|
||||
<View style={styles.metaItem}>
|
||||
<MaterialCommunityIcons name="calendar-outline" size={14} color={colors.text.hint} />
|
||||
<Text style={styles.metaText}>
|
||||
{(() => {
|
||||
const d = new Date(user.created_at || Date.now());
|
||||
if (Number.isNaN(d.getTime())) return '加入于 未知时间';
|
||||
return `加入于 ${d.getFullYear()}年${d.getMonth() + 1}月`;
|
||||
})()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 统计数据 - 卡片式 */}
|
||||
<View style={[
|
||||
styles.statsCard,
|
||||
isWideScreen && styles.statsCardWide,
|
||||
]}>
|
||||
{renderStatItem({
|
||||
value: formatCount(getPostsCount()),
|
||||
label: '帖子',
|
||||
onPress: onPostsPress,
|
||||
})}
|
||||
<View style={styles.statDivider} />
|
||||
{renderStatItem({
|
||||
value: formatCount(getFollowingCount()),
|
||||
label: '关注',
|
||||
onPress: onFollowingPress,
|
||||
})}
|
||||
<View style={styles.statDivider} />
|
||||
{renderStatItem({
|
||||
value: formatCount(getFollowersCount()),
|
||||
label: '粉丝',
|
||||
onPress: onFollowersPress,
|
||||
})}
|
||||
{/* 统计数据 - Twitter 风格内联 */}
|
||||
<View style={styles.statsRow}>
|
||||
{renderStatLink(getFollowingCount(), '正在关注', onFollowingPress)}
|
||||
{renderStatLink(getFollowersCount(), '关注者', onFollowersPress)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View style={styles.actionButtons}>
|
||||
{isCurrentUser ? (
|
||||
<View style={styles.buttonRow} />
|
||||
) : (
|
||||
<View style={StyleSheet.flatten([
|
||||
styles.buttonRow,
|
||||
isWideScreen && styles.buttonRowWide,
|
||||
])}>
|
||||
<Button
|
||||
title={getButtonConfig().title}
|
||||
onPress={onFollow}
|
||||
variant={getButtonConfig().variant}
|
||||
style={StyleSheet.flatten([
|
||||
styles.followButton,
|
||||
isWideScreen && styles.followButtonWide,
|
||||
])}
|
||||
icon={getButtonConfig().icon}
|
||||
{renderDropdownMenu()}
|
||||
|
||||
{/* 图片预览 */}
|
||||
<ImageGallery
|
||||
visible={galleryVisible}
|
||||
images={galleryImages}
|
||||
initialIndex={galleryIndex}
|
||||
onClose={() => setGalleryVisible(false)}
|
||||
enableSave
|
||||
/>
|
||||
<TouchableOpacity style={styles.messageButton} onPress={onMessage}>
|
||||
<MaterialCommunityIcons name="message-text-outline" size={20} color={colors.primary.main} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.moreButton} onPress={onMore}>
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={24} color={colors.text.secondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -321,11 +343,12 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
||||
function createUserProfileHeaderStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: colors.background.default,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
// 封面
|
||||
coverContainer: {
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
coverTouchable: {
|
||||
width: '100%',
|
||||
@@ -335,67 +358,58 @@ function createUserProfileHeaderStyles(colors: AppColors) {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
gradient: {
|
||||
coverFallback: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
settingsButton: {
|
||||
position: 'absolute',
|
||||
top: spacing.lg,
|
||||
right: spacing.lg,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
waveDecoration: {
|
||||
// 顶部栏
|
||||
topBar: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 40,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.md,
|
||||
},
|
||||
wave: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: colors.background.default,
|
||||
borderTopLeftRadius: 30,
|
||||
borderTopRightRadius: 30,
|
||||
iconButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
...shadows.sm,
|
||||
},
|
||||
profileCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: -50,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.lg,
|
||||
...shadows.md,
|
||||
// 信息区
|
||||
infoSection: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.lg,
|
||||
},
|
||||
profileCardWide: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: -60,
|
||||
padding: spacing.xl,
|
||||
avatarActionRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
avatarWrapper: {
|
||||
alignItems: 'center',
|
||||
marginTop: -60,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
avatarWrapperWide: {
|
||||
marginTop: -80,
|
||||
marginBottom: spacing.lg,
|
||||
// 负边距在 inline style 中计算
|
||||
},
|
||||
avatarContainer: {
|
||||
position: 'relative',
|
||||
padding: 4,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: 50,
|
||||
padding: 3,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
editAvatarButton: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
@@ -405,140 +419,206 @@ function createUserProfileHeaderStyles(colors: AppColors) {
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
userInfo: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
nickname: {
|
||||
marginBottom: spacing.xs,
|
||||
fontWeight: '700',
|
||||
},
|
||||
nicknameWide: {
|
||||
fontSize: fontSizes['3xl'],
|
||||
},
|
||||
username: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
bio: {
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.sm,
|
||||
lineHeight: 20,
|
||||
},
|
||||
bioWide: {
|
||||
fontSize: fontSizes.md,
|
||||
lineHeight: 24,
|
||||
maxWidth: 600,
|
||||
},
|
||||
bioPlaceholder: {
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.sm,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
metaInfo: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
metaTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
metaTagText: {
|
||||
marginLeft: spacing.xs,
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
statsCard: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: spacing.xs,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
statsCardWide: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
statItem: {
|
||||
flex: 1,
|
||||
minHeight: 58,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
statItemTouchable: {
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
statContent: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
statNumber: {
|
||||
fontWeight: '600',
|
||||
marginBottom: 0,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: fontSizes.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
statDivider: {
|
||||
width: 1,
|
||||
height: 24,
|
||||
backgroundColor: colors.divider + '55',
|
||||
},
|
||||
// 操作按钮
|
||||
actionButtons: {
|
||||
marginTop: spacing.sm,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
buttonRowWide: {
|
||||
iconButtonSmall: {
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: borderRadius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
editProfileButton: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
editProfileText: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
followBtn: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.full,
|
||||
minWidth: 80,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
followBtnPrimary: {
|
||||
backgroundColor: colors.text.primary,
|
||||
},
|
||||
followBtnOutline: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
followBtnText: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '700',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
followBtnTextPrimary: {
|
||||
color: colors.background.paper,
|
||||
},
|
||||
// 名字
|
||||
nameSection: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
nicknameRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
marginBottom: 2,
|
||||
},
|
||||
nickname: {
|
||||
fontSize: fontSizes['2xl'],
|
||||
fontWeight: '800',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
username: {
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.hint,
|
||||
},
|
||||
// 简介
|
||||
bio: {
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
lineHeight: 20,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
// 元信息
|
||||
metaInfo: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
editButton: {
|
||||
flex: 1,
|
||||
},
|
||||
followButton: {
|
||||
flex: 1,
|
||||
},
|
||||
followButtonWide: {
|
||||
flex: 0,
|
||||
minWidth: 120,
|
||||
},
|
||||
messageButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
metaItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
moreButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.background.default,
|
||||
justifyContent: 'center',
|
||||
metaText: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.hint,
|
||||
},
|
||||
metaLink: {
|
||||
color: colors.info.main,
|
||||
},
|
||||
// 统计
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.lg,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
statItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
settingsButtonOnly: {
|
||||
alignSelf: 'center',
|
||||
padding: spacing.sm,
|
||||
statNumber: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '700',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.hint,
|
||||
},
|
||||
// 下拉菜单
|
||||
menuBackdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
menuContainer: {
|
||||
position: 'absolute',
|
||||
minWidth: 160,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
menuItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
menuItemText: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 使用 React.memo 避免不必要的重新渲染
|
||||
const MemoizedUserProfileHeader = React.memo(UserProfileHeader);
|
||||
import { shadows } from '../../theme';
|
||||
|
||||
// ==================== VerificationBadge ====================
|
||||
|
||||
interface VerificationBadgeProps {
|
||||
identity?: UserIdentity;
|
||||
verificationStatus?: VerificationStatus;
|
||||
}
|
||||
|
||||
const IDENTITY_CONFIG: Record<
|
||||
UserIdentity,
|
||||
{ label: string; color: string; bg: string }
|
||||
> = {
|
||||
general: { label: '未认证', color: '#9CA3AF', bg: '#F3F4F6' },
|
||||
student: { label: '学生认证', color: '#10B981', bg: '#D1FAE5' },
|
||||
teacher: { label: '教师认证', color: '#3B82F6', bg: '#DBEAFE' },
|
||||
staff: { label: '官方', color: '#D4AF37', bg: '#FFF8DC' },
|
||||
alumni: { label: '校友认证', color: '#F59E0B', bg: '#FEF3C7' },
|
||||
external: { label: '未认证', color: '#9CA3AF', bg: '#F3F4F6' },
|
||||
};
|
||||
|
||||
const VerificationBadge: React.FC<VerificationBadgeProps> = ({
|
||||
identity,
|
||||
verificationStatus,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
|
||||
// 默认值:未认证状态
|
||||
const effectiveIdentity = identity || 'general';
|
||||
const effectiveStatus = verificationStatus || 'none';
|
||||
|
||||
const isApproved = effectiveStatus === 'approved';
|
||||
// none 表示未认证,统一用 general 的未认证样式
|
||||
const displayIdentity = isApproved ? effectiveIdentity : 'general';
|
||||
const config = IDENTITY_CONFIG[displayIdentity] || IDENTITY_CONFIG.general;
|
||||
|
||||
return (
|
||||
<View style={{ width: 22, height: 22, justifyContent: 'center', alignItems: 'center' }}>
|
||||
{isApproved ? (
|
||||
<MaterialCommunityIcons name="check-decagram" size={22} color={config.color} />
|
||||
) : (
|
||||
<>
|
||||
<MaterialCommunityIcons name="decagram" size={22} color="#9CA3AF" />
|
||||
<MaterialCommunityIcons name="decagram-outline" size={22} color="#9CA3AF" style={{ position: 'absolute' }} />
|
||||
<Text style={{ position: 'absolute', fontSize: 11, fontWeight: '800', color: '#FFFFFF', marginTop: 1 }}>?</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoizedUserProfileHeader = React.memo(UserProfileHeader);
|
||||
export default MemoizedUserProfileHeader;
|
||||
|
||||
@@ -159,7 +159,7 @@ function createVoteCardStyles(colors: AppColors) {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
loadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: colors.background.paper + 'CC',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -22,3 +22,11 @@ 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';
|
||||
|
||||
460
src/components/call/CallScreen.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
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;
|
||||
424
src/components/call/CallScreen.web.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
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;
|
||||
204
src/components/call/FloatingCallWindow.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons, Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { callStore } from '../../stores/call';
|
||||
|
||||
const BUTTON_SIZE = 40;
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const FloatingCallWindow: React.FC = () => {
|
||||
const currentCall = callStore((s) => s.currentCall);
|
||||
const callDuration = callStore((s) => s.callDuration);
|
||||
const isMinimized = callStore((s) => s.isMinimized);
|
||||
const toggleMinimize = callStore((s) => s.toggleMinimize);
|
||||
const endCall = callStore((s) => s.endCall);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (!currentCall || !isMinimized) return null;
|
||||
|
||||
const getStatusText = (): string => {
|
||||
switch (currentCall.status) {
|
||||
case 'connected':
|
||||
return formatDuration(callDuration);
|
||||
case 'connecting':
|
||||
case 'calling':
|
||||
case 'ringing':
|
||||
return '连接中...';
|
||||
case 'reconnecting':
|
||||
return '重连中...';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const isConnected = currentCall.status === 'connected';
|
||||
const participantCount = currentCall.participants.size;
|
||||
const isVideoCall = currentCall.mediaType === 'video';
|
||||
const isGroupCall = participantCount > 2;
|
||||
const displayName = isGroupCall
|
||||
? `群组通话 (${participantCount})`
|
||||
: currentCall.peerName || '未知用户';
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { top: insets.top + 4 }]}>
|
||||
<TouchableOpacity
|
||||
style={styles.bar}
|
||||
onPress={toggleMinimize}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{/* Call type indicator */}
|
||||
<View style={styles.callTypeIcon}>
|
||||
<Ionicons
|
||||
name={isVideoCall ? 'videocam' : 'call'}
|
||||
size={14}
|
||||
color={isConnected ? '#34C759' : 'rgba(255,255,255,0.6)'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Avatar + status indicator ring */}
|
||||
<View style={styles.avatarWrap}>
|
||||
{currentCall.peerAvatar && !isGroupCall ? (
|
||||
<Image source={{ uri: currentCall.peerAvatar }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={[styles.avatar, styles.avatarFallback]}>
|
||||
{isGroupCall ? (
|
||||
<MaterialCommunityIcons name="account-group" size={22} color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.avatarText}>
|
||||
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{isConnected && <View style={styles.statusDot} />}
|
||||
</View>
|
||||
|
||||
{/* Info */}
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text style={[styles.status, isConnected && styles.statusTimer]}>
|
||||
{getStatusText()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* End call button */}
|
||||
<TouchableOpacity
|
||||
style={styles.endBtn}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
endCall('hangup');
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<MaterialCommunityIcons name="phone-hangup" size={20} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10000,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(20, 20, 40, 0.92)',
|
||||
borderRadius: 18,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 14,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 12,
|
||||
elevation: 10,
|
||||
borderWidth: 0.5,
|
||||
borderColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
callTypeIcon: {
|
||||
marginRight: 8,
|
||||
width: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarWrap: {
|
||||
position: 'relative',
|
||||
marginRight: 14,
|
||||
},
|
||||
avatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#3A3A5C',
|
||||
},
|
||||
avatarFallback: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 18,
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
statusDot: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#34C759',
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(20, 20, 40, 0.92)',
|
||||
},
|
||||
info: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
name: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 2,
|
||||
},
|
||||
status: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(255, 255, 255, 0.45)',
|
||||
},
|
||||
statusTimer: {
|
||||
color: '#34C759',
|
||||
fontWeight: '500',
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
endBtn: {
|
||||
width: BUTTON_SIZE,
|
||||
height: BUTTON_SIZE,
|
||||
borderRadius: BUTTON_SIZE / 2,
|
||||
backgroundColor: '#E54D42',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: 10,
|
||||
},
|
||||
});
|
||||
|
||||
export default FloatingCallWindow;
|
||||
326
src/components/call/IncomingCallModal.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
Animated,
|
||||
Modal,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { callStore } from '../../stores/call';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
const AVATAR_SIZE = 104;
|
||||
const BTN_SIZE = 68;
|
||||
const BTN_ICON = 30;
|
||||
|
||||
const IncomingCallModal: React.FC = () => {
|
||||
const incomingCall = callStore((s) => s.incomingCall);
|
||||
const acceptCall = callStore((s) => s.acceptCall);
|
||||
const rejectCall = callStore((s) => s.rejectCall);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
const rippleAnim1 = useRef(new Animated.Value(0)).current;
|
||||
const rippleAnim2 = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (incomingCall) {
|
||||
blurActiveElement();
|
||||
|
||||
const pulse = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.05,
|
||||
duration: 1200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 1200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const ripple1 = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(rippleAnim1, {
|
||||
toValue: 1,
|
||||
duration: 2200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(rippleAnim1, {
|
||||
toValue: 0,
|
||||
duration: 0,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const ripple2 = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.delay(1100),
|
||||
Animated.timing(rippleAnim2, {
|
||||
toValue: 1,
|
||||
duration: 2200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(rippleAnim2, {
|
||||
toValue: 0,
|
||||
duration: 0,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
Animated.parallel([pulse, ripple1, ripple2]).start();
|
||||
return () => {
|
||||
pulse.stop();
|
||||
ripple1.stop();
|
||||
ripple2.stop();
|
||||
};
|
||||
}
|
||||
}, [incomingCall, pulseAnim, rippleAnim1, rippleAnim2]);
|
||||
|
||||
if (!incomingCall) return null;
|
||||
|
||||
const callerName = incomingCall.callerName || '未知用户';
|
||||
const callerInitial = callerName.charAt(0).toUpperCase();
|
||||
const isVideoCall = incomingCall.callType === 'video';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
transparent
|
||||
animationType="fade"
|
||||
statusBarTranslucent
|
||||
>
|
||||
<View style={styles.root}>
|
||||
<StatusBar barStyle="light-content" translucent backgroundColor="transparent" />
|
||||
|
||||
{/* Top safe area + label */}
|
||||
<View style={[styles.topLabel, { paddingTop: insets.top + 16 }]}>
|
||||
<Text style={styles.topLabelText}>
|
||||
{isVideoCall ? '视频通话邀请' : '语音通话邀请'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Avatar section with ripples */}
|
||||
<View style={styles.avatarSection}>
|
||||
{/* Ripple rings — staggered */}
|
||||
{[
|
||||
{ anim: rippleAnim1, scaleRange: [1, 1.6], opacityRange: [0.2, 0] as const },
|
||||
{ anim: rippleAnim2, scaleRange: [1, 1.4], opacityRange: [0.15, 0] as const },
|
||||
].map(({ anim, scaleRange, opacityRange }, i) => (
|
||||
<Animated.View
|
||||
key={i}
|
||||
style={[
|
||||
styles.ripple,
|
||||
{
|
||||
transform: [
|
||||
{
|
||||
scale: anim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: scaleRange,
|
||||
}),
|
||||
},
|
||||
],
|
||||
opacity: anim.interpolate({
|
||||
inputRange: [0, 0.5, 1],
|
||||
outputRange: [0, ...opacityRange],
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
|
||||
<View style={styles.avatarBorder}>
|
||||
{incomingCall.callerAvatar ? (
|
||||
<Image source={{ uri: incomingCall.callerAvatar }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={[styles.avatar, styles.avatarFallback]}>
|
||||
<Text style={styles.avatarInitial}>{callerInitial}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Caller info */}
|
||||
<View style={styles.infoSection}>
|
||||
<Text style={styles.callerName} numberOfLines={1}>
|
||||
{callerName}
|
||||
</Text>
|
||||
<Text style={styles.callType}>
|
||||
{isVideoCall ? '邀请你进行视频通话' : '邀请你进行语音通话'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Bottom action buttons */}
|
||||
<View style={[styles.actions, { paddingBottom: insets.bottom + 32 }]}>
|
||||
{/* Decline */}
|
||||
<TouchableOpacity
|
||||
style={styles.actionTouchable}
|
||||
onPress={rejectCall}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.declineCircle}>
|
||||
<MaterialCommunityIcons
|
||||
name="phone-hangup"
|
||||
size={BTN_ICON}
|
||||
color="#fff"
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.actionLabel}>拒绝</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Accept */}
|
||||
<TouchableOpacity
|
||||
style={styles.actionTouchable}
|
||||
onPress={acceptCall}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.acceptCircle}>
|
||||
<MaterialCommunityIcons
|
||||
name="phone"
|
||||
size={BTN_ICON}
|
||||
color="#fff"
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.actionLabel}>接听</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default IncomingCallModal;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0A0A14',
|
||||
},
|
||||
topLabel: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
topLabelText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: 'rgba(255,255,255,0.35)',
|
||||
letterSpacing: 0.5,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
// ---- Avatar + ripples ----
|
||||
avatarSection: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
maxHeight: 260,
|
||||
},
|
||||
ripple: {
|
||||
position: 'absolute',
|
||||
width: AVATAR_SIZE + 32,
|
||||
height: AVATAR_SIZE + 32,
|
||||
borderRadius: (AVATAR_SIZE + 32) / 2,
|
||||
borderWidth: 2,
|
||||
borderColor: '#4CD964',
|
||||
},
|
||||
avatarBorder: {
|
||||
width: AVATAR_SIZE + 10,
|
||||
height: AVATAR_SIZE + 10,
|
||||
borderRadius: (AVATAR_SIZE + 10) / 2,
|
||||
borderWidth: 2.5,
|
||||
borderColor: 'rgba(76, 217, 100, 0.4)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatar: {
|
||||
width: AVATAR_SIZE,
|
||||
height: AVATAR_SIZE,
|
||||
borderRadius: AVATAR_SIZE / 2,
|
||||
backgroundColor: '#2A2A4E',
|
||||
},
|
||||
avatarFallback: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarInitial: {
|
||||
fontSize: 42,
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// ---- Info ----
|
||||
infoSection: {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 40,
|
||||
marginTop: -20,
|
||||
marginBottom: 40,
|
||||
},
|
||||
callerName: {
|
||||
fontSize: 28,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
textAlign: 'center',
|
||||
maxWidth: 300,
|
||||
},
|
||||
callType: {
|
||||
fontSize: 15,
|
||||
fontWeight: '400',
|
||||
color: 'rgba(255,255,255,0.45)',
|
||||
marginTop: 8,
|
||||
},
|
||||
|
||||
// ---- Actions ----
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 72,
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
actionTouchable: {
|
||||
alignItems: 'center',
|
||||
width: 80,
|
||||
},
|
||||
declineCircle: {
|
||||
width: BTN_SIZE,
|
||||
height: BTN_SIZE,
|
||||
borderRadius: BTN_SIZE / 2,
|
||||
backgroundColor: '#E54D42',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#E54D42',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.45,
|
||||
shadowRadius: 10,
|
||||
elevation: 8,
|
||||
},
|
||||
acceptCircle: {
|
||||
width: BTN_SIZE,
|
||||
height: BTN_SIZE,
|
||||
borderRadius: BTN_SIZE / 2,
|
||||
backgroundColor: '#4CD964',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#4CD964',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.45,
|
||||
shadowRadius: 10,
|
||||
elevation: 8,
|
||||
},
|
||||
actionLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
marginTop: 12,
|
||||
},
|
||||
});
|
||||
3
src/components/call/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as CallScreen } from './CallScreen';
|
||||
export { default as IncomingCallModal } from './IncomingCallModal';
|
||||
export { default as FloatingCallWindow } from './FloatingCallWindow';
|
||||