Compare commits
22 Commits
97477c3471
...
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 |
@@ -1,10 +1,40 @@
|
|||||||
|
# Dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
# Source control
|
||||||
.git
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Expo caches
|
||||||
.expo
|
.expo
|
||||||
|
.expo-shared
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
dist
|
dist
|
||||||
dist-web
|
dist-web
|
||||||
dist-android
|
dist-android
|
||||||
|
dist-ios
|
||||||
dist-android-update.zip
|
dist-android-update.zip
|
||||||
|
dist-ios-update.zip
|
||||||
android
|
android
|
||||||
|
ios
|
||||||
|
web-build
|
||||||
|
|
||||||
|
# Native credentials (never ship into image)
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
*.orig.*
|
||||||
|
|
||||||
|
# Misc
|
||||||
screenshots
|
screenshots
|
||||||
*.log
|
*.log
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
coverage
|
||||||
|
__tests__
|
||||||
|
|||||||
@@ -21,17 +21,18 @@ on:
|
|||||||
env:
|
env:
|
||||||
REGISTRY: code.littlelan.cn
|
REGISTRY: code.littlelan.cn
|
||||||
IMAGE_NAME: carrot_bbs/frontend-web
|
IMAGE_NAME: carrot_bbs/frontend-web
|
||||||
OTA_PLATFORM_ANDROID: android
|
|
||||||
OTA_PLATFORM_IOS: ios
|
|
||||||
OTA_PUBLISH_URL: https://updates.littlelan.cn/admin/publish
|
OTA_PUBLISH_URL: https://updates.littlelan.cn/admin/publish
|
||||||
OTA_MANIFEST_URL: https://updates.littlelan.cn/api/manifest
|
OTA_MANIFEST_URL: https://updates.littlelan.cn/api/manifest
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ota-android:
|
ota:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_ota == 'true')
|
if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_ota == 'true')
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
platform: [android, ios]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -40,13 +41,6 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '25.6.1'
|
node-version: '25.6.1'
|
||||||
registry-url: 'https://registry.npmmirror.com'
|
|
||||||
|
|
||||||
- name: Remove deprecated always-auth npm config
|
|
||||||
run: |
|
|
||||||
if [ -f ~/.npmrc ]; then
|
|
||||||
sed -i '/always-auth/d' ~/.npmrc
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -54,121 +48,48 @@ jobs:
|
|||||||
- name: Resolve runtime version
|
- name: Resolve runtime version
|
||||||
id: runtime
|
id: runtime
|
||||||
run: |
|
run: |
|
||||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
|
RUNTIME_VERSION="$(node -p "require('./app.json').expo.runtimeVersion")"
|
||||||
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
||||||
|
|
||||||
- name: Export Android update bundle
|
- name: Export update bundle
|
||||||
run: |
|
run: |
|
||||||
rm -rf dist-android dist-android-update.zip
|
rm -rf dist-${{ matrix.platform }} dist-${{ matrix.platform }}-update.zip
|
||||||
npx expo export --platform android --output-dir dist-android
|
npx expo export --platform ${{ matrix.platform }} --output-dir dist-${{ matrix.platform }}
|
||||||
npx expo config --type public --json > dist-android/expoConfig.json
|
npx expo config --type public --json > dist-${{ matrix.platform }}/expoConfig.json
|
||||||
|
|
||||||
- name: Archive Android update bundle
|
- name: Archive update bundle
|
||||||
run: |
|
run: |
|
||||||
python - <<'PY'
|
python -c "
|
||||||
import os
|
import os, zipfile
|
||||||
import zipfile
|
p = '${{ matrix.platform }}'
|
||||||
|
dist = f'dist-{p}'
|
||||||
with zipfile.ZipFile('dist-android-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
|
with zipfile.ZipFile(f'dist-{p}-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
for root, _, files in os.walk('dist-android'):
|
for root, _, files in os.walk(dist):
|
||||||
for name in files:
|
for name in files:
|
||||||
src = os.path.join(root, name)
|
src = os.path.join(root, name)
|
||||||
arc = os.path.relpath(src, 'dist-android')
|
arc = os.path.relpath(src, dist)
|
||||||
zf.write(src, arc)
|
zf.write(src, arc)
|
||||||
PY
|
"
|
||||||
|
|
||||||
- name: Publish OTA
|
- name: Publish OTA
|
||||||
env:
|
env:
|
||||||
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
||||||
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${OTA_PLATFORM_ANDROID}" \
|
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${{ matrix.platform }}" \
|
||||||
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
||||||
-H "Content-Type: application/zip" \
|
-H "Content-Type: application/zip" \
|
||||||
--data-binary @"dist-android-update.zip"
|
--data-binary @"dist-${{ matrix.platform }}-update.zip"
|
||||||
|
|
||||||
- name: Verify OTA manifest
|
- name: Verify OTA manifest
|
||||||
run: |
|
run: |
|
||||||
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
|
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
|
||||||
-H "expo-platform: ${OTA_PLATFORM_ANDROID}" \
|
-H "expo-platform: ${{ matrix.platform }}" \
|
||||||
-H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
|
|
||||||
-H "expo-protocol-version: 1" \
|
|
||||||
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\\\"id\\\":.*\\\"extra\\\":\\{.*\\}\\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
|
|
||||||
LOCAL="$(python -c "import json; m=json.load(open('dist-android/metadata.json')); print(m['fileMetadata']['android']['bundle'].split('/')[-1])")"
|
|
||||||
echo "Remote bundle: ${REMOTE}"
|
|
||||||
echo "Local bundle: ${LOCAL}"
|
|
||||||
test "${REMOTE}" = "${LOCAL}"
|
|
||||||
|
|
||||||
ota-ios:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_ota == 'true')
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '25.6.1'
|
|
||||||
registry-url: 'https://registry.npmmirror.com'
|
|
||||||
|
|
||||||
- name: Remove deprecated always-auth npm config
|
|
||||||
run: |
|
|
||||||
if [ -f ~/.npmrc ]; then
|
|
||||||
sed -i '/always-auth/d' ~/.npmrc
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Resolve runtime version
|
|
||||||
id: runtime
|
|
||||||
run: |
|
|
||||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
|
|
||||||
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
|
||||||
|
|
||||||
- name: Export iOS update bundle
|
|
||||||
run: |
|
|
||||||
rm -rf dist-ios dist-ios-update.zip
|
|
||||||
npx expo export --platform ios --output-dir dist-ios
|
|
||||||
npx expo config --type public --json > dist-ios/expoConfig.json
|
|
||||||
|
|
||||||
- name: Archive iOS update bundle
|
|
||||||
run: |
|
|
||||||
python - <<'PY'
|
|
||||||
import os
|
|
||||||
import zipfile
|
|
||||||
|
|
||||||
with zipfile.ZipFile('dist-ios-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
|
|
||||||
for root, _, files in os.walk('dist-ios'):
|
|
||||||
for name in files:
|
|
||||||
src = os.path.join(root, name)
|
|
||||||
arc = os.path.relpath(src, 'dist-ios')
|
|
||||||
zf.write(src, arc)
|
|
||||||
PY
|
|
||||||
|
|
||||||
- name: Publish OTA
|
|
||||||
env:
|
|
||||||
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
|
||||||
run: |
|
|
||||||
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
|
||||||
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${OTA_PLATFORM_IOS}" \
|
|
||||||
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
|
||||||
-H "Content-Type: application/zip" \
|
|
||||||
--data-binary @"dist-ios-update.zip"
|
|
||||||
|
|
||||||
- name: Verify OTA manifest
|
|
||||||
run: |
|
|
||||||
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
|
|
||||||
-H "expo-platform: ${OTA_PLATFORM_IOS}" \
|
|
||||||
-H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
|
-H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
|
||||||
-H "expo-protocol-version: 1" \
|
-H "expo-protocol-version: 1" \
|
||||||
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\"id\":.*\"extra\":\{.*\}\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
|
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\"id\":.*\"extra\":\{.*\}\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
|
||||||
LOCAL="$(python -c "import json; m=json.load(open('dist-ios/metadata.json')); print(m['fileMetadata']['ios']['bundle'].split('/')[-1])")"
|
LOCAL="$(python -c "import json; m=json.load(open('dist-${{ matrix.platform }}/metadata.json')); print(m['fileMetadata']['${{ matrix.platform }}']['bundle'].split('/')[-1])")"
|
||||||
echo "Remote bundle: ${REMOTE}"
|
echo "Remote bundle: ${REMOTE}"
|
||||||
echo "Local bundle: ${LOCAL}"
|
echo "Local bundle: ${LOCAL}"
|
||||||
test "${REMOTE}" = "${LOCAL}"
|
test "${REMOTE}" = "${LOCAL}"
|
||||||
@@ -178,12 +99,9 @@ jobs:
|
|||||||
container:
|
container:
|
||||||
image: reactnativecommunity/react-native-android:latest
|
image: reactnativecommunity/react-native-android:latest
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=4 -Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
|
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=false -Dorg.gradle.workers.max=1"
|
||||||
_JAVA_OPTIONS: "-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
|
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
|
||||||
NODE_ENV: "production"
|
NODE_ENV: "production"
|
||||||
NDK_NUM_JOBS: "4"
|
|
||||||
CMAKE_BUILD_PARALLEL_LEVEL: "4"
|
|
||||||
GRADLE_USER_HOME: /root/.gradle
|
GRADLE_USER_HOME: /root/.gradle
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -201,86 +119,24 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '25.6.1'
|
node-version: '25.6.1'
|
||||||
registry-url: 'https://registry.npmmirror.com'
|
|
||||||
|
|
||||||
- name: Remove deprecated always-auth npm config
|
|
||||||
run: |
|
|
||||||
if [ -f ~/.npmrc ]; then
|
|
||||||
sed -i '/always-auth/d' ~/.npmrc
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Cache Android NDK
|
|
||||||
uses: actions/cache@v4
|
|
||||||
id: cache-ndk
|
|
||||||
with:
|
|
||||||
path: /opt/android/ndk/27.1.12297006
|
|
||||||
key: ndk-27.1.12297006-v1
|
|
||||||
|
|
||||||
- name: Install Android NDK
|
|
||||||
if: steps.cache-ndk.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
echo "Existing NDK versions:"
|
|
||||||
ls /opt/android/ndk/ 2>/dev/null || echo "No NDK dir"
|
|
||||||
echo "Installing NDK 27.1.12297006..."
|
|
||||||
yes | sdkmanager --install "ndk;27.1.12297006"
|
|
||||||
echo "NDK after install:"
|
|
||||||
ls /opt/android/ndk/
|
|
||||||
|
|
||||||
- name: Cache Gradle
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.gradle/caches
|
|
||||||
~/.gradle/wrapper
|
|
||||||
~/.android/build-cache
|
|
||||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle.properties') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-gradle-
|
|
||||||
|
|
||||||
- name: Cache node_modules
|
|
||||||
uses: actions/cache@v4
|
|
||||||
id: cache-node-modules
|
|
||||||
with:
|
|
||||||
path: node_modules
|
|
||||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-node-modules-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Generate Android native project
|
- name: Generate Android native project
|
||||||
run: npx expo prebuild --platform android
|
run: npx expo prebuild --platform android
|
||||||
|
|
||||||
- name: Switch Gradle distribution to Tencent mirror
|
- name: Configure Gradle with signing
|
||||||
run: |
|
|
||||||
PROPS="android/gradle/wrapper/gradle-wrapper.properties"
|
|
||||||
if [ -f "$PROPS" ]; then
|
|
||||||
sed -i 's|distributionUrl=https\\://services.gradle.org/distributions/|distributionUrl=https\\://mirrors.cloud.tencent.com/gradle/|' "$PROPS"
|
|
||||||
echo "Updated gradle-wrapper.properties:"
|
|
||||||
cat "$PROPS"
|
|
||||||
else
|
|
||||||
echo "gradle-wrapper.properties not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Decode Android signing keystore
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/app/withyou-release-key.keystore
|
|
||||||
|
|
||||||
- name: Configure Gradle with China Maven mirrors
|
|
||||||
run: |
|
run: |
|
||||||
cd android
|
cd android
|
||||||
|
|
||||||
# Update settings.gradle with China Maven mirrors
|
# Decode Android signing keystore
|
||||||
|
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > app/withyou-release-key.keystore
|
||||||
|
|
||||||
|
# Update settings.gradle
|
||||||
cat > settings.gradle << 'SETTINGS_EOF'
|
cat > settings.gradle << 'SETTINGS_EOF'
|
||||||
pluginManagement {
|
pluginManagement {
|
||||||
repositories {
|
repositories {
|
||||||
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
|
||||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
|
||||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
|
||||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
|
||||||
maven { url 'https://developer.huawei.com/repo/' }
|
maven { url 'https://developer.huawei.com/repo/' }
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
@@ -310,6 +166,15 @@ jobs:
|
|||||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
|
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositories {
|
||||||
|
maven { url 'https://developer.huawei.com/repo/' }
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
maven { url 'https://www.jitpack.io' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
||||||
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
|
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
|
||||||
ex.autolinkLibrariesFromCommand()
|
ex.autolinkLibrariesFromCommand()
|
||||||
@@ -327,16 +192,12 @@ jobs:
|
|||||||
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
||||||
SETTINGS_EOF
|
SETTINGS_EOF
|
||||||
|
|
||||||
# Update build.gradle with China Maven mirrors
|
# Update build.gradle
|
||||||
cat > build.gradle << 'BUILD_EOF'
|
cat > build.gradle << 'BUILD_EOF'
|
||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
repositories {
|
repositories {
|
||||||
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
|
||||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
|
||||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
|
||||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
|
||||||
maven { url 'https://developer.huawei.com/repo/' }
|
maven { url 'https://developer.huawei.com/repo/' }
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
@@ -354,13 +215,10 @@ jobs:
|
|||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
|
||||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
|
||||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
|
||||||
maven { url 'https://www.jitpack.io' }
|
|
||||||
maven { url 'https://developer.huawei.com/repo/' }
|
maven { url 'https://developer.huawei.com/repo/' }
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven { url 'https://www.jitpack.io' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,13 +226,14 @@ jobs:
|
|||||||
apply plugin: "com.facebook.react.rootproject"
|
apply plugin: "com.facebook.react.rootproject"
|
||||||
BUILD_EOF
|
BUILD_EOF
|
||||||
|
|
||||||
# Update gradle.properties
|
# Update gradle.properties (without secrets for better cache hit rate)
|
||||||
cat > gradle.properties << 'PROPS_EOF'
|
cat > gradle.properties << 'PROPS_EOF'
|
||||||
org.gradle.daemon=false
|
org.gradle.daemon=false
|
||||||
org.gradle.parallel=true
|
org.gradle.parallel=false
|
||||||
org.gradle.configureondemand=true
|
org.gradle.configureondemand=false
|
||||||
org.gradle.workers.max=4
|
org.gradle.workers.max=1
|
||||||
org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
kotlin.daemon.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m
|
||||||
android.enableJetifier=false
|
android.enableJetifier=false
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
hermesEnabled=true
|
hermesEnabled=true
|
||||||
@@ -386,11 +245,11 @@ jobs:
|
|||||||
expo.webp.animated=false
|
expo.webp.animated=false
|
||||||
ndkVersion=27.1.12297006
|
ndkVersion=27.1.12297006
|
||||||
expo.useLegacyPackaging=false
|
expo.useLegacyPackaging=false
|
||||||
|
systemProp.org.gradle.internal.http.connectionTimeout=30000
|
||||||
|
systemProp.org.gradle.internal.http.socketTimeout=30000
|
||||||
PROPS_EOF
|
PROPS_EOF
|
||||||
|
|
||||||
- name: Configure Gradle signing properties
|
# Append signing properties (secrets appended, not cached)
|
||||||
run: |
|
|
||||||
cd android
|
|
||||||
cat >> gradle.properties << SIGNING_PROPS
|
cat >> gradle.properties << SIGNING_PROPS
|
||||||
MYAPP_UPLOAD_STORE_FILE=withyou-release-key.keystore
|
MYAPP_UPLOAD_STORE_FILE=withyou-release-key.keystore
|
||||||
MYAPP_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
MYAPP_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
@@ -398,11 +257,15 @@ jobs:
|
|||||||
MYAPP_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}
|
MYAPP_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
SIGNING_PROPS
|
SIGNING_PROPS
|
||||||
|
|
||||||
|
# Verify signing config in app/build.gradle
|
||||||
|
echo "=== app/build.gradle signing section ==="
|
||||||
|
grep -A 20 'signingConfigs' app/build.gradle || echo "signingConfigs not found"
|
||||||
|
|
||||||
- name: Build Android release APK (arm64 only)
|
- name: Build Android release APK (arm64 only)
|
||||||
run: |
|
run: |
|
||||||
cd android
|
cd android
|
||||||
chmod +x gradlew
|
chmod +x gradlew
|
||||||
./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=4 --parallel
|
taskset -c 0-7 ./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=1 -Dkotlin.daemon.jvm.options="-Xmx2g,XX:MaxMetaspaceSize=1g"
|
||||||
|
|
||||||
- name: Upload APK artifact
|
- name: Upload APK artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
@@ -413,7 +276,7 @@ jobs:
|
|||||||
- name: Resolve runtime version
|
- name: Resolve runtime version
|
||||||
id: runtime
|
id: runtime
|
||||||
run: |
|
run: |
|
||||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
|
RUNTIME_VERSION="$(node -p "require('./app.json').expo.runtimeVersion")"
|
||||||
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
||||||
|
|
||||||
@@ -488,8 +351,6 @@ jobs:
|
|||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
provenance: false
|
provenance: false
|
||||||
cache-from: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache
|
|
||||||
cache-to: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache,mode=max
|
|
||||||
|
|
||||||
- name: Show image tags
|
- name: Show image tags
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ COPY package.json package-lock.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npx expo export --platform web --output-dir dist-web
|
RUN node /app/node_modules/.bin/expo export --platform web --output-dir dist-web
|
||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,21 @@ const releaseApiBaseUrl = 'https://withyou.littlelan.cn/api/v1';
|
|||||||
const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
|
const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
|
||||||
const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1';
|
const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1';
|
||||||
|
|
||||||
function getGitShortHash() {
|
function getCommitCount() {
|
||||||
try {
|
try {
|
||||||
return execSync('git rev-parse --short=4 HEAD', { encoding: 'utf-8' }).trim();
|
return execSync('git rev-list --count HEAD', { encoding: 'utf-8' }).trim();
|
||||||
} catch {
|
} catch {
|
||||||
return '0000';
|
return '1';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const gitBuildSuffix = getGitShortHash();
|
// 在 commit count 上加偏移,保证 versionCode / buildNumber / runtimeVersion
|
||||||
|
// 始终大于历史最大值(之前的最后一个版本是 5547),避免被系统/Play Store
|
||||||
|
// 误判为降级。后续即使 commit count 重置或换仓库,偏移也能保证单调递增。
|
||||||
|
const BUILD_NUMBER_OFFSET = 100000;
|
||||||
|
|
||||||
|
const commitCount = getCommitCount();
|
||||||
|
const buildNumber = String(parseInt(commitCount, 10) + BUILD_NUMBER_OFFSET);
|
||||||
|
|
||||||
function toManifestUrl(baseUrl, portOverride) {
|
function toManifestUrl(baseUrl, portOverride) {
|
||||||
const parsed = new URL(baseUrl);
|
const parsed = new URL(baseUrl);
|
||||||
@@ -60,9 +66,9 @@ const filteredPlugins = isWeb
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
...expo,
|
...expo,
|
||||||
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
||||||
runtimeVersion: {
|
// runtimeVersion 用 build number(commit count + 偏移):单调递增、纯数字、与 version (语义版本) 解耦
|
||||||
policy: 'appVersion',
|
// 字符串形式,等价于 policy: 'custom'
|
||||||
},
|
runtimeVersion: buildNumber,
|
||||||
updates: {
|
updates: {
|
||||||
...(expo.updates || {}),
|
...(expo.updates || {}),
|
||||||
url: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
url: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
||||||
@@ -71,12 +77,12 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
ios: {
|
ios: {
|
||||||
...expo.ios,
|
...expo.ios,
|
||||||
buildNumber: gitBuildSuffix,
|
buildNumber: buildNumber,
|
||||||
},
|
},
|
||||||
android: {
|
android: {
|
||||||
...expo.android,
|
...expo.android,
|
||||||
package: 'cn.qczlit.withyou',
|
package: 'cn.qczlit.withyou',
|
||||||
versionCode: parseInt(gitBuildSuffix, 16),
|
versionCode: parseInt(buildNumber, 10),
|
||||||
},
|
},
|
||||||
// Web 端使用过滤后的插件
|
// Web 端使用过滤后的插件
|
||||||
plugins: filteredPlugins,
|
plugins: filteredPlugins,
|
||||||
|
|||||||
13
app.json
13
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "威友",
|
"name": "威友",
|
||||||
"slug": "qojo",
|
"slug": "qojo",
|
||||||
"version": "0.0.2",
|
"version": "1.0.1",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
@@ -36,7 +36,6 @@
|
|||||||
},
|
},
|
||||||
"predictiveBackGestureEnabled": false,
|
"predictiveBackGestureEnabled": false,
|
||||||
"package": "cn.qczlit.withyou",
|
"package": "cn.qczlit.withyou",
|
||||||
"versionCode": 7,
|
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"VIBRATE",
|
"VIBRATE",
|
||||||
"RECORD_AUDIO",
|
"RECORD_AUDIO",
|
||||||
@@ -53,7 +52,6 @@
|
|||||||
"android.permission.READ_EXTERNAL_STORAGE",
|
"android.permission.READ_EXTERNAL_STORAGE",
|
||||||
"android.permission.WRITE_EXTERNAL_STORAGE",
|
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||||
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
|
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
|
||||||
"android.permission.ACCESS_MEDIA_LOCATION",
|
|
||||||
"android.permission.READ_MEDIA_IMAGES",
|
"android.permission.READ_MEDIA_IMAGES",
|
||||||
"android.permission.READ_MEDIA_VIDEO",
|
"android.permission.READ_MEDIA_VIDEO",
|
||||||
"android.permission.READ_MEDIA_AUDIO",
|
"android.permission.READ_MEDIA_AUDIO",
|
||||||
@@ -66,6 +64,8 @@
|
|||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"./plugins/withCmakeJobLimit",
|
||||||
|
"./plugins/withJcorePatch",
|
||||||
"./plugins/withSigning",
|
"./plugins/withSigning",
|
||||||
"./plugins/withMainActivityConfigChange",
|
"./plugins/withMainActivityConfigChange",
|
||||||
[
|
[
|
||||||
@@ -100,12 +100,13 @@
|
|||||||
"minimumInterval": 15
|
"minimumInterval": 15
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"./plugins/withRemoveAutoStart",
|
||||||
[
|
[
|
||||||
"expo-media-library",
|
"expo-media-library",
|
||||||
{
|
{
|
||||||
"photosPermission": "允许威友访问您的照片以发布内容",
|
"photosPermission": "允许威友访问您的照片以发布内容",
|
||||||
"savePhotosPermission": "允许威友保存照片到您的相册",
|
"savePhotosPermission": "允许威友保存照片到您的相册",
|
||||||
"isAccessMediaLocationEnabled": true
|
"isAccessMediaLocationEnabled": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -154,6 +155,10 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./plugins/withHuaweiPush",
|
"./plugins/withHuaweiPush",
|
||||||
|
"./plugins/withXiaomiPush",
|
||||||
|
"./plugins/withHonorPush",
|
||||||
|
"./plugins/withVivoPush",
|
||||||
|
"./plugins/withOppoPush",
|
||||||
"expo-callkit-telecom",
|
"expo-callkit-telecom",
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
|
|||||||
@@ -1,24 +1,54 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ActivityIndicator, View } from 'react-native';
|
||||||
import { Redirect } from 'expo-router';
|
import { Redirect } from 'expo-router';
|
||||||
|
|
||||||
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
|
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
|
||||||
import { messageManager, useAuthStore } from '../../src/stores';
|
import { messageManager, useAuthStore } from '../../src/stores';
|
||||||
import { useRegisterPushDevice } from '../../src/hooks';
|
import { useRegisterPushDevice } from '../../src/hooks';
|
||||||
import { hrefAuthLogin } from '../../src/navigation/hrefs';
|
import { hrefAuthLogin } from '../../src/navigation/hrefs';
|
||||||
|
import { useAppColors } from '../../src/theme';
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
|
||||||
const userID = useAuthStore((s) => s.currentUser?.id);
|
const userID = useAuthStore((s) => s.currentUser?.id);
|
||||||
|
const colors = useAppColors();
|
||||||
|
const [verified, setVerified] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 冷启动 token 校验:仅鉴权分组 (app) 需要。
|
||||||
|
// 公开页(/privacy、/terms)和 (auth) 分组在根布局直接渲染,不受此校验影响,
|
||||||
|
// 因此冷启动 401 不会把公开页顶到 /login。
|
||||||
|
fetchCurrentUser().finally(() => setVerified(true));
|
||||||
|
}, [fetchCurrentUser]);
|
||||||
|
|
||||||
|
useRegisterPushDevice(isAuthenticated, userID);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messageManager.initialize();
|
messageManager.initialize();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useRegisterPushDevice(isAuthenticated, userID);
|
// 持久化状态显示已登录则立即渲染(后台静默校验)
|
||||||
|
if (isAuthenticated) {
|
||||||
if (!isAuthenticated) {
|
|
||||||
return <Redirect href={hrefAuthLogin()} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <AppRouteStack />;
|
return <AppRouteStack />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 未登录且校验未完成,显示 loading
|
||||||
|
if (!verified) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.background.default,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验完成仍未登录,跳转登录页
|
||||||
|
return <Redirect href={hrefAuthLogin()} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useWindowDimensions } from 'react-native';
|
import { ActivityIndicator, View, useWindowDimensions } from 'react-native';
|
||||||
import { Redirect } from 'expo-router';
|
import { Redirect } from 'expo-router';
|
||||||
|
|
||||||
import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell';
|
import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell';
|
||||||
@@ -7,23 +7,50 @@ import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
|
|||||||
import { BREAKPOINTS } from '../../src/hooks';
|
import { BREAKPOINTS } from '../../src/hooks';
|
||||||
import { messageManager, useAuthStore } from '../../src/stores';
|
import { messageManager, useAuthStore } from '../../src/stores';
|
||||||
import { hrefAuthLogin } from '../../src/navigation/hrefs';
|
import { hrefAuthLogin } from '../../src/navigation/hrefs';
|
||||||
|
import { useAppColors } from '../../src/theme';
|
||||||
|
|
||||||
export default function AppLayoutWeb() {
|
export default function AppLayoutWeb() {
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
|
||||||
const useDesktopShell = width >= BREAKPOINTS.desktop;
|
const useDesktopShell = width >= BREAKPOINTS.desktop;
|
||||||
|
const colors = useAppColors();
|
||||||
|
const [verified, setVerified] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 冷启动 token 校验:仅鉴权分组 (app) 需要。
|
||||||
|
// 公开页(/privacy、/terms)和 (auth) 分组在根布局直接渲染,不受此校验影响。
|
||||||
|
fetchCurrentUser().finally(() => setVerified(true));
|
||||||
|
}, [fetchCurrentUser]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messageManager.initialize();
|
messageManager.initialize();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
// 持久化状态显示已登录则立即渲染(后台静默校验)
|
||||||
return <Redirect href={hrefAuthLogin()} />;
|
if (isAuthenticated) {
|
||||||
}
|
|
||||||
|
|
||||||
if (useDesktopShell) {
|
if (useDesktopShell) {
|
||||||
return <AppDesktopShell />;
|
return <AppDesktopShell />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AppRouteStack />;
|
return <AppRouteStack />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 未登录且校验未完成,显示 loading
|
||||||
|
if (!verified) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.background.default,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验完成仍未登录,跳转登录页
|
||||||
|
return <Redirect href={hrefAuthLogin()} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import '../src/polyfills';
|
import '../src/polyfills';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { AppState, AppStateStatus, Platform, View, ActivityIndicator } from 'react-native';
|
import { AppState, AppStateStatus, Platform, View, ActivityIndicator } from 'react-native';
|
||||||
import { Stack, useRouter } from 'expo-router';
|
import { Stack, useRouter } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
@@ -11,8 +11,6 @@ import * as SystemUI from 'expo-system-ui';
|
|||||||
import { useFonts } from 'expo-font';
|
import { useFonts } from 'expo-font';
|
||||||
|
|
||||||
import { registerNotificationPresentationHandler } from '@/services/notification';
|
import { registerNotificationPresentationHandler } from '@/services/notification';
|
||||||
import { api } from '@/services/core';
|
|
||||||
import { wsService } from '@/services/core';
|
|
||||||
import { EventSubscriber } from '../src/infrastructure/EventSubscriber';
|
import { EventSubscriber } from '../src/infrastructure/EventSubscriber';
|
||||||
import {
|
import {
|
||||||
ThemeBootstrap,
|
ThemeBootstrap,
|
||||||
@@ -24,7 +22,6 @@ import {
|
|||||||
import AppPromptBar from '../src/components/common/AppPromptBar';
|
import AppPromptBar from '../src/components/common/AppPromptBar';
|
||||||
import AppDialogHost from '../src/components/common/AppDialogHost';
|
import AppDialogHost from '../src/components/common/AppDialogHost';
|
||||||
import { installAlertOverride } from '@/services/ui';
|
import { installAlertOverride } from '@/services/ui';
|
||||||
import { useAuthStore } from '../src/stores';
|
|
||||||
import { checkForAPKUpdate } from '@/services/platform';
|
import { checkForAPKUpdate } from '@/services/platform';
|
||||||
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
|
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
|
||||||
import { jpushService } from '@/services/notification/jpushService';
|
import { jpushService } from '@/services/notification/jpushService';
|
||||||
@@ -104,36 +101,6 @@ function SystemChrome() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionGate({ children }: { children: React.ReactNode }) {
|
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
|
||||||
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
|
|
||||||
const colors = useAppColors();
|
|
||||||
const [verified, setVerified] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchCurrentUser().finally(() => setVerified(true));
|
|
||||||
}, [fetchCurrentUser]);
|
|
||||||
|
|
||||||
// If persisted state shows authenticated, render immediately (background verify)
|
|
||||||
// If not authenticated and not yet verified, show loading
|
|
||||||
if (!isAuthenticated && !verified) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// If verified and not authenticated, the Redirect in (app)/_layout.tsx handles it
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotificationBootstrap() {
|
function NotificationBootstrap() {
|
||||||
const appState = useRef<AppStateStatus>(AppState.currentState);
|
const appState = useRef<AppStateStatus>(AppState.currentState);
|
||||||
const permissionRequested = useRef(false);
|
const permissionRequested = useRef(false);
|
||||||
@@ -146,6 +113,7 @@ function NotificationBootstrap() {
|
|||||||
const { systemNotificationService } = await import('@/services/notification');
|
const { systemNotificationService } = await import('@/services/notification');
|
||||||
await systemNotificationService.initialize();
|
await systemNotificationService.initialize();
|
||||||
const { initBackgroundService } = await import('@/services/background');
|
const { initBackgroundService } = await import('@/services/background');
|
||||||
|
// 默认静默模式,不会注册后台任务,不会触发自启动
|
||||||
await initBackgroundService();
|
await initBackgroundService();
|
||||||
|
|
||||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||||
@@ -279,7 +247,6 @@ function ThemedStack() {
|
|||||||
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
||||||
<SystemChrome />
|
<SystemChrome />
|
||||||
<EventSubscriber />
|
<EventSubscriber />
|
||||||
<SessionGate>
|
|
||||||
<NotificationBootstrap />
|
<NotificationBootstrap />
|
||||||
<APKUpdateBootstrap />
|
<APKUpdateBootstrap />
|
||||||
<CallKeepBootstrap />
|
<CallKeepBootstrap />
|
||||||
@@ -287,8 +254,9 @@ function ThemedStack() {
|
|||||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="(app)" options={{ headerShown: false }} />
|
<Stack.Screen name="(app)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="privacy" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="terms" options={{ headerShown: false }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</SessionGate>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "with_you",
|
"name": "with_you",
|
||||||
"version": "0.0.2",
|
"version": "1.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "with_you",
|
"name": "with_you",
|
||||||
"version": "0.0.2",
|
"version": "1.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/ui": "~56.0.17",
|
"@expo/ui": "~56.0.17",
|
||||||
"@expo/vector-icons": "^15.1.1",
|
"@expo/vector-icons": "^15.1.1",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"expo-camera": "~56.0.8",
|
"expo-camera": "~56.0.8",
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-dev-client": "~56.0.20",
|
"expo-dev-client": "~56.0.20",
|
||||||
|
"expo-document-picker": "~56.0.4",
|
||||||
"expo-file-system": "~56.0.8",
|
"expo-file-system": "~56.0.8",
|
||||||
"expo-font": "~56.0.6",
|
"expo-font": "~56.0.6",
|
||||||
"expo-haptics": "~56.0.3",
|
"expo-haptics": "~56.0.3",
|
||||||
@@ -5530,6 +5531,15 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-document-picker": {
|
||||||
|
"version": "56.0.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/expo-document-picker/-/expo-document-picker-56.0.4.tgz",
|
||||||
|
"integrity": "sha512-75Apf74XNkYYohObIH19VZw42xpe0gmEnPccuzGXKVAzlvTYCfibSgW17F+6vt4paOfZEnAoZ1QFZM6dmaujRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-eas-client": {
|
"node_modules/expo-eas-client": {
|
||||||
"version": "56.0.1",
|
"version": "56.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/expo-eas-client/-/expo-eas-client-56.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/expo-eas-client/-/expo-eas-client-56.0.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "with_you",
|
"name": "with_you",
|
||||||
"version": "0.0.2",
|
"version": "1.0.1",
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"expo-camera": "~56.0.8",
|
"expo-camera": "~56.0.8",
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-dev-client": "~56.0.20",
|
"expo-dev-client": "~56.0.20",
|
||||||
|
"expo-document-picker": "~56.0.4",
|
||||||
"expo-file-system": "~56.0.8",
|
"expo-file-system": "~56.0.8",
|
||||||
"expo-font": "~56.0.6",
|
"expo-font": "~56.0.6",
|
||||||
"expo-haptics": "~56.0.3",
|
"expo-haptics": "~56.0.3",
|
||||||
|
|||||||
40
plugins/withCmakeJobLimit.js
Normal file
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;
|
||||||
205
plugins/withHonorPush.js
Normal file
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;
|
||||||
@@ -9,7 +9,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const withHuaweiPush = (config, options = {}) => {
|
const withHuaweiPush = (config, options = {}) => {
|
||||||
const { jpushVersion = '5.8.0' } = options;
|
const { jpushVersion = '6.1.0' } = options;
|
||||||
|
|
||||||
config = withSettingsGradle(config, (config) => {
|
config = withSettingsGradle(config, (config) => {
|
||||||
const contents = config.modResults.contents;
|
const contents = config.modResults.contents;
|
||||||
|
|||||||
43
plugins/withJcorePatch.js
Normal file
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;
|
||||||
144
plugins/withOppoPush.js
Normal file
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
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;
|
||||||
@@ -35,6 +35,94 @@ const withSigning = (config, options = {}) => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
config = withDangerousMod(config, [
|
||||||
|
'android',
|
||||||
|
async (config) => {
|
||||||
|
const platformRoot = config.modRequest.platformProjectRoot;
|
||||||
|
const buildGradlePath = path.join(platformRoot, 'app', 'build.gradle');
|
||||||
|
|
||||||
|
if (!fs.existsSync(buildGradlePath)) return config;
|
||||||
|
|
||||||
|
let lines = fs.readFileSync(buildGradlePath, 'utf-8').split('\n');
|
||||||
|
|
||||||
|
// 1. Add release signing config that reads from gradle.properties
|
||||||
|
const hasReleaseSigning = lines.some((l) => l.includes('MYAPP_UPLOAD_STORE_FILE'));
|
||||||
|
if (!hasReleaseSigning) {
|
||||||
|
// Find the debug signingConfig closing brace and insert release after it
|
||||||
|
let debugBraceLine = -1;
|
||||||
|
let inSigningConfigs = false;
|
||||||
|
let braceDepth = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.includes('signingConfigs {') || line.match(/^\s*signingConfigs\s*\{/)) {
|
||||||
|
inSigningConfigs = true;
|
||||||
|
braceDepth = 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inSigningConfigs) {
|
||||||
|
braceDepth += (line.match(/\{/g) || []).length;
|
||||||
|
braceDepth -= (line.match(/\}/g) || []).length;
|
||||||
|
if (braceDepth === 0) {
|
||||||
|
debugBraceLine = i; // This is the closing } of signingConfigs
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debugBraceLine > 0) {
|
||||||
|
const releaseBlock = [
|
||||||
|
' release {',
|
||||||
|
" if (project.hasProperty('MYAPP_UPLOAD_STORE_FILE')) {",
|
||||||
|
' storeFile file(MYAPP_UPLOAD_STORE_FILE)',
|
||||||
|
' storePassword MYAPP_UPLOAD_STORE_PASSWORD',
|
||||||
|
' keyAlias MYAPP_UPLOAD_KEY_ALIAS',
|
||||||
|
' keyPassword MYAPP_UPLOAD_KEY_PASSWORD',
|
||||||
|
' }',
|
||||||
|
' }',
|
||||||
|
];
|
||||||
|
lines.splice(debugBraceLine, 0, ...releaseBlock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Change release buildType to use release signingConfig
|
||||||
|
// Walk through buildTypes > release and replace signingConfigs.debug -> signingConfigs.release
|
||||||
|
let inBuildTypes = false;
|
||||||
|
let inRelease = false;
|
||||||
|
let buildTypesDepth = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.includes('buildTypes {') || line.match(/^\s*buildTypes\s*\{/)) {
|
||||||
|
inBuildTypes = true;
|
||||||
|
buildTypesDepth = 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inBuildTypes) {
|
||||||
|
buildTypesDepth += (line.match(/\{/g) || []).length;
|
||||||
|
buildTypesDepth -= (line.match(/\}/g) || []).length;
|
||||||
|
|
||||||
|
if (line.match(/^\s*release\s*\{/)) {
|
||||||
|
inRelease = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inRelease && line.includes('signingConfig signingConfigs.debug')) {
|
||||||
|
lines[i] = line.replace('signingConfig signingConfigs.debug', 'signingConfig signingConfigs.release');
|
||||||
|
inRelease = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buildTypesDepth === 0) {
|
||||||
|
inBuildTypes = false;
|
||||||
|
inRelease = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(buildGradlePath, lines.join('\n'));
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
141
plugins/withVivoPush.js
Normal file
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
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;
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* 检测输入中的 @ 字符,弹出关注者列表供选择
|
* 检测输入中的 @ 字符,弹出关注者列表供选择
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -51,7 +51,12 @@ interface PostMentionInputProps {
|
|||||||
returnKeyType?: 'default' | 'send' | 'done';
|
returnKeyType?: 'default' | 'send' | 'done';
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
export interface PostMentionInputHandle {
|
||||||
|
focus: () => void;
|
||||||
|
blur: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PostMentionInput = forwardRef<PostMentionInputHandle, PostMentionInputProps>(({
|
||||||
value,
|
value,
|
||||||
onChangeText,
|
onChangeText,
|
||||||
onSegmentsChange,
|
onSegmentsChange,
|
||||||
@@ -63,7 +68,7 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
|||||||
onPostRefPasted,
|
onPostRefPasted,
|
||||||
onSubmitEditing,
|
onSubmitEditing,
|
||||||
returnKeyType = 'default',
|
returnKeyType = 'default',
|
||||||
}) => {
|
}, ref) => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const currentUser = useAuthStore(s => s.currentUser);
|
const currentUser = useAuthStore(s => s.currentUser);
|
||||||
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
|
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
|
||||||
@@ -75,6 +80,21 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
|||||||
const [postSearchTimer, setPostSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
const [postSearchTimer, setPostSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
// 暴露 focus / blur 方法给父组件,用于在模式切换(如点击回复)后自动唤起键盘
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
focus: () => {
|
||||||
|
// 展开动画/挂载需要时间,延迟一帧后再聚焦,避免在折叠态未真正展开时调用无效
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
blur: () => {
|
||||||
|
inputRef.current?.blur();
|
||||||
|
},
|
||||||
|
}), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loaded && currentUser?.id) {
|
if (!loaded && currentUser?.id) {
|
||||||
loadFollowing();
|
loadFollowing();
|
||||||
@@ -361,7 +381,7 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export { default as VoteEditor } from './VoteEditor';
|
|||||||
export { default as VotePreview } from './VotePreview';
|
export { default as VotePreview } from './VotePreview';
|
||||||
export { default as PostContentRenderer } from './PostContentRenderer';
|
export { default as PostContentRenderer } from './PostContentRenderer';
|
||||||
export { default as PostMentionInput } from './PostMentionInput';
|
export { default as PostMentionInput } from './PostMentionInput';
|
||||||
|
export type { PostMentionInputHandle } from './PostMentionInput';
|
||||||
export { default as BlockEditor } from './BlockEditor';
|
export { default as BlockEditor } from './BlockEditor';
|
||||||
export type { BlockEditorHandle } from './BlockEditor';
|
export type { BlockEditorHandle } from './BlockEditor';
|
||||||
export { default as ReportDialog } from './ReportDialog';
|
export { default as ReportDialog } from './ReportDialog';
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
let downloaded: InstanceType<typeof File> | null = null;
|
||||||
try {
|
try {
|
||||||
const urlPath = currentImage.url.split('?')[0];
|
const urlPath = currentImage.url.split('?')[0];
|
||||||
const ext = urlPath.split('.').pop()?.toLowerCase() ?? 'jpg';
|
const ext = urlPath.split('.').pop()?.toLowerCase() ?? 'jpg';
|
||||||
@@ -222,13 +223,14 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
const fileName = `withyou_${Date.now()}.${fileExt}`;
|
const fileName = `withyou_${Date.now()}.${fileExt}`;
|
||||||
const destination = new File(Paths.cache, fileName);
|
const destination = new File(Paths.cache, fileName);
|
||||||
|
|
||||||
// File.downloadFileAsync 是新版 expo-file-system/next 的静态方法
|
// File.downloadFileAsync 是新版 expo-file-system 的静态方法
|
||||||
const downloaded = await File.downloadFileAsync(currentImage.url, destination);
|
downloaded = await File.downloadFileAsync(currentImage.url, destination, {
|
||||||
|
idempotent: true,
|
||||||
|
});
|
||||||
|
|
||||||
await MediaLibrary.saveToLibraryAsync(downloaded.uri);
|
// expo-media-library v17+ 已移除顶层 saveToLibraryAsync,改用 Asset.create()
|
||||||
|
// 在 Android 上 filePath 必须以 file:/// 开头
|
||||||
// 清理缓存文件
|
await MediaLibrary.Asset.create(downloaded.uri);
|
||||||
downloaded.delete();
|
|
||||||
|
|
||||||
onSave?.(currentImage.url);
|
onSave?.(currentImage.url);
|
||||||
showToast('success');
|
showToast('success');
|
||||||
@@ -236,6 +238,14 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
console.error('[ImageGallery] 保存图片失败:', err);
|
console.error('[ImageGallery] 保存图片失败:', err);
|
||||||
showToast('error');
|
showToast('error');
|
||||||
} finally {
|
} finally {
|
||||||
|
// 清理缓存文件(无论成功失败都清理,避免残留)
|
||||||
|
if (downloaded) {
|
||||||
|
try {
|
||||||
|
downloaded.delete();
|
||||||
|
} catch (cleanupErr) {
|
||||||
|
console.warn('[ImageGallery] 清理缓存文件失败:', cleanupErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [currentImage, saving, onSave, showToast]);
|
}, [currentImage, saving, onSave, showToast]);
|
||||||
|
|||||||
@@ -236,6 +236,14 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
|
|||||||
const currentState = usePostListStore.getState().getPostsState(listKey);
|
const currentState = usePostListStore.getState().getPostsState(listKey);
|
||||||
syncFromStore(currentState);
|
syncFromStore(currentState);
|
||||||
|
|
||||||
|
// listKey 切换时,如果目标列表在 store 中还没有有效数据(首次访问),
|
||||||
|
// 立即进入 loading 态,避免先渲染空列表再切到 loading 造成闪烁。
|
||||||
|
// syncFromStore 会用 store 的 isLoading 覆盖,因此在其之后补设。
|
||||||
|
// 如果已有数据(曾访问过),则保留旧数据,由后续 fetch 静默刷新。
|
||||||
|
if (!currentState.posts || currentState.posts.length === 0) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
const unsubscribe = usePostListStore.subscribe(state => {
|
const unsubscribe = usePostListStore.subscribe(state => {
|
||||||
const postsState = state.postsStateMap.get(listKey);
|
const postsState = state.postsStateMap.get(listKey);
|
||||||
if (postsState) syncFromStore(postsState);
|
if (postsState) syncFromStore(postsState);
|
||||||
@@ -277,6 +285,18 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
|
|||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
calculatorRef.current?.reset();
|
calculatorRef.current?.reset();
|
||||||
batcherRef.current?.clearPending();
|
batcherRef.current?.clearPending();
|
||||||
|
// 同步清空 store 中对应 listKey 的数据,避免 subscribe 回调
|
||||||
|
// 在切换 listKey 时把上一个 key 的残留数据重新同步回本地造成闪烁
|
||||||
|
usePostListStore.getState().updatePostsState(listKey, {
|
||||||
|
posts: [],
|
||||||
|
cursor: null,
|
||||||
|
currentPage: 1,
|
||||||
|
hasMore: true,
|
||||||
|
isLoading: false,
|
||||||
|
isRefreshing: false,
|
||||||
|
error: null,
|
||||||
|
lastParams: undefined,
|
||||||
|
});
|
||||||
setPosts([]);
|
setPosts([]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
@@ -284,7 +304,7 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
|
|||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
previousPostsRef.current = [];
|
previousPostsRef.current = [];
|
||||||
setDiffUpdates({ addedCount: 0, updatedCount: 0, deletedCount: 0, lastUpdateTime: 0 });
|
setDiffUpdates({ addedCount: 0, updatedCount: 0, deletedCount: 0, lastUpdateTime: 0 });
|
||||||
}, []);
|
}, [listKey]);
|
||||||
|
|
||||||
const forceUpdate = useCallback((newPosts: T[]) => {
|
const forceUpdate = useCallback((newPosts: T[]) => {
|
||||||
setPosts(newPosts);
|
setPosts(newPosts);
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ export function useRegisterPushDevice(isAuthenticated: boolean, userID?: string)
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === 'web' || !isAuthenticated || !userID) return;
|
if (Platform.OS === 'web' || !isAuthenticated || !userID) return;
|
||||||
|
|
||||||
|
// JPush 初始化只取决于登录态——这是基础推送通道(获取 RegistrationID、
|
||||||
|
// 向服务器注册设备 token),与"是否允许后台自启动/保活"无关。
|
||||||
|
// 后台保活由 backgroundService 控制;退后台是否保持 JPush 长连接由
|
||||||
|
// jpushService._setBackgroundKeepLongConn() 根据 autoStart 同意状态自行处理。
|
||||||
|
// 若在此处用 consent 拦截 init,未同意用户将永远拿不到 RegistrationID,
|
||||||
|
// 即便在前台/已授予通知权限也无法收到推送。
|
||||||
if (!deviceRegistered.current) {
|
if (!deviceRegistered.current) {
|
||||||
deviceRegistered.current = true;
|
deviceRegistered.current = true;
|
||||||
|
|
||||||
|
|||||||
@@ -437,7 +437,6 @@ export const HomeScreen: React.FC = () => {
|
|||||||
refreshing: isRefreshing,
|
refreshing: isRefreshing,
|
||||||
hasMore,
|
hasMore,
|
||||||
error,
|
error,
|
||||||
reset,
|
|
||||||
} = useDifferentialPosts<Post>(
|
} = useDifferentialPosts<Post>(
|
||||||
[],
|
[],
|
||||||
{
|
{
|
||||||
@@ -497,8 +496,9 @@ export const HomeScreen: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
reset();
|
// 注意:不再在此处调用 reset()。
|
||||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
// fetchPosts 内部会在参数变化时立即清空 store 中的旧数据并设置 loading,
|
||||||
|
// 这样可以避免本地清空后、请求发起前的间隙渲染到上一个分区的帖子造成闪烁。
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { postService, commentService, authService, showPrompt, voteService } fro
|
|||||||
import { postSyncService } from '@/services/post';
|
import { postSyncService } from '@/services/post';
|
||||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||||
import { CommentItem, VoteCard, ReportDialog, ShareSheet, PostContentRenderer, PostMentionInput } from '../../components/business';
|
import { CommentItem, VoteCard, ReportDialog, ShareSheet, PostContentRenderer, PostMentionInput } from '../../components/business';
|
||||||
|
import type { PostMentionInputHandle } from '../../components/business';
|
||||||
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
|
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
|
||||||
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks';
|
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks';
|
||||||
import {
|
import {
|
||||||
@@ -1271,10 +1272,22 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
// 回复评论
|
// 回复评论
|
||||||
const [replyingTo, setReplyingTo] = useState<Comment | null>(null);
|
const [replyingTo, setReplyingTo] = useState<Comment | null>(null);
|
||||||
const [isComposerVisible, setIsComposerVisible] = useState(false);
|
const [isComposerVisible, setIsComposerVisible] = useState(false);
|
||||||
|
const commentInputRef = useRef<PostMentionInputHandle>(null);
|
||||||
|
|
||||||
|
// 展开编辑器后自动聚焦输入框、唤起键盘,提升回复体验
|
||||||
|
const focusCommentInput = useCallback(() => {
|
||||||
|
// 展开态需要先渲染,延迟一帧再 focus,避免 ref 尚未挂载到真实 TextInput
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
commentInputRef.current?.focus();
|
||||||
|
}, 60);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleReply = (comment: Comment) => {
|
const handleReply = (comment: Comment) => {
|
||||||
setReplyingTo(comment);
|
setReplyingTo(comment);
|
||||||
setIsComposerVisible(true);
|
setIsComposerVisible(true);
|
||||||
|
focusCommentInput();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelReply = () => {
|
const handleCancelReply = () => {
|
||||||
@@ -1363,6 +1376,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
|
|
||||||
const openComposer = () => {
|
const openComposer = () => {
|
||||||
setIsComposerVisible(true);
|
setIsComposerVisible(true);
|
||||||
|
focusCommentInput();
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeComposer = () => {
|
const closeComposer = () => {
|
||||||
@@ -1471,6 +1485,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
|
|
||||||
{/* Text Input —— 小红书风格:无边框、自动扩展 */}
|
{/* Text Input —— 小红书风格:无边框、自动扩展 */}
|
||||||
<PostMentionInput
|
<PostMentionInput
|
||||||
|
ref={commentInputRef}
|
||||||
value={commentText}
|
value={commentText}
|
||||||
onChangeText={setCommentText}
|
onChangeText={setCommentText}
|
||||||
onSegmentsChange={setCommentSegments}
|
onSegmentsChange={setCommentSegments}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
|
Animated,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
@@ -23,7 +24,7 @@ import { useUserStore } from '../../stores';
|
|||||||
import { postService, authService } from '../../services';
|
import { postService, authService } from '../../services';
|
||||||
import { tradeService } from '../../services/trade/tradeService';
|
import { tradeService } from '../../services/trade/tradeService';
|
||||||
import { postSyncService } from '@/services/post';
|
import { postSyncService } from '@/services/post';
|
||||||
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
import { PostCard, SearchBar } from '../../components/business';
|
||||||
import { TradeCard } from '../../components/business/TradeCard/TradeCard';
|
import { TradeCard } from '../../components/business/TradeCard/TradeCard';
|
||||||
import type { PostCardAction } from '../../components/business/PostCard';
|
import type { PostCardAction } from '../../components/business/PostCard';
|
||||||
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
|
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
|
||||||
@@ -94,6 +95,25 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
|||||||
// 保存当前搜索关键词,用于Tab切换时重新搜索
|
// 保存当前搜索关键词,用于Tab切换时重新搜索
|
||||||
const [currentKeyword, setCurrentKeyword] = useState('');
|
const [currentKeyword, setCurrentKeyword] = useState('');
|
||||||
|
|
||||||
|
// 入场动画
|
||||||
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const slideAnim = useRef(new Animated.Value(20)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(fadeAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(slideAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 250,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const searchExtraParams = useMemo(() => ({ query: currentKeyword }), [currentKeyword]);
|
const searchExtraParams = useMemo(() => ({ query: currentKeyword }), [currentKeyword]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -508,10 +528,16 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
|||||||
if (hasSearched) return null;
|
if (hasSearched) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.suggestionsContainer, { paddingHorizontal: responsivePadding }]}>
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.suggestionsContainer,
|
||||||
|
{ paddingHorizontal: responsivePadding },
|
||||||
|
{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }
|
||||||
|
]}
|
||||||
|
>
|
||||||
{/* 搜索历史 */}
|
{/* 搜索历史 */}
|
||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
<View style={[styles.section, { marginTop: responsiveGap }]}>
|
<View style={[styles.section, { marginTop: responsiveGap * 1.5 }]}>
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
<Text
|
<Text
|
||||||
variant="body"
|
variant="body"
|
||||||
@@ -522,7 +548,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
|||||||
>
|
>
|
||||||
搜索历史
|
搜索历史
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity onPress={handleClearHistory}>
|
<TouchableOpacity onPress={handleClearHistory} activeOpacity={0.7}>
|
||||||
<MaterialCommunityIcons name="delete-outline" size={isDesktop ? 22 : 18} color={colors.text.secondary} />
|
<MaterialCommunityIcons name="delete-outline" size={isDesktop ? 22 : 18} color={colors.text.secondary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -540,6 +566,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
onPress={() => handleHistoryPress(keyword)}
|
onPress={() => handleHistoryPress(keyword)}
|
||||||
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons name="history" size={isDesktop ? 16 : 14} color={colors.text.secondary} />
|
<MaterialCommunityIcons name="history" size={isDesktop ? 16 : 14} color={colors.text.secondary} />
|
||||||
<Text
|
<Text
|
||||||
@@ -557,7 +584,18 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 空状态提示 */}
|
||||||
|
{history.length === 0 && (
|
||||||
|
<View style={styles.emptySuggestions}>
|
||||||
|
<EmptyState
|
||||||
|
title="开始搜索"
|
||||||
|
description="输入关键词搜索帖子、用户或商品"
|
||||||
|
icon="magnify"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -581,6 +619,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
|||||||
onSubmit={handleSearch}
|
onSubmit={handleSearch}
|
||||||
placeholder={isMarket ? "搜索商品、用户" : "搜索帖子、用户"}
|
placeholder={isMarket ? "搜索商品、用户" : "搜索帖子、用户"}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -598,20 +637,30 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Tab切换 */}
|
{/* Tab切换 - 与主页风格一致的下划线样式 */}
|
||||||
<View style={styles.tabWrapper}>
|
<View style={styles.tabWrapper}>
|
||||||
<TabBar
|
<View style={[styles.homeTabSwitcher, { paddingHorizontal: responsivePadding }]}>
|
||||||
tabs={TABS}
|
{TABS.map((tab, index) => {
|
||||||
activeIndex={activeIndex}
|
const isActive = activeIndex === index;
|
||||||
onTabChange={(index) => {
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={tab}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={[styles.homeTabItem, isActive && styles.homeTabItemActive]}
|
||||||
|
onPress={() => {
|
||||||
setActiveIndex(index);
|
setActiveIndex(index);
|
||||||
if (currentKeyword && hasSearched) {
|
if (currentKeyword && hasSearched) {
|
||||||
performSearch(currentKeyword);
|
performSearch(currentKeyword);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
variant="modern"
|
>
|
||||||
icons={isMarket ? ['shopping-outline', 'account-outline'] : ['file-document-outline', 'account-outline']}
|
<Text style={isActive ? styles.homeTabTextActive : styles.homeTabText}>
|
||||||
/>
|
{tab}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 内容区域 */}
|
{/* 内容区域 */}
|
||||||
@@ -629,15 +678,12 @@ function createSearchScreenStyles(colors: AppColors) {
|
|||||||
searchHeader: {
|
searchHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.default,
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: `${colors.divider}70`,
|
|
||||||
},
|
},
|
||||||
searchShell: {
|
searchShell: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
cancelButton: {
|
cancelButton: {
|
||||||
backgroundColor: `${colors.primary.main}12`,
|
|
||||||
borderRadius: borderRadius.full,
|
borderRadius: borderRadius.full,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
@@ -647,13 +693,43 @@ function createSearchScreenStyles(colors: AppColors) {
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
tabWrapper: {
|
tabWrapper: {
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.default,
|
||||||
borderBottomWidth: 1,
|
paddingVertical: spacing.xs,
|
||||||
borderBottomColor: `${colors.divider}50`,
|
},
|
||||||
|
homeTabSwitcher: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 20,
|
||||||
|
},
|
||||||
|
homeTabItem: {
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderBottomWidth: 2,
|
||||||
|
borderBottomColor: 'transparent',
|
||||||
|
},
|
||||||
|
homeTabItemActive: {
|
||||||
|
borderBottomColor: colors.text.primary,
|
||||||
|
},
|
||||||
|
homeTabText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '400',
|
||||||
|
color: colors.text.hint,
|
||||||
|
},
|
||||||
|
homeTabTextActive: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.text.primary,
|
||||||
},
|
},
|
||||||
suggestionsContainer: {
|
suggestionsContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
emptySuggestions: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: -40,
|
||||||
|
},
|
||||||
section: {
|
section: {
|
||||||
marginTop: spacing.md,
|
marginTop: spacing.md,
|
||||||
},
|
},
|
||||||
@@ -674,13 +750,14 @@ function createSearchScreenStyles(colors: AppColors) {
|
|||||||
tag: {
|
tag: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: `${colors.primary.main}10`,
|
backgroundColor: colors.background.paper,
|
||||||
borderRadius: borderRadius.full,
|
borderRadius: borderRadius.full,
|
||||||
borderWidth: 0,
|
borderWidth: 1,
|
||||||
|
borderColor: colors.divider,
|
||||||
},
|
},
|
||||||
tagText: {
|
tagText: {
|
||||||
marginLeft: spacing.xs,
|
marginLeft: spacing.xs,
|
||||||
color: colors.primary.main,
|
color: colors.text.secondary,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
userCard: {
|
userCard: {
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
|||||||
longPressMenuVisible,
|
longPressMenuVisible,
|
||||||
selectedMessage,
|
selectedMessage,
|
||||||
selectedMessageId,
|
selectedMessageId,
|
||||||
setSelectedMessageId,
|
|
||||||
menuPosition,
|
menuPosition,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
groupInfo,
|
groupInfo,
|
||||||
@@ -207,7 +206,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
|||||||
handleMentionAll,
|
handleMentionAll,
|
||||||
getSenderInfo,
|
getSenderInfo,
|
||||||
getTypingHint,
|
getTypingHint,
|
||||||
getInputBottom,
|
|
||||||
handleDismiss,
|
handleDismiss,
|
||||||
navigateToInfo,
|
navigateToInfo,
|
||||||
navigateToChatSettings,
|
navigateToChatSettings,
|
||||||
|
|||||||
@@ -476,23 +476,102 @@ const renderVideoSegment = (data: VideoSegmentData, isMe: boolean): React.ReactN
|
|||||||
return <VideoSegment key={`video-${data.url}`} data={data} isMe={isMe} />;
|
return <VideoSegment key={`video-${data.url}`} data={data} isMe={isMe} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 MIME 类型返回文件图标名与主题色
|
||||||
|
*/
|
||||||
|
function getFileVisual(mime?: string): { icon: string; color: string } {
|
||||||
|
if (!mime) return { icon: 'file-document-outline', color: '#5C6BC0' };
|
||||||
|
const m = mime.toLowerCase();
|
||||||
|
if (m === 'application/pdf') return { icon: 'file-pdf-box', color: '#E53935' };
|
||||||
|
if (m.includes('word') || m === 'application/msword') return { icon: 'file-word-box', color: '#1E88E5' };
|
||||||
|
if (m.includes('excel') || m === 'application/vnd.ms-excel' || m === 'text/csv') return { icon: 'file-excel-box', color: '#43A047' };
|
||||||
|
if (m.includes('powerpoint') || m === 'application/vnd.ms-powerpoint') return { icon: 'file-powerpoint-box', color: '#FB8C00' };
|
||||||
|
if (m === 'application/zip' || m.includes('compressed') || m.includes('rar') || m.includes('7z') || m.includes('gzip') || m.includes('tar')) {
|
||||||
|
return { icon: 'folder-zip-outline', color: '#8D6E63' };
|
||||||
|
}
|
||||||
|
if (m.startsWith('audio/')) return { icon: 'file-music-outline', color: '#8E24AA' };
|
||||||
|
if (m.startsWith('video/')) return { icon: 'file-video-outline', color: '#00897B' };
|
||||||
|
if (m.startsWith('image/')) return { icon: 'file-image-outline', color: '#FF6B35' };
|
||||||
|
if (m === 'application/json' || m === 'text/xml' || m === 'application/xml') return { icon: 'code-json', color: '#455A64' };
|
||||||
|
if (m === 'text/plain' || m === 'text/markdown') return { icon: 'file-document-outline', color: '#607D8B' };
|
||||||
|
return { icon: 'file-document-outline', color: '#5C6BC0' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开/下载文件:移动端用浏览器在新页面打开 URL(系统会提示下载/预览),
|
||||||
|
* 此处不再依赖 expo-file-system(SDK 56 已废弃 downloadAsync)。
|
||||||
|
*/
|
||||||
|
async function openRemoteFile(url: string, name: string) {
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
await Linking.openURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('打开文件失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染文件 Segment
|
* 渲染文件 Segment
|
||||||
|
* 当 data.expired 为 true 时显示"文件已过期"失效态,禁用点击。
|
||||||
*/
|
*/
|
||||||
const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ data, isMe }) => {
|
const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ data, isMe }) => {
|
||||||
const styles = useSegmentStyles();
|
const styles = useSegmentStyles();
|
||||||
const themeColors = useAppColors();
|
const themeColors = useAppColors();
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
const fileSize = data.size ? formatFileSize(data.size) : '';
|
const fileSize = data.size ? formatFileSize(data.size) : '';
|
||||||
|
const visual = getFileVisual(data.mime_type);
|
||||||
|
const isExpired = !!data.expired;
|
||||||
|
|
||||||
|
const handlePress = async () => {
|
||||||
|
if (isExpired || !data.url || downloading) return;
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
await openRemoteFile(data.url, data.name || 'file');
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 失效态:灰态卡片 + 时钟图标 + "文件已过期",禁用点击
|
||||||
|
if (isExpired) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[styles.fileContainer, styles.fileExpired, isMe ? styles.fileMe : styles.fileOther]}
|
||||||
|
pointerEvents="none"
|
||||||
|
>
|
||||||
|
<View style={[styles.fileIcon, { backgroundColor: 'rgba(150,150,150,0.15)' }]}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="clock-alert-outline"
|
||||||
|
size={26}
|
||||||
|
color={themeColors.chat.textTertiary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.fileInfo}>
|
||||||
|
<Text
|
||||||
|
style={[styles.fileName, styles.fileExpiredText]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{data.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.fileSize, styles.fileExpiredText]}>
|
||||||
|
文件已过期
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.fileContainer, isMe ? styles.fileMe : styles.fileOther]}
|
style={[styles.fileContainer, isMe ? styles.fileMe : styles.fileOther]}
|
||||||
|
onPress={handlePress}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<View style={styles.fileIcon}>
|
<View style={[styles.fileIcon, { backgroundColor: `${visual.color}22` }]}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="file-document"
|
name={visual.icon as any}
|
||||||
size={28}
|
size={26}
|
||||||
color={isMe ? themeColors.primary.contrast : themeColors.primary.main}
|
color={isMe ? themeColors.primary.contrast : visual.color}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.fileInfo}>
|
<View style={styles.fileInfo}>
|
||||||
@@ -502,8 +581,15 @@ const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ d
|
|||||||
>
|
>
|
||||||
{data.name}
|
{data.name}
|
||||||
</Text>
|
</Text>
|
||||||
{fileSize ? <Text style={styles.fileSize}>{fileSize}</Text> : null}
|
<Text style={styles.fileSize}>
|
||||||
|
{downloading ? '下载中…' : (fileSize || (data.mime_type || '文件'))}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={downloading ? 'progress-download' : 'download-outline'}
|
||||||
|
size={20}
|
||||||
|
color={isMe ? themeColors.primary.contrast : themeColors.chat.textTertiary}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1021,6 +1107,14 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
|
|||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 文件过期失效态
|
||||||
|
fileExpired: {
|
||||||
|
opacity: 0.65,
|
||||||
|
},
|
||||||
|
fileExpiredText: {
|
||||||
|
color: colors.chat.textTertiary,
|
||||||
|
},
|
||||||
|
|
||||||
// 链接 - QQ风格:卡片式设计
|
// 链接 - QQ风格:卡片式设计
|
||||||
linkContainer: {
|
linkContainer: {
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
|
|||||||
@@ -178,13 +178,6 @@ export const MORE_ACTIONS: MoreAction[] = [
|
|||||||
color: '#5C6BC0',
|
color: '#5C6BC0',
|
||||||
gradientColors: ['#7986CB', '#3F51B5'],
|
gradientColors: ['#7986CB', '#3F51B5'],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'location',
|
|
||||||
icon: 'map-marker',
|
|
||||||
name: '位置',
|
|
||||||
color: '#EC407A',
|
|
||||||
gradientColors: ['#F06292', '#D81B60'],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 消息撤回时间限制(毫秒)
|
// 消息撤回时间限制(毫秒)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* ChatScreen 类型定义
|
* ChatScreen 类型定义
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MessageResponse, UserDTO, GroupMemberResponse, GroupResponse } from '../../../../types/dto';
|
import { MessageResponse, UserDTO, GroupMemberResponse } from '../../../../types/dto';
|
||||||
|
|
||||||
// 面板类型
|
// 面板类型
|
||||||
export type PanelType = 'none' | 'emoji' | 'more' | 'mention';
|
export type PanelType = 'none' | 'emoji' | 'more' | 'mention';
|
||||||
@@ -215,39 +215,3 @@ export interface SwipeableMessageBubbleProps {
|
|||||||
onReply: () => void;
|
onReply: () => void;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatScreen 状态接口
|
|
||||||
export interface ChatScreenState {
|
|
||||||
// 基础状态
|
|
||||||
messages: GroupMessage[];
|
|
||||||
conversationId: string | null;
|
|
||||||
inputText: string;
|
|
||||||
otherUser: UserDTO | null;
|
|
||||||
currentUser: UserDTO | null;
|
|
||||||
keyboardHeight: number;
|
|
||||||
loading: boolean;
|
|
||||||
sending: boolean;
|
|
||||||
currentUserId: string;
|
|
||||||
lastSeq: number;
|
|
||||||
otherUserLastReadSeq: number;
|
|
||||||
activePanel: PanelType;
|
|
||||||
sendingImage: boolean;
|
|
||||||
|
|
||||||
// 回复消息状态
|
|
||||||
replyingTo: GroupMessage | null;
|
|
||||||
|
|
||||||
// 长按菜单状态
|
|
||||||
longPressMenuVisible: boolean;
|
|
||||||
selectedMessage: GroupMessage | null;
|
|
||||||
|
|
||||||
// 群聊相关状态
|
|
||||||
groupInfo: GroupResponse | null;
|
|
||||||
groupMembers: GroupMemberResponse[];
|
|
||||||
typingUsers: string[];
|
|
||||||
currentUserRole: UserRole;
|
|
||||||
mentionQuery: string;
|
|
||||||
selectedMentions: string[];
|
|
||||||
mentionAll: boolean;
|
|
||||||
isMuted: boolean;
|
|
||||||
muteAll: boolean;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import {
|
|||||||
import { useLocalSearchParams, router } from 'expo-router';
|
import { useLocalSearchParams, router } from 'expo-router';
|
||||||
import { formatChatTime } from '@/utils/formatTime';
|
import { formatChatTime } from '@/utils/formatTime';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import { GroupMemberResponse, MessageSegment, TextSegmentData, ImageSegmentData, AtSegmentData, ReplySegmentData, MessageStatus } from '../../../../types/dto';
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
|
import { Linking } from 'react-native';
|
||||||
|
import { GroupMemberResponse, MessageSegment, TextSegmentData, ImageSegmentData, FileSegmentData, AtSegmentData, ReplySegmentData, MessageStatus } from '../../../../types/dto';
|
||||||
import { messageService } from '@/services/message';
|
import { messageService } from '@/services/message';
|
||||||
import { uploadService } from '@/services/upload';
|
import { uploadService } from '@/services/upload';
|
||||||
import { ApiError } from '@/services/core';
|
import { ApiError } from '@/services/core';
|
||||||
@@ -181,8 +183,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [lastSeq, setLastSeq] = useState<number>(0);
|
|
||||||
const [firstSeq, setFirstSeq] = useState<number>(0);
|
|
||||||
const [otherUserLastReadSeq, setOtherUserLastReadSeq] = useState<number>(0);
|
const [otherUserLastReadSeq, setOtherUserLastReadSeq] = useState<number>(0);
|
||||||
const [activePanel, setActivePanel] = useState<PanelType>('none');
|
const [activePanel, setActivePanel] = useState<PanelType>('none');
|
||||||
const [sendingImage, setSendingImage] = useState(false);
|
const [sendingImage, setSendingImage] = useState(false);
|
||||||
@@ -196,11 +196,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
// 滚动状态机 refs(Telegram/Element 风格:底部粘附 + 阅读锚点)
|
// 滚动状态机 refs(Telegram/Element 风格:底部粘附 + 阅读锚点)
|
||||||
const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0, viewportHeight: 0 });
|
const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0, viewportHeight: 0 });
|
||||||
const hasInitialAnchorDoneRef = useRef(false);
|
const hasInitialAnchorDoneRef = useRef(false);
|
||||||
const prevMessageCountRef = useRef(0);
|
|
||||||
const prevLatestSeqRef = useRef(0);
|
const prevLatestSeqRef = useRef(0);
|
||||||
const prevMarkedReadSeqRef = useRef(0);
|
const prevMarkedReadSeqRef = useRef(0);
|
||||||
const enterMarkedKeyRef = useRef<string>('');
|
const enterMarkedKeyRef = useRef<string>('');
|
||||||
const isProgrammaticScrollRef = useRef(false);
|
|
||||||
const suppressAutoFollowRef = useRef(false);
|
const suppressAutoFollowRef = useRef(false);
|
||||||
const isBrowsingHistoryRef = useRef(false);
|
const isBrowsingHistoryRef = useRef(false);
|
||||||
const hasShownMessageListRef = useRef(false);
|
const hasShownMessageListRef = useRef(false);
|
||||||
@@ -229,6 +227,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
setGroupInfo(prev => prev ? prev : { name: name || undefined, avatar: avatar || null });
|
setGroupInfo(prev => prev ? prev : { name: name || undefined, avatar: avatar || null });
|
||||||
}
|
}
|
||||||
}, [isGroupChat, effectiveGroupName, effectiveGroupAvatar]);
|
}, [isGroupChat, effectiveGroupName, effectiveGroupAvatar]);
|
||||||
|
|
||||||
const [currentUserRole, setCurrentUserRole] = useState<UserRole>('member');
|
const [currentUserRole, setCurrentUserRole] = useState<UserRole>('member');
|
||||||
const [mentionQuery, setMentionQuery] = useState<string>('');
|
const [mentionQuery, setMentionQuery] = useState<string>('');
|
||||||
const [selectedMentions, setSelectedMentions] = useState<string[]>([]);
|
const [selectedMentions, setSelectedMentions] = useState<string[]>([]);
|
||||||
@@ -369,10 +368,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
};
|
};
|
||||||
}, [isGroupChat, otherUserId]);
|
}, [isGroupChat, otherUserId]);
|
||||||
|
|
||||||
// 进入新会话时重置滚动状态
|
// 进入新会话时重置滚动/草稿状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hasInitialAnchorDoneRef.current = false;
|
hasInitialAnchorDoneRef.current = false;
|
||||||
prevMessageCountRef.current = 0;
|
|
||||||
prevLatestSeqRef.current = 0;
|
prevLatestSeqRef.current = 0;
|
||||||
prevMarkedReadSeqRef.current = 0;
|
prevMarkedReadSeqRef.current = 0;
|
||||||
enterMarkedKeyRef.current = '';
|
enterMarkedKeyRef.current = '';
|
||||||
@@ -381,9 +379,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
hasShownMessageListRef.current = false;
|
hasShownMessageListRef.current = false;
|
||||||
historyLoadingLockUntilRef.current = 0;
|
historyLoadingLockUntilRef.current = 0;
|
||||||
scrollToSeqRef.current = routeScrollToSeq ?? null;
|
scrollToSeqRef.current = routeScrollToSeq ?? null;
|
||||||
}, [conversationId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPendingAttachments([]);
|
setPendingAttachments([]);
|
||||||
}, [conversationId]);
|
}, [conversationId]);
|
||||||
|
|
||||||
@@ -403,14 +398,10 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => {
|
const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => {
|
||||||
if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked())) return;
|
if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked())) return;
|
||||||
// inverted 列表下,最新消息端对应 offset=0
|
// inverted 列表下,最新消息端对应 offset=0
|
||||||
isProgrammaticScrollRef.current = true;
|
|
||||||
flatListRef.current?.scrollToOffset({
|
flatListRef.current?.scrollToOffset({
|
||||||
offset: 0,
|
offset: 0,
|
||||||
animated,
|
animated,
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
|
||||||
isProgrammaticScrollRef.current = false;
|
|
||||||
}, animated ? 220 : 32);
|
|
||||||
}, [flatListRef, isHistoryLoadingLocked]);
|
}, [flatListRef, isHistoryLoadingLocked]);
|
||||||
|
|
||||||
const isNearBottom = useCallback(() => {
|
const isNearBottom = useCallback(() => {
|
||||||
@@ -420,18 +411,23 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
}, [scrollPositionRef]);
|
}, [scrollPositionRef]);
|
||||||
|
|
||||||
// 首屏加载完成后仅锚定一次到最新消息端(有 scrollToSeq 时跳过)
|
// 首屏加载完成后仅锚定一次到最新消息端(有 scrollToSeq 时跳过)
|
||||||
|
// 注意:必须在消息首次出现时立刻把 hasInitialAnchorDoneRef 置为 true,
|
||||||
|
// 不能再依赖 viewportHeight 等通过 ref 同步的值——否则后续首次 loadMoreHistory
|
||||||
|
// 让 messages.length 变化时此 effect 会被再度命中,并以 force=true 调用
|
||||||
|
// scrollToLatest 强行把列表拉回最底端,造成"上滑首次加载更多就回底"的问题。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
loading ||
|
loading ||
|
||||||
loadingMore ||
|
loadingMore ||
|
||||||
messages.length === 0 ||
|
messages.length === 0 ||
|
||||||
hasInitialAnchorDoneRef.current ||
|
hasInitialAnchorDoneRef.current
|
||||||
scrollPositionRef.current.viewportHeight <= 0
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hasInitialAnchorDoneRef.current = true;
|
hasInitialAnchorDoneRef.current = true;
|
||||||
if (scrollToSeqRef.current != null) return; // 由下方 scrollToSeq effect 处理
|
if (scrollToSeqRef.current != null) return; // 由下方 scrollToSeq effect 处理
|
||||||
|
// inverted FlashList 天然从 offset=0(最新端)开始;
|
||||||
|
// 这里仅做一次保险锚定,即使布局尚未就绪也不会有副作用。
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
scrollToLatest(false, true, 'initial-anchor');
|
scrollToLatest(false, true, 'initial-anchor');
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -442,10 +438,8 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
// 仅当"最新端消息 seq 增长"且用户在底部附近时才跟随;
|
// 仅当"最新端消息 seq 增长"且用户在底部附近时才跟随;
|
||||||
// 历史加载只会增加旧消息,不会提升 latest seq,因此不会触发回底。
|
// 历史加载只会增加旧消息,不会提升 latest seq,因此不会触发回底。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentCount = messages.length;
|
const latestSeq = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
|
||||||
const latestSeq = currentCount > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
|
|
||||||
const prevLatestSeq = prevLatestSeqRef.current;
|
const prevLatestSeq = prevLatestSeqRef.current;
|
||||||
prevMessageCountRef.current = currentCount;
|
|
||||||
prevLatestSeqRef.current = latestSeq;
|
prevLatestSeqRef.current = latestSeq;
|
||||||
|
|
||||||
if (loading || loadingMore) return;
|
if (loading || loadingMore) return;
|
||||||
@@ -575,33 +569,20 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
};
|
};
|
||||||
}, [routeUserId, conversationId, currentUserId, isGroupChat]);
|
}, [routeUserId, conversationId, currentUserId, isGroupChat]);
|
||||||
|
|
||||||
// 加载更多历史消息(inverted 下保持阅读锚点)
|
// 加载更多历史消息(inverted + maintainVisibleContentPosition 由列表原生保持位置)
|
||||||
const loadMoreHistory = useCallback(async () => {
|
const loadMoreHistory = useCallback(async () => {
|
||||||
if (!conversationId || !hasMoreHistory || loadingMore) {
|
if (!conversationId || !hasMoreHistory || loadingMore) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 历史加载期间禁止“新消息自动跟随到底”
|
// 历史加载期间禁止"新消息自动跟随到底"
|
||||||
suppressAutoFollowRef.current = true;
|
suppressAutoFollowRef.current = true;
|
||||||
isBrowsingHistoryRef.current = true;
|
isBrowsingHistoryRef.current = true;
|
||||||
historyLoadingLockUntilRef.current = Date.now() + 3000;
|
historyLoadingLockUntilRef.current = Date.now() + 3000;
|
||||||
|
|
||||||
// 保存加载前的滚动位置和内容高度
|
|
||||||
const scrollYBefore = scrollPositionRef.current.scrollY;
|
|
||||||
const contentHeightBefore = scrollPositionRef.current.contentHeight;
|
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loadMoreMessages();
|
await loadMoreMessages();
|
||||||
|
|
||||||
// 更新 firstSeq
|
|
||||||
if (messages.length > 0) {
|
|
||||||
const minSeq = Math.min(...messages.map(m => m.seq));
|
|
||||||
setFirstSeq(minSeq);
|
|
||||||
}
|
|
||||||
|
|
||||||
// inverted + maintainVisibleContentPosition 下由列表原生保持位置
|
|
||||||
// 不做手动 scrollToOffset,避免与原生锚点冲突导致回到底部
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载历史消息失败:', error);
|
console.error('加载历史消息失败:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -609,7 +590,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
// 加载结束后继续保留短暂锁窗,避免布局结算阶段误触自动回底
|
// 加载结束后继续保留短暂锁窗,避免布局结算阶段误触自动回底
|
||||||
historyLoadingLockUntilRef.current = Date.now() + 800;
|
historyLoadingLockUntilRef.current = Date.now() + 800;
|
||||||
}
|
}
|
||||||
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]);
|
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages]);
|
||||||
|
|
||||||
// 从搜索结果跳转:滚动到目标 seq
|
// 从搜索结果跳转:滚动到目标 seq
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -658,13 +639,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
isBrowsingHistoryRef.current = false;
|
isBrowsingHistoryRef.current = false;
|
||||||
// FlashList 在 inverted 模式下 scrollToOffset({ offset: 0 }) 可能无法真正滚到最底部,
|
// FlashList 在 inverted 模式下 scrollToOffset({ offset: 0 }) 可能无法真正滚到最底部,
|
||||||
// 因为列表内容高度变化后最小 offset 可能不是 0。先尝试滚到负值确保到底,再补偿回 0。
|
// 因为列表内容高度变化后最小 offset 可能不是 0。先尝试滚到负值确保到底,再补偿回 0。
|
||||||
isProgrammaticScrollRef.current = true;
|
|
||||||
flatListRef.current?.scrollToOffset({ offset: -99999, animated: false });
|
flatListRef.current?.scrollToOffset({ offset: -99999, animated: false });
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||||
setTimeout(() => {
|
|
||||||
isProgrammaticScrollRef.current = false;
|
|
||||||
}, 220);
|
|
||||||
});
|
});
|
||||||
}, [flatListRef]);
|
}, [flatListRef]);
|
||||||
|
|
||||||
@@ -698,6 +675,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
// 自动标记已读(QQ/Telegram 风格):
|
// 自动标记已读(QQ/Telegram 风格):
|
||||||
// 仅当出现更大的 latest seq,且用户在最新端附近时才上报。
|
// 仅当出现更大的 latest seq,且用户在最新端附近时才上报。
|
||||||
// 历史加载/浏览历史不会触发 read。
|
// 历史加载/浏览历史不会触发 read。
|
||||||
|
// 注意:不能复用 prevLatestSeqRef 做判定——它被新消息跟随 effect 无条件推进,
|
||||||
|
// 会导致这里的去重判断永远成立,标记已读逻辑事实上不会执行。
|
||||||
|
// 这里只依赖专属的 prevMarkedReadSeqRef 做去重。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversationId || messages.length === 0) return;
|
if (!conversationId || messages.length === 0) return;
|
||||||
if (loading || loadingMore) return;
|
if (loading || loadingMore) return;
|
||||||
@@ -707,10 +687,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
const latestSeq = Math.max(...messages.map(m => m.seq), 0);
|
const latestSeq = Math.max(...messages.map(m => m.seq), 0);
|
||||||
if (latestSeq <= 0) return;
|
if (latestSeq <= 0) return;
|
||||||
|
|
||||||
// 没有新增最新消息,不重复上报
|
|
||||||
if (latestSeq <= prevLatestSeqRef.current) return;
|
|
||||||
prevLatestSeqRef.current = latestSeq;
|
|
||||||
|
|
||||||
// 避免对同一 seq 重复标记
|
// 避免对同一 seq 重复标记
|
||||||
if (latestSeq <= prevMarkedReadSeqRef.current) return;
|
if (latestSeq <= prevMarkedReadSeqRef.current) return;
|
||||||
prevMarkedReadSeqRef.current = latestSeq;
|
prevMarkedReadSeqRef.current = latestSeq;
|
||||||
@@ -720,18 +696,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
});
|
});
|
||||||
}, [messages, conversationId, markAsRead, loading, loadingMore, isNearBottom]);
|
}, [messages, conversationId, markAsRead, loading, loadingMore, isNearBottom]);
|
||||||
|
|
||||||
// 使用 ref 存储 groupMembers
|
|
||||||
const groupMembersRef = useRef(groupMembers);
|
|
||||||
useEffect(() => {
|
|
||||||
groupMembersRef.current = groupMembers;
|
|
||||||
}, [groupMembers]);
|
|
||||||
|
|
||||||
// 使用 ref 存储 currentUserId
|
|
||||||
const currentUserIdRef = useRef(currentUserId);
|
|
||||||
useEffect(() => {
|
|
||||||
currentUserIdRef.current = currentUserId;
|
|
||||||
}, [currentUserId]);
|
|
||||||
|
|
||||||
// 监听键盘事件
|
// 监听键盘事件
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const keyboardWillShow = (e: KeyboardEvent) => {
|
const keyboardWillShow = (e: KeyboardEvent) => {
|
||||||
@@ -1049,6 +1013,38 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
return segments;
|
return segments;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建文件消息的 segments 数组
|
||||||
|
*/
|
||||||
|
const buildFileSegments = useCallback((
|
||||||
|
fileUrl: string,
|
||||||
|
name: string,
|
||||||
|
size?: number,
|
||||||
|
mimeType?: string,
|
||||||
|
replyToMessage?: GroupMessage | null,
|
||||||
|
): MessageSegment[] => {
|
||||||
|
const segments: MessageSegment[] = [];
|
||||||
|
|
||||||
|
if (replyToMessage) {
|
||||||
|
segments.push({
|
||||||
|
type: 'reply',
|
||||||
|
data: { id: replyToMessage.id, seq: replyToMessage.seq } as ReplySegmentData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
segments.push({
|
||||||
|
type: 'file',
|
||||||
|
data: {
|
||||||
|
url: fileUrl,
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
mime_type: mimeType,
|
||||||
|
} as FileSegmentData
|
||||||
|
});
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 【新架构】发送消息(支持纯文字、纯多图、图文同条)
|
// 【新架构】发送消息(支持纯文字、纯多图、图文同条)
|
||||||
const handleSend = useCallback(async () => {
|
const handleSend = useCallback(async () => {
|
||||||
const trimmedText = inputText.trim();
|
const trimmedText = inputText.trim();
|
||||||
@@ -1253,6 +1249,89 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
setActivePanel('none');
|
setActivePanel('none');
|
||||||
}, [pendingAttachments.length]);
|
}, [pendingAttachments.length]);
|
||||||
|
|
||||||
|
// 选择并发送文件(单选,立即上传后发送)
|
||||||
|
const handlePickFile = useCallback(async () => {
|
||||||
|
if (!conversationId) {
|
||||||
|
setActivePanel('none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGroupChat && isMuted) {
|
||||||
|
Alert.alert('无法发送', muteAll ? '当前群组已开启全员禁言' : '你已被管理员禁言');
|
||||||
|
setActivePanel('none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActivePanel('none');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
|
multiple: false,
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
});
|
||||||
|
if (result.canceled || !result.assets || result.assets.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = result.assets[0];
|
||||||
|
setUploadingAttachments(true);
|
||||||
|
|
||||||
|
const uploaded = await uploadService.uploadFile(
|
||||||
|
{
|
||||||
|
uri: asset.uri,
|
||||||
|
name: asset.name,
|
||||||
|
type: asset.mimeType || undefined,
|
||||||
|
},
|
||||||
|
'chat'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!uploaded?.url) {
|
||||||
|
Alert.alert('上传失败', getSendErrorMessage(null, '文件上传失败,请重试'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = buildFileSegments(
|
||||||
|
uploaded.url,
|
||||||
|
uploaded.name || asset.name,
|
||||||
|
uploaded.size ?? asset.size,
|
||||||
|
uploaded.mime_type ?? asset.mimeType,
|
||||||
|
replyingTo,
|
||||||
|
);
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
if (isGroupChat && effectiveGroupId) {
|
||||||
|
await messageService.sendMessageByAction('group', conversationId, segments);
|
||||||
|
} else {
|
||||||
|
await sendMessageViaManager(segments);
|
||||||
|
}
|
||||||
|
setReplyingTo(null);
|
||||||
|
setTimeout(() => scrollToLatest(false, false, 'send-file'), 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送文件消息失败:', error);
|
||||||
|
Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试'));
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('选择文件失败:', error);
|
||||||
|
Alert.alert('错误', getSendErrorMessage(error, '选择文件失败'));
|
||||||
|
} finally {
|
||||||
|
setUploadingAttachments(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
conversationId,
|
||||||
|
isGroupChat,
|
||||||
|
isMuted,
|
||||||
|
muteAll,
|
||||||
|
effectiveGroupId,
|
||||||
|
replyingTo,
|
||||||
|
buildFileSegments,
|
||||||
|
sendMessageViaManager,
|
||||||
|
scrollToLatest,
|
||||||
|
getSendErrorMessage,
|
||||||
|
]);
|
||||||
|
|
||||||
// 处理更多功能
|
// 处理更多功能
|
||||||
const handleMoreAction = useCallback((actionId: string) => {
|
const handleMoreAction = useCallback((actionId: string) => {
|
||||||
switch (actionId) {
|
switch (actionId) {
|
||||||
@@ -1281,17 +1360,12 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
handleTakePhoto();
|
handleTakePhoto();
|
||||||
break;
|
break;
|
||||||
case 'file':
|
case 'file':
|
||||||
Alert.alert('提示', '文件功能即将上线');
|
handlePickFile();
|
||||||
setActivePanel('none');
|
|
||||||
break;
|
|
||||||
case 'location':
|
|
||||||
Alert.alert('提示', '位置功能即将上线');
|
|
||||||
setActivePanel('none');
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
setActivePanel('none');
|
setActivePanel('none');
|
||||||
}
|
}
|
||||||
}, [handlePickImage, handleTakePhoto, isGroupChat, otherUser, conversationId]);
|
}, [handlePickImage, handleTakePhoto, handlePickFile, isGroupChat, otherUser, conversationId]);
|
||||||
|
|
||||||
// 插入表情
|
// 插入表情
|
||||||
const handleInsertEmoji = useCallback((emoji: string) => {
|
const handleInsertEmoji = useCallback((emoji: string) => {
|
||||||
@@ -1364,20 +1438,15 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
setActivePanel('none');
|
setActivePanel('none');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 撤回消息 - 现在通过 MessageManager 处理
|
// 撤回消息 - 现在通过 MessageManager 处理(撤回事件由 MessageManager 同步状态)
|
||||||
const handleRecall = useCallback(async (messageId: string) => {
|
const handleRecall = useCallback(async (messageId: string) => {
|
||||||
try {
|
try {
|
||||||
if (isGroupChat && effectiveGroupId) {
|
|
||||||
await messageService.recallMessage(messageId);
|
await messageService.recallMessage(messageId);
|
||||||
} else {
|
|
||||||
await messageService.recallMessage(messageId);
|
|
||||||
}
|
|
||||||
// 不需要手动更新状态,MessageManager 会处理撤回事件
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('撤回消息失败:', error);
|
console.error('撤回消息失败:', error);
|
||||||
Alert.alert('撤回失败', '无法撤回消息');
|
Alert.alert('撤回失败', '无法撤回消息');
|
||||||
}
|
}
|
||||||
}, [isGroupChat, effectiveGroupId, conversationId]);
|
}, []);
|
||||||
|
|
||||||
// 长按消息显示操作菜单
|
// 长按消息显示操作菜单
|
||||||
const handleLongPressMessage = useCallback((message: GroupMessage, position?: MenuPosition) => {
|
const handleLongPressMessage = useCallback((message: GroupMessage, position?: MenuPosition) => {
|
||||||
@@ -1416,8 +1485,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
if (!conversationId) return;
|
if (!conversationId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLastSeq(0);
|
|
||||||
setFirstSeq(0);
|
|
||||||
setHasMoreHistory(true);
|
setHasMoreHistory(true);
|
||||||
await messageRepository.clearConversation(conversationId);
|
await messageRepository.clearConversation(conversationId);
|
||||||
// 刷新消息列表
|
// 刷新消息列表
|
||||||
@@ -1426,9 +1493,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
console.error('清空会话失败:', error);
|
console.error('清空会话失败:', error);
|
||||||
Alert.alert('清空失败', '无法清空聊天记录');
|
Alert.alert('清空失败', '无法清空聊天记录');
|
||||||
}
|
}
|
||||||
}, [conversationId, refreshMessages]);
|
}, [conversationId, refreshMessages]); // 回复消息
|
||||||
|
|
||||||
// 回复消息
|
|
||||||
const handleReplyMessage = useCallback((message: GroupMessage) => {
|
const handleReplyMessage = useCallback((message: GroupMessage) => {
|
||||||
setReplyingTo(message);
|
setReplyingTo(message);
|
||||||
textInputRef.current?.focus();
|
textInputRef.current?.focus();
|
||||||
@@ -1481,26 +1546,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
}
|
}
|
||||||
}, [groupTypingUsers, groupMembers]);
|
}, [groupTypingUsers, groupMembers]);
|
||||||
|
|
||||||
// 计算底部面板高度
|
|
||||||
const getPanelHeight = useCallback(() => {
|
|
||||||
if (activePanel === 'none') return 0;
|
|
||||||
if (activePanel === 'mention') return 250;
|
|
||||||
return 350;
|
|
||||||
}, [activePanel]);
|
|
||||||
|
|
||||||
// 计算输入框的bottom值
|
|
||||||
const getInputBottom = useCallback(() => {
|
|
||||||
if (keyboardHeight > 0) return keyboardHeight;
|
|
||||||
if (activePanel !== 'none') return getPanelHeight();
|
|
||||||
return 0;
|
|
||||||
}, [keyboardHeight, activePanel, getPanelHeight]);
|
|
||||||
|
|
||||||
// 计算消息列表的底部padding
|
|
||||||
const getListPaddingBottom = useCallback(() => {
|
|
||||||
if (activePanel !== 'none' && keyboardHeight === 0) return getPanelHeight();
|
|
||||||
return 0;
|
|
||||||
}, [keyboardHeight, activePanel, getPanelHeight]);
|
|
||||||
|
|
||||||
// 关闭所有面板
|
// 关闭所有面板
|
||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
Keyboard.dismiss();
|
Keyboard.dismiss();
|
||||||
@@ -1544,7 +1589,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
currentUserId,
|
currentUserId,
|
||||||
keyboardHeight,
|
keyboardHeight,
|
||||||
loading,
|
loading,
|
||||||
sending,
|
|
||||||
activePanel,
|
activePanel,
|
||||||
sendingImage,
|
sendingImage,
|
||||||
uploadingAttachments,
|
uploadingAttachments,
|
||||||
@@ -1554,16 +1598,12 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
longPressMenuVisible,
|
longPressMenuVisible,
|
||||||
selectedMessage,
|
selectedMessage,
|
||||||
selectedMessageId,
|
selectedMessageId,
|
||||||
setSelectedMessageId,
|
|
||||||
menuPosition,
|
menuPosition,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
groupInfo,
|
groupInfo,
|
||||||
groupMembers,
|
groupMembers,
|
||||||
// 【改造】使用 MessageManager 的输入状态
|
|
||||||
typingUsers: groupTypingUsers,
|
|
||||||
currentUserRole,
|
currentUserRole,
|
||||||
mentionQuery,
|
mentionQuery,
|
||||||
selectedMentions,
|
|
||||||
isMuted,
|
isMuted,
|
||||||
muteAll,
|
muteAll,
|
||||||
followRestrictionHint,
|
followRestrictionHint,
|
||||||
@@ -1585,9 +1625,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
shouldShowTime,
|
shouldShowTime,
|
||||||
handleInputChange,
|
handleInputChange,
|
||||||
handleSend,
|
handleSend,
|
||||||
handlePickImage,
|
|
||||||
removePendingAttachment,
|
removePendingAttachment,
|
||||||
handleTakePhoto,
|
|
||||||
handleMoreAction,
|
handleMoreAction,
|
||||||
handleInsertEmoji,
|
handleInsertEmoji,
|
||||||
handleSendSticker,
|
handleSendSticker,
|
||||||
@@ -1606,9 +1644,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
handleMentionAll,
|
handleMentionAll,
|
||||||
getSenderInfo,
|
getSenderInfo,
|
||||||
getTypingHint,
|
getTypingHint,
|
||||||
getPanelHeight,
|
|
||||||
getInputBottom,
|
|
||||||
getListPaddingBottom,
|
|
||||||
handleDismiss,
|
handleDismiss,
|
||||||
navigateToInfo,
|
navigateToInfo,
|
||||||
navigateToChatSettings,
|
navigateToChatSettings,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -13,7 +15,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
|||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { authService } from '../../services';
|
import { authService } from '../../services';
|
||||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
|
import { spacing, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||||
import { Text, SimpleHeader } from '../../components/common';
|
import { Text, SimpleHeader } from '../../components/common';
|
||||||
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
||||||
import { useAuthStore } from '../../stores';
|
import { useAuthStore } from '../../stores';
|
||||||
@@ -33,6 +35,9 @@ function createAccountDeletionStyles(colors: AppColors) {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
keyboardView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
paddingVertical: spacing.lg,
|
paddingVertical: spacing.lg,
|
||||||
},
|
},
|
||||||
@@ -41,53 +46,153 @@ function createAccountDeletionStyles(colors: AppColors) {
|
|||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
},
|
},
|
||||||
section: {
|
|
||||||
marginBottom: spacing['2xl'],
|
// 顶部叙述区:左对齐、有呼吸感
|
||||||
|
heroSection: {
|
||||||
|
paddingHorizontal: spacing['2xl'],
|
||||||
|
paddingTop: spacing.md,
|
||||||
|
paddingBottom: spacing.xl,
|
||||||
},
|
},
|
||||||
// 警告卡片 - 扁平化风格
|
heroEyebrow: {
|
||||||
warningCard: {
|
fontSize: 13,
|
||||||
backgroundColor: colors.error.light + '20',
|
color: colors.text.hint,
|
||||||
borderRadius: 14,
|
letterSpacing: 1,
|
||||||
padding: spacing.lg,
|
|
||||||
marginHorizontal: spacing['2xl'],
|
|
||||||
marginBottom: spacing.xl,
|
|
||||||
},
|
|
||||||
warningTitle: {
|
|
||||||
fontWeight: '700',
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
color: colors.error.main,
|
|
||||||
},
|
},
|
||||||
warningText: {
|
heroTitle: {
|
||||||
color: colors.error.dark,
|
fontSize: 24,
|
||||||
marginBottom: spacing.xs,
|
|
||||||
fontSize: fontSizes.sm,
|
|
||||||
},
|
|
||||||
// 状态卡片 - 扁平化风格
|
|
||||||
statusCard: {
|
|
||||||
backgroundColor: colors.warning.light + '30',
|
|
||||||
borderRadius: 14,
|
|
||||||
padding: spacing.lg,
|
|
||||||
marginHorizontal: spacing['2xl'],
|
|
||||||
marginBottom: spacing.xl,
|
|
||||||
},
|
|
||||||
statusTitle: {
|
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
fontSize: fontSizes.md,
|
color: colors.text.primary,
|
||||||
|
lineHeight: 32,
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
color: colors.warning.dark,
|
|
||||||
},
|
},
|
||||||
daysText: {
|
heroDesc: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 倒计时区:圆环 + 数字 + 文案,替代"大数字 + 标签"卡片
|
||||||
|
countdownSection: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing['2xl'],
|
||||||
|
paddingVertical: spacing.lg,
|
||||||
|
},
|
||||||
|
countdownRing: {
|
||||||
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
borderRadius: 44,
|
||||||
|
borderWidth: 4,
|
||||||
|
borderColor: colors.warning.main,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: spacing.lg,
|
||||||
|
},
|
||||||
|
countdownNumber: {
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: colors.warning.dark,
|
color: colors.warning.dark,
|
||||||
textAlign: 'center',
|
lineHeight: 36,
|
||||||
marginVertical: spacing.md,
|
|
||||||
},
|
},
|
||||||
// 内容区域
|
countdownUnit: {
|
||||||
sectionContent: {
|
fontSize: 11,
|
||||||
marginHorizontal: spacing['2xl'],
|
color: colors.text.secondary,
|
||||||
marginBottom: spacing.xl,
|
marginTop: -2,
|
||||||
|
},
|
||||||
|
countdownTextWrap: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
countdownTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
countdownDesc: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: 19,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 引导文 + 列表(无背景、无圆角,自然排版)
|
||||||
|
guideSection: {
|
||||||
|
paddingHorizontal: spacing['2xl'],
|
||||||
|
paddingTop: spacing.lg,
|
||||||
|
},
|
||||||
|
guideLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
guideItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
guideIcon: {
|
||||||
|
marginRight: spacing.md,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
guideTextWrap: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
guideTitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
guideDesc: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: 19,
|
||||||
|
},
|
||||||
|
guideDivider: {
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
backgroundColor: colors.divider,
|
||||||
|
marginLeft: spacing['2xl'] + 22 + spacing.md,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 注意事项(无填色,仅左侧色条)
|
||||||
|
noticeSection: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
paddingHorizontal: spacing['2xl'],
|
||||||
|
paddingTop: spacing.lg,
|
||||||
|
paddingBottom: spacing.md,
|
||||||
|
},
|
||||||
|
noticeBar: {
|
||||||
|
width: 3,
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
backgroundColor: colors.error.main,
|
||||||
|
borderRadius: 2,
|
||||||
|
marginRight: spacing.md,
|
||||||
|
},
|
||||||
|
noticeTextWrap: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
noticeTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.error.main,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
noticeDesc: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 表单区(与 AccountSecurity 风格一致:分节标题 + 输入框)
|
||||||
|
formSection: {
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
paddingHorizontal: spacing['2xl'],
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
marginTop: spacing.sm,
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
@@ -95,47 +200,40 @@ function createAccountDeletionStyles(colors: AppColors) {
|
|||||||
color: colors.text.secondary,
|
color: colors.text.secondary,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
marginBottom: spacing.md,
|
|
||||||
},
|
},
|
||||||
listItem: {
|
|
||||||
|
// 输入框
|
||||||
|
inputWrapper: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'center',
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
listBullet: {
|
|
||||||
marginRight: spacing.sm,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
// 输入框 - 扁平化风格
|
|
||||||
input: {
|
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: colors.background.default,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
padding: spacing.md,
|
paddingHorizontal: spacing.lg,
|
||||||
fontSize: 16,
|
height: 56,
|
||||||
|
marginHorizontal: spacing['2xl'],
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
inputIcon: {
|
||||||
|
marginRight: spacing.sm,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
color: colors.text.primary,
|
color: colors.text.primary,
|
||||||
|
fontSize: fontSizes.md,
|
||||||
height: 56,
|
height: 56,
|
||||||
},
|
},
|
||||||
// 按钮行
|
eyeButton: {
|
||||||
|
padding: 4,
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 按钮行:次按钮在左、危险按钮在右,与全站保持一致
|
||||||
buttonRow: {
|
buttonRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
marginTop: spacing.lg,
|
marginTop: spacing.lg,
|
||||||
marginHorizontal: spacing['2xl'],
|
marginHorizontal: spacing['2xl'],
|
||||||
},
|
},
|
||||||
// 扁平化按钮
|
|
||||||
primaryButton: {
|
|
||||||
flex: 1,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 14,
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
primaryButtonText: {
|
|
||||||
color: colors.text.inverse,
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
secondaryButton: {
|
secondaryButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: 56,
|
height: 56,
|
||||||
@@ -165,11 +263,38 @@ function createAccountDeletionStyles(colors: AppColors) {
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
buttonDisabled: {
|
buttonDisabled: {
|
||||||
opacity: 0.6,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
cancelButton: {
|
primaryButton: {
|
||||||
marginTop: spacing.lg,
|
height: 56,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: colors.primary.main,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
marginHorizontal: spacing['2xl'],
|
marginHorizontal: spacing['2xl'],
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
primaryButtonText: {
|
||||||
|
color: colors.text.inverse,
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
|
||||||
|
// 页脚:联系客服
|
||||||
|
footer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: spacing.xl,
|
||||||
|
paddingHorizontal: spacing['2xl'],
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.text.hint,
|
||||||
|
lineHeight: 20,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
footerLink: {
|
||||||
|
color: colors.primary.main,
|
||||||
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -185,9 +310,9 @@ export const AccountDeletionScreen: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md;
|
const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md;
|
||||||
|
|
||||||
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
||||||
|
|
||||||
const loadStatus = useCallback(async () => {
|
const loadStatus = useCallback(async () => {
|
||||||
@@ -284,95 +409,206 @@ export const AccountDeletionScreen: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 待注销状态
|
||||||
|
if (status?.is_pending_deletion) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="auto" />
|
||||||
<SimpleHeader title="注销账号" onBack={() => router.back()} />
|
<SimpleHeader title="注销账号" onBack={() => router.back()} />
|
||||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
<ScrollView
|
||||||
<View style={styles.content}>
|
contentContainerStyle={[
|
||||||
{status?.is_pending_deletion ? (
|
styles.scrollContent,
|
||||||
<View style={styles.section}>
|
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
||||||
<View style={styles.statusCard}>
|
]}
|
||||||
<Text variant="body" style={styles.statusTitle}>
|
showsVerticalScrollIndicator={false}
|
||||||
账号注销申请中
|
|
||||||
</Text>
|
|
||||||
<Text variant="body" color={colors.text.secondary}>
|
|
||||||
您的账号将在以下天数后永久删除:
|
|
||||||
</Text>
|
|
||||||
<Text variant="body" style={styles.daysText}>
|
|
||||||
{status.cool_down_days || 90} 天
|
|
||||||
</Text>
|
|
||||||
<Text variant="caption" color={colors.text.secondary}>
|
|
||||||
在此期间,您可以通过重新登录或点击下方按钮来取消注销申请。
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.secondaryButton, styles.cancelButton, submitting && styles.buttonDisabled]}
|
|
||||||
onPress={handleCancelDeletion}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
>
|
||||||
{submitting ? (
|
<View style={styles.content}>
|
||||||
<ActivityIndicator size="small" color={colors.text.primary} />
|
{/* 顶部叙述 */}
|
||||||
) : (
|
<View style={styles.heroSection}>
|
||||||
<Text style={styles.secondaryButtonText}>取消注销申请</Text>
|
<Text style={styles.heroEyebrow}>ACCOUNT · 注销申请中</Text>
|
||||||
)}
|
<Text style={styles.heroTitle}>我们将在倒计时结束后清除你的账号</Text>
|
||||||
</TouchableOpacity>
|
<Text style={styles.heroDesc}>
|
||||||
</View>
|
在此期间,你随时可以撤销申请,账号会立即恢复正常使用。
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View style={styles.section}>
|
|
||||||
{/* 警告卡片 */}
|
|
||||||
<View style={styles.warningCard}>
|
|
||||||
<Text variant="body" style={styles.warningTitle}>
|
|
||||||
警告:账号注销后将无法恢复
|
|
||||||
</Text>
|
|
||||||
<Text variant="body" style={styles.warningText}>
|
|
||||||
注销后,您的账号将在90天后永久删除。
|
|
||||||
</Text>
|
|
||||||
<Text variant="body" style={styles.warningText}>
|
|
||||||
在此期间,您可以通过重新登录来取消注销。
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 注销说明 */}
|
{/* 倒计时 */}
|
||||||
<View style={styles.sectionContent}>
|
<View style={styles.countdownSection}>
|
||||||
<Text style={styles.sectionTitle}>注销后将发生什么</Text>
|
<View style={styles.countdownRing}>
|
||||||
<View style={styles.listItem}>
|
<Text style={styles.countdownNumber}>{status.cool_down_days || 90}</Text>
|
||||||
<MaterialCommunityIcons name="close-circle" size={16} color={colors.error.main} style={styles.listBullet} />
|
<Text style={styles.countdownUnit}>天</Text>
|
||||||
<Text variant="body" color={colors.text.secondary}>
|
</View>
|
||||||
您的个人资料将被删除
|
<View style={styles.countdownTextWrap}>
|
||||||
|
<Text style={styles.countdownTitle}>距离永久删除</Text>
|
||||||
|
<Text style={styles.countdownDesc}>
|
||||||
|
再次登录或点击下方按钮可立即取消注销。
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.listItem}>
|
</View>
|
||||||
<MaterialCommunityIcons name="information" size={16} color={colors.warning.main} style={styles.listBullet} />
|
|
||||||
<Text variant="body" color={colors.text.secondary}>
|
{/* 取消按钮 */}
|
||||||
您发布的帖子、评论将保留,但显示为「已注销用户」
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.primaryButton,
|
||||||
|
submitting && styles.buttonDisabled,
|
||||||
|
]}
|
||||||
|
onPress={handleCancelDeletion}
|
||||||
|
disabled={submitting}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.text.inverse} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.primaryButtonText}>撤销注销申请</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* 页脚 */}
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Text style={styles.footerText}>
|
||||||
|
如有疑虑可联系<Text style={styles.footerLink}> 客服 </Text>获取帮助
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.listItem}>
|
</View>
|
||||||
<MaterialCommunityIcons name="close-circle" size={16} color={colors.error.main} style={styles.listBullet} />
|
</ScrollView>
|
||||||
<Text variant="body" color={colors.text.secondary}>
|
</SafeAreaView>
|
||||||
您的关注、粉丝关系将被清除
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常状态:申请注销
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
<SimpleHeader title="注销账号" onBack={() => router.back()} />
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
style={styles.keyboardView}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.scrollContent,
|
||||||
|
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
||||||
|
]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* 顶部叙述 */}
|
||||||
|
<View style={styles.heroSection}>
|
||||||
|
<Text style={styles.heroEyebrow}>ACCOUNT · 注销</Text>
|
||||||
|
<Text style={styles.heroTitle}>在离开之前,我们想让你知道这些</Text>
|
||||||
|
<Text style={styles.heroDesc}>
|
||||||
|
注销并非立即生效,提交后你有 90 天的冷静期,反悔了随时可以回来。
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.listItem}>
|
|
||||||
<MaterialCommunityIcons name="close-circle" size={16} color={colors.error.main} style={styles.listBullet} />
|
{/* 注销影响 - 列表式(无背景) */}
|
||||||
<Text variant="body" color={colors.text.secondary}>
|
<View style={styles.guideSection}>
|
||||||
您的收藏、点赞记录将被删除
|
<Text style={styles.guideLabel}>注销后会发生什么</Text>
|
||||||
|
|
||||||
|
<View style={styles.guideItem}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="account-remove-outline"
|
||||||
|
size={22}
|
||||||
|
color={colors.error.main}
|
||||||
|
style={styles.guideIcon}
|
||||||
|
/>
|
||||||
|
<View style={styles.guideTextWrap}>
|
||||||
|
<Text style={styles.guideTitle}>个人资料会被清除</Text>
|
||||||
|
<Text style={styles.guideDesc}>头像、昵称、简介等所有个人信息都将被永久删除。</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.guideDivider} />
|
||||||
|
|
||||||
|
<View style={styles.guideItem}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="message-text-outline"
|
||||||
|
size={22}
|
||||||
|
color={colors.warning.main}
|
||||||
|
style={styles.guideIcon}
|
||||||
|
/>
|
||||||
|
<View style={styles.guideTextWrap}>
|
||||||
|
<Text style={styles.guideTitle}>历史内容会保留</Text>
|
||||||
|
<Text style={styles.guideDesc}>你发布的帖子、评论将保留,但作者会显示为「已注销用户」。</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.guideDivider} />
|
||||||
|
|
||||||
|
<View style={styles.guideItem}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="heart-broken-outline"
|
||||||
|
size={22}
|
||||||
|
color={colors.error.main}
|
||||||
|
style={styles.guideIcon}
|
||||||
|
/>
|
||||||
|
<View style={styles.guideTextWrap}>
|
||||||
|
<Text style={styles.guideTitle}>关注关系被解绑</Text>
|
||||||
|
<Text style={styles.guideDesc}>你关注的人、粉丝、收藏、点赞等社交关系会被一并清除。</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.guideDivider} />
|
||||||
|
|
||||||
|
<View style={styles.guideItem}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="clock-time-four-outline"
|
||||||
|
size={22}
|
||||||
|
color={colors.text.secondary}
|
||||||
|
style={styles.guideIcon}
|
||||||
|
/>
|
||||||
|
<View style={styles.guideTextWrap}>
|
||||||
|
<Text style={styles.guideTitle}>90 天冷静期</Text>
|
||||||
|
<Text style={styles.guideDesc}>期间重新登录即可撤销申请,账号会立刻恢复。</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 红色提示 - 左侧细线代替大色块 */}
|
||||||
|
<View style={styles.noticeSection}>
|
||||||
|
<View style={styles.noticeBar} />
|
||||||
|
<View style={styles.noticeTextWrap}>
|
||||||
|
<Text style={styles.noticeTitle}>这是不可恢复的操作</Text>
|
||||||
|
<Text style={styles.noticeDesc}>
|
||||||
|
90 天后所有数据将被永久删除,届时无法通过任何方式找回。请确认你已备份好需要保留的内容。
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 密码确认 */}
|
{/* 密码确认 */}
|
||||||
<View style={styles.sectionContent}>
|
<View style={styles.formSection}>
|
||||||
<Text style={styles.sectionTitle}>请输入密码确认</Text>
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text variant="caption" style={styles.sectionTitle}>
|
||||||
|
身份验证
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputWrapper}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="lock-outline"
|
||||||
|
size={20}
|
||||||
|
color={colors.text.secondary}
|
||||||
|
style={styles.inputIcon}
|
||||||
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="请输入密码"
|
placeholder="请输入登录密码以确认"
|
||||||
placeholderTextColor={colors.text.hint}
|
placeholderTextColor={colors.text.hint}
|
||||||
secureTextEntry
|
secureTextEntry={!showPassword}
|
||||||
value={password}
|
value={password}
|
||||||
onChangeText={setPassword}
|
onChangeText={setPassword}
|
||||||
|
autoCapitalize="none"
|
||||||
|
returnKeyType="done"
|
||||||
/>
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowPassword(!showPassword)}
|
||||||
|
style={styles.eyeButton}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||||
|
size={20}
|
||||||
|
color={colors.text.hint}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 按钮行 */}
|
{/* 按钮行 */}
|
||||||
@@ -380,25 +616,37 @@ export const AccountDeletionScreen: React.FC = () => {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.secondaryButton}
|
style={styles.secondaryButton}
|
||||||
onPress={() => router.back()}
|
onPress={() => router.back()}
|
||||||
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Text style={styles.secondaryButtonText}>返回</Text>
|
<Text style={styles.secondaryButtonText}>再想想</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.dangerButton, submitting && styles.buttonDisabled]}
|
style={[
|
||||||
|
styles.dangerButton,
|
||||||
|
(submitting || !password.trim()) && styles.buttonDisabled,
|
||||||
|
]}
|
||||||
onPress={handleRequestDeletion}
|
onPress={handleRequestDeletion}
|
||||||
disabled={submitting || !password.trim()}
|
disabled={submitting || !password.trim()}
|
||||||
|
activeOpacity={0.9}
|
||||||
>
|
>
|
||||||
{submitting ? (
|
{submitting ? (
|
||||||
<ActivityIndicator size="small" color={colors.text.inverse} />
|
<ActivityIndicator size="small" color={colors.text.inverse} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.dangerButtonText}>确认注销</Text>
|
<Text style={styles.dangerButtonText}>确认申请注销</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 页脚 */}
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Text style={styles.footerText}>
|
||||||
|
遇到问题?可以联系<Text style={styles.footerLink}> 客服 </Text>
|
||||||
|
我们会帮你处理
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,13 +63,19 @@ export const DataStorageScreen: React.FC = () => {
|
|||||||
if (Platform.OS === 'web') return 0;
|
if (Platform.OS === 'web') return 0;
|
||||||
try {
|
try {
|
||||||
const cacheDir = Paths.cache;
|
const cacheDir = Paths.cache;
|
||||||
const sdImageCacheDir = new Directory(cacheDir, 'defaultDiskCache');
|
// expo-image 在底层使用原生图片库,磁盘缓存目录名与平台相关:
|
||||||
const expoImageCacheDir = new Directory(cacheDir, 'expo-image');
|
// - iOS(SDWebImage): {cache}/com.hackemist.SDImageCache/
|
||||||
|
// - Android(Glide): {cache}/image_manager_disk_cache/
|
||||||
|
// 同时保留旧的猜测路径作为兜底,避免未来库升级改路径时彻底失效。
|
||||||
|
const candidateDirNames =
|
||||||
|
Platform.OS === 'ios'
|
||||||
|
? ['com.hackemist.SDImageCache', 'defaultDiskCache', 'expo-image']
|
||||||
|
: ['image_manager_disk_cache', 'expo-image', 'defaultDiskCache'];
|
||||||
|
|
||||||
const scanDirectorySize = async (dir: Directory): Promise<number> => {
|
const scanDirectorySize = async (dir: Directory): Promise<number> => {
|
||||||
let size = 0;
|
let size = 0;
|
||||||
try {
|
try {
|
||||||
if (!(await dir.exists)) return 0;
|
if (!dir.exists) return 0;
|
||||||
const items = dir.list();
|
const items = dir.list();
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
try {
|
try {
|
||||||
@@ -87,7 +93,10 @@ export const DataStorageScreen: React.FC = () => {
|
|||||||
return size;
|
return size;
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalSize = await scanDirectorySize(sdImageCacheDir) + await scanDirectorySize(expoImageCacheDir);
|
let totalSize = 0;
|
||||||
|
for (const name of candidateDirNames) {
|
||||||
|
totalSize += await scanDirectorySize(new Directory(cacheDir, name));
|
||||||
|
}
|
||||||
return totalSize;
|
return totalSize;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('读取 expo-image 缓存大小失败:', error);
|
console.warn('读取 expo-image 缓存大小失败:', error);
|
||||||
@@ -144,6 +153,9 @@ export const DataStorageScreen: React.FC = () => {
|
|||||||
await mediaCacheManager.clearAll();
|
await mediaCacheManager.clearAll();
|
||||||
await Image.clearDiskCache();
|
await Image.clearDiskCache();
|
||||||
await Image.clearMemoryCache();
|
await Image.clearMemoryCache();
|
||||||
|
// expo-image 的磁盘缓存清理由原生库(SDWebImage / Glide)在后台线程执行,
|
||||||
|
// 立即读取目录大小可能仍是清理前的值,这里短暂等待以确保文件落盘删除完成。
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
await loadStats();
|
await loadStats();
|
||||||
Alert.alert('完成', '缓存已清除');
|
Alert.alert('完成', '缓存已清除');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ import {
|
|||||||
} from '@/services/notification';
|
} from '@/services/notification';
|
||||||
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
||||||
import { backgroundSyncManager, BackgroundSyncMode } from '@/services/background';
|
import { backgroundSyncManager, BackgroundSyncMode } from '@/services/background';
|
||||||
|
import {
|
||||||
|
loadAutoStartConsent,
|
||||||
|
consentToAutoStart,
|
||||||
|
rejectAutoStart,
|
||||||
|
getAutoStartDescription,
|
||||||
|
} from '@/services/consent';
|
||||||
|
|
||||||
// 内容最大宽度
|
// 内容最大宽度
|
||||||
const CONTENT_MAX_WIDTH = 720;
|
const CONTENT_MAX_WIDTH = 720;
|
||||||
@@ -49,8 +55,9 @@ export const NotificationSettingsScreen: React.FC = () => {
|
|||||||
const [vibrationEnabled, setVibrationEnabledState] = useState(true);
|
const [vibrationEnabled, setVibrationEnabledState] = useState(true);
|
||||||
const [pushEnabled, setPushEnabled] = useState(true);
|
const [pushEnabled, setPushEnabled] = useState(true);
|
||||||
const [soundEnabled, setSoundEnabled] = useState(true);
|
const [soundEnabled, setSoundEnabled] = useState(true);
|
||||||
const [syncMode, setSyncMode] = useState<BackgroundSyncMode>(BackgroundSyncMode.BATTERY_SAVER);
|
const [syncMode, setSyncMode] = useState<BackgroundSyncMode>(BackgroundSyncMode.DISABLED);
|
||||||
const [systemPushEnabled, setSystemPushEnabled] = useState<boolean | null>(null);
|
const [systemPushEnabled, setSystemPushEnabled] = useState<boolean | null>(null);
|
||||||
|
const [autoStartConsented, setAutoStartConsented] = useState(false);
|
||||||
const { isWideScreen, isMobile } = useResponsive();
|
const { isWideScreen, isMobile } = useResponsive();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
@@ -67,6 +74,10 @@ export const NotificationSettingsScreen: React.FC = () => {
|
|||||||
setSoundEnabled(prefs.soundEnabled);
|
setSoundEnabled(prefs.soundEnabled);
|
||||||
setSyncMode(backgroundSyncManager.getMode());
|
setSyncMode(backgroundSyncManager.getMode());
|
||||||
|
|
||||||
|
// 加载自启动同意状态
|
||||||
|
const consent = await loadAutoStartConsent();
|
||||||
|
setAutoStartConsented(consent.consented);
|
||||||
|
|
||||||
if (Platform.OS !== 'web') {
|
if (Platform.OS !== 'web') {
|
||||||
const enabled = await jpushService.checkNotificationPermission();
|
const enabled = await jpushService.checkNotificationPermission();
|
||||||
setSystemPushEnabled(enabled);
|
setSystemPushEnabled(enabled);
|
||||||
@@ -106,6 +117,41 @@ export const NotificationSettingsScreen: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSyncModeChange = async (mode: BackgroundSyncMode) => {
|
const handleSyncModeChange = async (mode: BackgroundSyncMode) => {
|
||||||
|
// 如果要切换到需要自启动的模式,先请求用户同意
|
||||||
|
if (mode !== BackgroundSyncMode.DISABLED && !autoStartConsented) {
|
||||||
|
Alert.alert(
|
||||||
|
'后台消息接收',
|
||||||
|
getAutoStartDescription() + '\n\n开启后,应用可在后台接收消息推送。',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '同意并开启',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await consentToAutoStart('接收实时消息推送');
|
||||||
|
setAutoStartConsented(true);
|
||||||
|
// 用户同意后,继续切换模式
|
||||||
|
await doSetSyncMode(mode);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('同意自启动失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果切换到禁用模式,撤销自启动同意
|
||||||
|
if (mode === BackgroundSyncMode.DISABLED && autoStartConsented) {
|
||||||
|
await rejectAutoStart();
|
||||||
|
setAutoStartConsented(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await doSetSyncMode(mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doSetSyncMode = async (mode: BackgroundSyncMode) => {
|
||||||
if (mode === BackgroundSyncMode.REALTIME) {
|
if (mode === BackgroundSyncMode.REALTIME) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'实时模式',
|
'实时模式',
|
||||||
@@ -130,24 +176,24 @@ export const NotificationSettingsScreen: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const syncModeOptions: { mode: BackgroundSyncMode; title: string; subtitle: string; icon: string }[] = [
|
const syncModeOptions: { mode: BackgroundSyncMode; title: string; subtitle: string; icon: string }[] = [
|
||||||
|
{
|
||||||
|
mode: BackgroundSyncMode.DISABLED,
|
||||||
|
title: '静默模式',
|
||||||
|
subtitle: '不自启动,仅在使用时接收消息',
|
||||||
|
icon: 'bell-off-outline',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
mode: BackgroundSyncMode.BATTERY_SAVER,
|
mode: BackgroundSyncMode.BATTERY_SAVER,
|
||||||
title: '省电模式',
|
title: '后台模式',
|
||||||
subtitle: '系统后台任务,每 15 分钟检查一次',
|
subtitle: '允许自启动,每 15 分钟检查一次',
|
||||||
icon: 'leaf',
|
icon: 'leaf',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
mode: BackgroundSyncMode.REALTIME,
|
mode: BackgroundSyncMode.REALTIME,
|
||||||
title: '实时模式',
|
title: '实时模式',
|
||||||
subtitle: '通知栏常驻保活,即时同步消息',
|
subtitle: '允许自启动,通知栏常驻保活',
|
||||||
icon: 'lightning-bolt',
|
icon: 'lightning-bolt',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
mode: BackgroundSyncMode.DISABLED,
|
|
||||||
title: '禁用',
|
|
||||||
subtitle: '仅在应用打开时接收消息',
|
|
||||||
icon: 'close-circle-outline',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleRequestSystemPermission = async () => {
|
const handleRequestSystemPermission = async () => {
|
||||||
@@ -304,12 +350,30 @@ export const NotificationSettingsScreen: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 实时模式说明 */}
|
{/* 模式说明 */}
|
||||||
|
{syncMode === BackgroundSyncMode.DISABLED && (
|
||||||
|
<View style={styles.tipContainer}>
|
||||||
|
<MaterialCommunityIcons name="information-outline" size={16} color={colors.text.hint} />
|
||||||
|
<Text variant="caption" color={colors.text.hint} style={styles.tipText}>
|
||||||
|
静默模式下,应用不会自启动。您仅在打开应用时接收消息。此模式最省电,但可能错过实时消息。
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{syncMode === BackgroundSyncMode.BATTERY_SAVER && (
|
||||||
|
<View style={styles.tipContainer}>
|
||||||
|
<MaterialCommunityIcons name="information-outline" size={16} color={colors.text.hint} />
|
||||||
|
<Text variant="caption" color={colors.text.hint} style={styles.tipText}>
|
||||||
|
后台模式已开启,应用允许自启动以接收消息推送。系统会每 15 分钟检查一次新消息。
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{syncMode === BackgroundSyncMode.REALTIME && (
|
{syncMode === BackgroundSyncMode.REALTIME && (
|
||||||
<View style={styles.tipContainer}>
|
<View style={styles.tipContainer}>
|
||||||
<MaterialCommunityIcons name="shield-check-outline" size={16} color={colors.text.hint} />
|
<MaterialCommunityIcons name="shield-check-outline" size={16} color={colors.text.hint} />
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.tipText}>
|
<Text variant="caption" color={colors.text.hint} style={styles.tipText}>
|
||||||
实时模式已开启,应用将在通知栏显示常驻通知以保持后台运行。如需更稳定的后台运行,请在系统设置中将本应用加入电池优化白名单。
|
实时模式已开启,应用允许自启动并在通知栏显示常驻通知以保持后台运行。如需更稳定的后台运行,请在系统设置中将本应用加入电池优化白名单。
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const THEME_COLORS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 政策最后更新日期
|
// 政策最后更新日期
|
||||||
const LAST_UPDATED = '2026年4月28日';
|
const LAST_UPDATED = '2026年6月15日';
|
||||||
|
|
||||||
// 隐私政策内容
|
// 隐私政策内容
|
||||||
const PRIVACY_SECTIONS = [
|
const PRIVACY_SECTIONS = [
|
||||||
@@ -159,29 +159,60 @@ const PRIVACY_SECTIONS = [
|
|||||||
title: '七、第三方SDK目录',
|
title: '七、第三方SDK目录',
|
||||||
content: `为了保障App的稳定运行或实现特定功能,我们可能接入第三方SDK。截至本隐私政策更新之日,我们主要使用以下服务:
|
content: `为了保障App的稳定运行或实现特定功能,我们可能接入第三方SDK。截至本隐私政策更新之日,我们主要使用以下服务:
|
||||||
|
|
||||||
1. 极光推送(JPush)
|
1. 极光推送 - JPush SDK - Android
|
||||||
• 提供方:深圳市和讯华谷信息技术有限公司
|
所属系统:安卓、iOS
|
||||||
• 使用目的:实现消息推送功能,向您的设备发送通知提醒
|
第三方公司名称:深圳市和讯华谷信息技术有限公司
|
||||||
• 收集的个人信息:设备标识符(Registration ID、Android ID、IMEI、OAID)、设备型号、操作系统版本、IP地址、应用列表信息
|
第三方收集的个人信息类型:
|
||||||
• 隐私政策:https://www.jiguang.cn/privacy
|
1. 设备标识符(Registration ID、Android ID、IMEI、OAID);2. 设备型号、操作系统版本;3. IP地址;4. 应用列表信息
|
||||||
|
我们从第三方获取的个人信息:设备标识符(Registration ID)
|
||||||
2. Expo推送服务
|
第三方可能调用的权限:无
|
||||||
• 提供方:Expo(650 Industries, Inc.)
|
实现功能及场景描述:消息推送
|
||||||
• 使用目的:辅助消息推送通道管理
|
处理目的:实现消息推送功能,向您的设备发送通知提醒
|
||||||
• 收集的个人信息:设备推送令牌(Push Token)、设备标识符
|
处理方式:SDK采集
|
||||||
|
联系方式:官网 https://www.jiguang.cn/
|
||||||
|
第三方隐私政策链接:https://www.jiguang.cn/license/privacy
|
||||||
|
|
||||||
如您后续接入其他第三方SDK(如微信登录、分享等功能),我们将在本章节更新相关说明,并告知您对应SDK收集的信息类型和用途。更新后的SDK目录将在应用内公布,请以最新版本为准。`,
|
如您后续接入其他第三方SDK(如微信登录、分享等功能),我们将在本章节更新相关说明,并告知您对应SDK收集的信息类型和用途。更新后的SDK目录将在应用内公布,请以最新版本为准。`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '八、隐私政策的更新',
|
title: '八、应用自启动与关联启动说明',
|
||||||
content: `8.1 我们可能会不时更新本隐私政策。更新后的隐私政策将在应用内公布,并标注更新日期。
|
content: `8.1 自启动/关联启动的目的与场景
|
||||||
|
为了及时向您推送消息通知,本应用可能需要在以下场景进行自启动或关联启动:
|
||||||
|
|
||||||
8.2 对于重大变更,我们会在变更生效前通过应用内通知、弹窗等方式告知您。
|
• 设备开机完成后:恢复后台消息推送服务,确保您能及时收到新消息提醒
|
||||||
|
• 应用更新后:恢复后台任务调度,保证消息同步功能正常运作
|
||||||
|
• 系统重启后:恢复通知服务,确保推送通道可用
|
||||||
|
• 关联启动场景:当系统或其他应用触发相关事件时,为保证推送服务连续性而进行关联启动
|
||||||
|
|
||||||
8.3 如您不同意变更后的内容,应立即停止使用本服务。如您继续使用本服务,即视为您已阅读并同意受变更后的隐私政策约束。`,
|
8.2 用户同意机制
|
||||||
|
• 自启动/关联启动功能仅在您明确同意后才启用
|
||||||
|
• 首次使用时,我们会在"通知设置"中向您说明自启动的目的、场景、规则及必要性,并征得您的同意
|
||||||
|
• 您可随时在"设置-通知设置-后台同步模式"中更改选择:
|
||||||
|
- 静默模式:不自启动,仅在使用应用时接收消息
|
||||||
|
- 后台模式:同意自启动,系统每15分钟检查一次新消息
|
||||||
|
- 实时模式:同意自启动,通知栏常驻保活,即时同步消息
|
||||||
|
|
||||||
|
8.3 关闭自启动的影响
|
||||||
|
• 选择静默模式后,应用不会自启动
|
||||||
|
• 您仅在打开应用时才能接收消息
|
||||||
|
• 此模式最省电,但可能错过实时消息
|
||||||
|
• 关闭自启动不会影响应用内的其他功能使用
|
||||||
|
|
||||||
|
8.4 我们承诺
|
||||||
|
• 自启动行为仅用于消息推送服务,不会用于收集额外个人信息
|
||||||
|
• 自启动行为不会用于广告推送或其他商业目的
|
||||||
|
• 我们仅在用户同意的范围内使用自启动功能`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '九、联系我们',
|
title: '九、隐私政策的更新',
|
||||||
|
content: `9.1 我们可能会不时更新本隐私政策。更新后的隐私政策将在应用内公布,并标注更新日期。
|
||||||
|
|
||||||
|
9.2 对于重大变更,我们会在变更生效前通过应用内通知、弹窗等方式告知您。
|
||||||
|
|
||||||
|
9.3 如您不同意变更后的内容,应立即停止使用本服务。如您继续使用本服务,即视为您已阅读并同意受变更后的隐私政策约束。`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '十、联系我们',
|
||||||
content: `如果您对本隐私政策有任何疑问、意见或建议,或者您希望行使您的权利,请通过以下方式与我们联系:
|
content: `如果您对本隐私政策有任何疑问、意见或建议,或者您希望行使您的权利,请通过以下方式与我们联系:
|
||||||
|
|
||||||
• 邮箱:system@qczlit.cn
|
• 邮箱:system@qczlit.cn
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ const TERMS_SECTIONS = [
|
|||||||
|
|
||||||
6.3 我们将采取合理的技术和管理措施保护您的个人信息安全,但不对因不可抗力或第三方原因导致的信息泄露承担责任。
|
6.3 我们将采取合理的技术和管理措施保护您的个人信息安全,但不对因不可抗力或第三方原因导致的信息泄露承担责任。
|
||||||
|
|
||||||
6.4 本应用集成了极光推送(JPush)等第三方SDK,用于实现消息推送等功能。第三方SDK可能会收集和处理您的设备标识符、设备信息等必要数据。具体的第三方SDK信息请参见《隐私政策》中的"第三方SDK目录"章节。`,
|
6.4 本应用集成了极光推送 - JPush SDK - Android等第三方SDK,用于实现消息推送等功能。第三方SDK可能会收集和处理您的设备标识符、设备信息等必要数据。具体的第三方SDK信息请参见《隐私政策》中的"第三方SDK目录"章节。`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '七、免责声明',
|
title: '七、免责声明',
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
|||||||
import { useAppColors } from '../../theme';
|
import { useAppColors } from '../../theme';
|
||||||
import { Post } from '../../types';
|
import { Post } from '../../types';
|
||||||
import { PostCard, TabBar, UserProfileHeader } from '../../components/business';
|
import { PostCard, TabBar, UserProfileHeader } from '../../components/business';
|
||||||
import { Loading, EmptyState, ResponsiveContainer } from '../../components/common';
|
import type { PostCardAction } from '../../components/business/PostCard';
|
||||||
|
import { Loading, EmptyState, ResponsiveContainer, ImageGallery, ImageGridItem } from '../../components/common';
|
||||||
import { useResponsive } from '../../hooks';
|
import { useResponsive } from '../../hooks';
|
||||||
import { useUserProfile, ProfileMode, TABS, TAB_ICONS, createSharedProfileStyles } from './useUserProfile';
|
import { useUserProfile, ProfileMode, TABS, TAB_ICONS, createSharedProfileStyles } from './useUserProfile';
|
||||||
|
|
||||||
@@ -116,6 +117,39 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
|||||||
currentUser,
|
currentUser,
|
||||||
} = useUserProfile({ mode, userId, isDesktop, isTablet });
|
} = useUserProfile({ mode, userId, isDesktop, isTablet });
|
||||||
|
|
||||||
|
// 图片查看器状态(与 HomeScreen / PostDetailScreen 一致)
|
||||||
|
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||||
|
const [postImages, setPostImages] = useState<ImageGridItem[]>([]);
|
||||||
|
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||||
|
|
||||||
|
const closeImageViewer = useCallback(() => setShowImageViewer(false), []);
|
||||||
|
const stableGalleryImages = useMemo(
|
||||||
|
() =>
|
||||||
|
postImages.map((img, i) => ({
|
||||||
|
id: img.id || img.url || `img-${i}`,
|
||||||
|
url: img.url || img.uri || '',
|
||||||
|
})),
|
||||||
|
[postImages]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 包装 handlePostAction:在 hook 通用逻辑之上处理 imagePress
|
||||||
|
const onPostAction = useCallback(
|
||||||
|
(post: Post, action: PostCardAction) => {
|
||||||
|
if (action.type === 'imagePress') {
|
||||||
|
const images = action.payload?.images;
|
||||||
|
const imageIndex = action.payload?.imageIndex;
|
||||||
|
if (images && imageIndex !== undefined) {
|
||||||
|
setPostImages(images);
|
||||||
|
setSelectedImageIndex(imageIndex);
|
||||||
|
setShowImageViewer(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePostAction(post, action);
|
||||||
|
},
|
||||||
|
[handlePostAction]
|
||||||
|
);
|
||||||
|
|
||||||
// 当前显示的帖子列表
|
// 当前显示的帖子列表
|
||||||
const currentPosts = activeTab === 0 ? posts : favorites;
|
const currentPosts = activeTab === 0 ? posts : favorites;
|
||||||
|
|
||||||
@@ -130,12 +164,12 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
|||||||
]}>
|
]}>
|
||||||
<PostCard
|
<PostCard
|
||||||
post={item}
|
post={item}
|
||||||
onAction={(action) => handlePostAction(item, action)}
|
onAction={(action) => onPostAction(item, action)}
|
||||||
isPostAuthor={isPostAuthor}
|
isPostAuthor={isPostAuthor}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}, [currentUser?.id, handlePostAction, currentPosts.length]);
|
}, [currentUser?.id, onPostAction, currentPosts.length]);
|
||||||
|
|
||||||
const postKeyExtractor = useCallback((item: Post) => item.id, []);
|
const postKeyExtractor = useCallback((item: Post) => item.id, []);
|
||||||
|
|
||||||
@@ -269,6 +303,15 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|
||||||
|
{/* 图片查看器 */}
|
||||||
|
<ImageGallery
|
||||||
|
visible={showImageViewer}
|
||||||
|
images={stableGalleryImages}
|
||||||
|
initialIndex={selectedImageIndex}
|
||||||
|
onClose={closeImageViewer}
|
||||||
|
enableSave
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -310,6 +353,15 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
|||||||
}
|
}
|
||||||
drawDistance={250}
|
drawDistance={250}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 图片查看器 */}
|
||||||
|
<ImageGallery
|
||||||
|
visible={showImageViewer}
|
||||||
|
images={stableGalleryImages}
|
||||||
|
initialIndex={selectedImageIndex}
|
||||||
|
onClose={closeImageViewer}
|
||||||
|
enableSave
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,25 +12,34 @@
|
|||||||
import { AppState, AppStateStatus, Platform } from 'react-native';
|
import { AppState, AppStateStatus, Platform } from 'react-native';
|
||||||
import { ForegroundServiceModule } from './ForegroundServiceModule';
|
import { ForegroundServiceModule } from './ForegroundServiceModule';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import {
|
||||||
|
loadAutoStartConsent,
|
||||||
|
saveAutoStartConsent,
|
||||||
|
AutoStartMode,
|
||||||
|
} from '../consent/autoStartConsent';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台同步模式
|
* 后台同步模式
|
||||||
|
* 整合自启动同意机制:
|
||||||
|
* - DISABLED(静默模式):不自启动,不注册后台任务
|
||||||
|
* - BATTERY_SAVER(后台模式):用户同意自启动后,使用 WorkManager 后台任务(15分钟间隔)
|
||||||
|
* - REALTIME(实时模式):用户同意自启动后,使用前台服务保活
|
||||||
*/
|
*/
|
||||||
export enum BackgroundSyncMode {
|
export enum BackgroundSyncMode {
|
||||||
/** 省电模式:仅依赖 JPush 推送 */
|
/** 静默模式:不自启动,仅在使用时接收消息 */
|
||||||
|
DISABLED = 'disabled',
|
||||||
|
|
||||||
|
/** 后台模式:用户同意自启动,使用 WorkManager 后台任务 */
|
||||||
BATTERY_SAVER = 'battery_saver',
|
BATTERY_SAVER = 'battery_saver',
|
||||||
|
|
||||||
/** 实时模式:前台服务保活(仅用于通话) */
|
/** 实时模式:用户同意自启动,前台服务保活 */
|
||||||
REALTIME = 'realtime',
|
REALTIME = 'realtime',
|
||||||
|
|
||||||
/** 禁用后台同步 */
|
|
||||||
DISABLED = 'disabled',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'background_sync_mode';
|
const STORAGE_KEY = 'background_sync_mode';
|
||||||
|
|
||||||
class BackgroundSyncManager {
|
class BackgroundSyncManager {
|
||||||
private mode: BackgroundSyncMode = BackgroundSyncMode.BATTERY_SAVER;
|
private mode: BackgroundSyncMode = BackgroundSyncMode.DISABLED;
|
||||||
private appStateSubscription: ReturnType<typeof AppState.addEventListener> | null = null;
|
private appStateSubscription: ReturnType<typeof AppState.addEventListener> | null = null;
|
||||||
private isInitialized: boolean = false;
|
private isInitialized: boolean = false;
|
||||||
private lastSyncAt: number = 0;
|
private lastSyncAt: number = 0;
|
||||||
@@ -64,8 +73,18 @@ class BackgroundSyncManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换后台同步模式
|
* 切换后台同步模式
|
||||||
|
* 需要用户同意自启动才能切换到 BATTERY_SAVER 或 REALTIME 模式
|
||||||
*/
|
*/
|
||||||
async setMode(mode: BackgroundSyncMode): Promise<void> {
|
async setMode(mode: BackgroundSyncMode): Promise<void> {
|
||||||
|
// 如果要切换到需要自启动的模式,先检查用户是否同意
|
||||||
|
if (mode !== BackgroundSyncMode.DISABLED) {
|
||||||
|
const consent = await loadAutoStartConsent();
|
||||||
|
if (!consent.consented || consent.mode !== AutoStartMode.BACKGROUND) {
|
||||||
|
console.log('[BackgroundSyncManager] 用户未同意自启动,无法切换到模式:', mode);
|
||||||
|
throw new Error('用户未同意自启动');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (AppState.currentState !== 'active' && this.mode === BackgroundSyncMode.REALTIME) {
|
if (AppState.currentState !== 'active' && this.mode === BackgroundSyncMode.REALTIME) {
|
||||||
await ForegroundServiceModule.stop();
|
await ForegroundServiceModule.stop();
|
||||||
}
|
}
|
||||||
@@ -161,9 +180,9 @@ class BackgroundSyncManager {
|
|||||||
private async loadMode(): Promise<BackgroundSyncMode> {
|
private async loadMode(): Promise<BackgroundSyncMode> {
|
||||||
try {
|
try {
|
||||||
const saved = await AsyncStorage.getItem(STORAGE_KEY);
|
const saved = await AsyncStorage.getItem(STORAGE_KEY);
|
||||||
return (saved as BackgroundSyncMode) || BackgroundSyncMode.BATTERY_SAVER;
|
return (saved as BackgroundSyncMode) || BackgroundSyncMode.DISABLED;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return BackgroundSyncMode.BATTERY_SAVER;
|
return BackgroundSyncMode.DISABLED;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ import {
|
|||||||
} from './BackgroundSyncManager';
|
} from './BackgroundSyncManager';
|
||||||
import { messageService } from '../message/messageService';
|
import { messageService } from '../message/messageService';
|
||||||
import { api } from '../core/api';
|
import { api } from '../core/api';
|
||||||
|
import {
|
||||||
|
loadAutoStartConsent,
|
||||||
|
isAutoStartAllowed,
|
||||||
|
AutoStartMode,
|
||||||
|
} from '../consent/autoStartConsent';
|
||||||
|
|
||||||
// 后台任务名称
|
// 后台任务名称
|
||||||
const BACKGROUND_SYNC_TASK = 'background-sync-task';
|
const BACKGROUND_SYNC_TASK = 'background-sync-task';
|
||||||
@@ -36,6 +41,13 @@ TaskManager.defineTask(BACKGROUND_SYNC_TASK, async () => {
|
|||||||
return BackgroundTask.BackgroundTaskResult.Success;
|
return BackgroundTask.BackgroundTaskResult.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查用户是否同意自启动
|
||||||
|
const consent = await loadAutoStartConsent();
|
||||||
|
if (!consent.consented || consent.mode !== AutoStartMode.BACKGROUND) {
|
||||||
|
console.log('[BackgroundService] 用户未同意自启动,跳过后台同步');
|
||||||
|
return BackgroundTask.BackgroundTaskResult.Success;
|
||||||
|
}
|
||||||
|
|
||||||
// 执行同步
|
// 执行同步
|
||||||
await syncMessages();
|
await syncMessages();
|
||||||
|
|
||||||
@@ -82,6 +94,7 @@ async function syncMessages(): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册后台任务(expo-background-task)
|
* 注册后台任务(expo-background-task)
|
||||||
|
* 仅在用户同意自启动且模式为 BATTERY_SAVER 时注册
|
||||||
*/
|
*/
|
||||||
async function registerBackgroundTask(): Promise<void> {
|
async function registerBackgroundTask(): Promise<void> {
|
||||||
if (Platform.OS === 'web') {
|
if (Platform.OS === 'web') {
|
||||||
@@ -89,6 +102,13 @@ async function registerBackgroundTask(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 检查用户是否同意自启动
|
||||||
|
const consent = await loadAutoStartConsent();
|
||||||
|
if (!consent.consented || consent.mode !== AutoStartMode.BACKGROUND) {
|
||||||
|
console.log('[BackgroundService] 用户未同意自启动,跳过注册后台任务');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 检查后台任务状态
|
// 检查后台任务状态
|
||||||
const status = await BackgroundTask.getStatusAsync();
|
const status = await BackgroundTask.getStatusAsync();
|
||||||
if (status !== BackgroundTask.BackgroundTaskStatus.Available) {
|
if (status !== BackgroundTask.BackgroundTaskStatus.Available) {
|
||||||
@@ -129,7 +149,7 @@ async function unregisterBackgroundTask(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化后台保活服务
|
* 根据用户同意的自启动模式初始化后台服务
|
||||||
*/
|
*/
|
||||||
export async function initBackgroundService(): Promise<boolean> {
|
export async function initBackgroundService(): Promise<boolean> {
|
||||||
if (isInitialized) {
|
if (isInitialized) {
|
||||||
@@ -142,14 +162,21 @@ export async function initBackgroundService(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 加载用户同意状态
|
||||||
|
await loadAutoStartConsent();
|
||||||
|
|
||||||
// 设置同步回调
|
// 设置同步回调
|
||||||
backgroundSyncManager.setSyncMessagesCallback(syncMessages);
|
backgroundSyncManager.setSyncMessagesCallback(syncMessages);
|
||||||
|
|
||||||
// 初始化后台同步管理器
|
// 初始化后台同步管理器
|
||||||
await backgroundSyncManager.initialize();
|
await backgroundSyncManager.initialize();
|
||||||
|
|
||||||
// 注册 expo-background-task 任务(用于 BATTERY_SAVER 模式)
|
// 仅在用户同意自启动时注册后台任务
|
||||||
|
if (isAutoStartAllowed()) {
|
||||||
await registerBackgroundTask();
|
await registerBackgroundTask();
|
||||||
|
} else {
|
||||||
|
console.log('[BackgroundService] 用户选择静默模式,不注册后台自启动任务');
|
||||||
|
}
|
||||||
|
|
||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
console.log('[BackgroundService] 初始化完成');
|
console.log('[BackgroundService] 初始化完成');
|
||||||
@@ -160,6 +187,39 @@ export async function initBackgroundService(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新初始化后台服务(在用户更改自启动同意后调用)
|
||||||
|
*/
|
||||||
|
export async function reinitBackgroundService(): Promise<boolean> {
|
||||||
|
if (!isInitialized) {
|
||||||
|
return initBackgroundService();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const consent = await loadAutoStartConsent();
|
||||||
|
|
||||||
|
if (consent.consented && consent.mode === AutoStartMode.BACKGROUND) {
|
||||||
|
// 用户同意自启动,注册后台任务
|
||||||
|
await registerBackgroundTask();
|
||||||
|
// 如果模式是实时模式,启动前台服务
|
||||||
|
if (backgroundSyncManager.getMode() === BackgroundSyncMode.REALTIME) {
|
||||||
|
await backgroundSyncManager.setMode(BackgroundSyncMode.REALTIME);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 用户拒绝自启动,取消后台任务
|
||||||
|
await unregisterBackgroundTask();
|
||||||
|
// 停止前台服务
|
||||||
|
await backgroundSyncManager.setMode(BackgroundSyncMode.DISABLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[BackgroundService] 根据用户同意状态重新初始化完成');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BackgroundService] 重新初始化失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 停止后台保活服务
|
* 停止后台保活服务
|
||||||
*/
|
*/
|
||||||
@@ -205,6 +265,7 @@ export async function checkBackgroundStatus(): Promise<{
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置后台同步模式
|
* 设置后台同步模式
|
||||||
|
* 同时更新自启动同意状态
|
||||||
*/
|
*/
|
||||||
export async function setBackgroundSyncMode(mode: BackgroundSyncMode): Promise<void> {
|
export async function setBackgroundSyncMode(mode: BackgroundSyncMode): Promise<void> {
|
||||||
await backgroundSyncManager.setMode(mode);
|
await backgroundSyncManager.setMode(mode);
|
||||||
@@ -239,6 +300,7 @@ export { BackgroundSyncMode };
|
|||||||
// 后台服务实例
|
// 后台服务实例
|
||||||
export const backgroundService = {
|
export const backgroundService = {
|
||||||
init: initBackgroundService,
|
init: initBackgroundService,
|
||||||
|
reinit: reinitBackgroundService,
|
||||||
stop: stopBackgroundService,
|
stop: stopBackgroundService,
|
||||||
setMode: setBackgroundSyncMode,
|
setMode: setBackgroundSyncMode,
|
||||||
getMode: getBackgroundSyncMode,
|
getMode: getBackgroundSyncMode,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export {
|
export {
|
||||||
backgroundService,
|
backgroundService,
|
||||||
initBackgroundService,
|
initBackgroundService,
|
||||||
|
reinitBackgroundService,
|
||||||
stopBackgroundService,
|
stopBackgroundService,
|
||||||
triggerVibration,
|
triggerVibration,
|
||||||
vibrateOnMessage,
|
vibrateOnMessage,
|
||||||
|
|||||||
201
src/services/consent/autoStartConsent.ts
Normal file
201
src/services/consent/autoStartConsent.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自启动权限同意管理
|
||||||
|
*
|
||||||
|
* 管理用户对应用自启动/关联启动行为的同意状态
|
||||||
|
* 符合隐私合规要求:未经用户同意不得进行自启动
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'auto_start_consent';
|
||||||
|
const STORAGE_KEY_FIRST_LAUNCH = 'auto_start_first_launch';
|
||||||
|
|
||||||
|
export enum AutoStartMode {
|
||||||
|
/** 静默模式:不自启动,仅在使用时接收消息 */
|
||||||
|
SILENT = 'silent',
|
||||||
|
/** 后台模式:允许自启动以接收实时推送 */
|
||||||
|
BACKGROUND = 'background',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoStartConsent {
|
||||||
|
/** 用户是否同意自启动 */
|
||||||
|
consented: boolean;
|
||||||
|
/** 当前模式 */
|
||||||
|
mode: AutoStartMode;
|
||||||
|
/** 同意时间 */
|
||||||
|
consentedAt?: string;
|
||||||
|
/** 用户同意的目的描述 */
|
||||||
|
purpose?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONSENT: AutoStartConsent = {
|
||||||
|
consented: false,
|
||||||
|
mode: AutoStartMode.SILENT,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedConsent: AutoStartConsent = { ...DEFAULT_CONSENT };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载自启动同意状态
|
||||||
|
*/
|
||||||
|
export async function loadAutoStartConsent(): Promise<AutoStartConsent> {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
return { ...DEFAULT_CONSENT };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
cachedConsent = JSON.parse(raw) as AutoStartConsent;
|
||||||
|
return { ...cachedConsent };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AutoStartConsent] 加载同意状态失败:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...DEFAULT_CONSENT };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存自启动同意状态
|
||||||
|
*/
|
||||||
|
export async function saveAutoStartConsent(consent: AutoStartConsent): Promise<void> {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
cachedConsent = { ...consent };
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(consent));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AutoStartConsent] 保存同意状态失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取同步的自启动同意状态
|
||||||
|
*/
|
||||||
|
export function getAutoStartConsentSync(): AutoStartConsent {
|
||||||
|
return { ...cachedConsent };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否是首次启动(用于显示首次同意弹窗)
|
||||||
|
*/
|
||||||
|
export async function isFirstLaunch(): Promise<boolean> {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(STORAGE_KEY_FIRST_LAUNCH);
|
||||||
|
if (raw === null) {
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY_FIRST_LAUNCH, 'false');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户同意自启动(后台模式)
|
||||||
|
* @param purpose 用户同意的目的描述
|
||||||
|
*/
|
||||||
|
export async function consentToAutoStart(purpose: string = '接收实时消息推送'): Promise<void> {
|
||||||
|
await saveAutoStartConsent({
|
||||||
|
consented: true,
|
||||||
|
mode: AutoStartMode.BACKGROUND,
|
||||||
|
consentedAt: new Date().toISOString(),
|
||||||
|
purpose,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户拒绝自启动(静默模式)
|
||||||
|
*/
|
||||||
|
export async function rejectAutoStart(): Promise<void> {
|
||||||
|
await saveAutoStartConsent({
|
||||||
|
consented: false,
|
||||||
|
mode: AutoStartMode.SILENT,
|
||||||
|
consentedAt: new Date().toISOString(),
|
||||||
|
purpose: '用户拒绝自启动',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换自启动模式
|
||||||
|
*/
|
||||||
|
export async function setAutoStartMode(mode: AutoStartMode): Promise<void> {
|
||||||
|
const current = getAutoStartConsentSync();
|
||||||
|
|
||||||
|
if (mode === AutoStartMode.BACKGROUND) {
|
||||||
|
await saveAutoStartConsent({
|
||||||
|
consented: true,
|
||||||
|
mode: AutoStartMode.BACKGROUND,
|
||||||
|
consentedAt: new Date().toISOString(),
|
||||||
|
purpose: current.purpose || '接收实时消息推送',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await saveAutoStartConsent({
|
||||||
|
consented: false,
|
||||||
|
mode: AutoStartMode.SILENT,
|
||||||
|
consentedAt: new Date().toISOString(),
|
||||||
|
purpose: '用户选择静默模式',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否允许自启动
|
||||||
|
*/
|
||||||
|
export function isAutoStartAllowed(): boolean {
|
||||||
|
return cachedConsent.consented && cachedConsent.mode === AutoStartMode.BACKGROUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前模式
|
||||||
|
*/
|
||||||
|
export function getCurrentAutoStartMode(): AutoStartMode {
|
||||||
|
return cachedConsent.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置同意状态(用于测试或用户撤销同意)
|
||||||
|
*/
|
||||||
|
export async function resetAutoStartConsent(): Promise<void> {
|
||||||
|
cachedConsent = { ...DEFAULT_CONSENT };
|
||||||
|
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||||
|
await AsyncStorage.removeItem(STORAGE_KEY_FIRST_LAUNCH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取自启动说明文本(用于隐私政策和弹窗)
|
||||||
|
*/
|
||||||
|
export function getAutoStartDescription(): string {
|
||||||
|
return `为了及时接收消息推送,应用需要在以下场景自启动:
|
||||||
|
|
||||||
|
1. 设备开机完成后:恢复后台消息推送服务
|
||||||
|
2. 应用更新后:恢复后台任务调度
|
||||||
|
3. 系统重启后:恢复通知服务
|
||||||
|
|
||||||
|
自启动行为仅用于消息推送,不会收集额外个人信息。
|
||||||
|
|
||||||
|
您可以在"设置-通知设置"中随时更改此选项。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取自启动目的说明(用于隐私政策)
|
||||||
|
*/
|
||||||
|
export function getAutoStartPurposeForPrivacy(): string {
|
||||||
|
return `应用自启动/关联启动的目的与规则:
|
||||||
|
|
||||||
|
• 目的:确保用户能够及时接收消息推送通知
|
||||||
|
• 场景:设备开机、应用更新、系统重启后
|
||||||
|
• 规则:仅在用户同意后才启用自启动功能
|
||||||
|
• 必要性:对于需要实时消息通知的用户是必要的
|
||||||
|
• 用户控制:用户可随时在设置中关闭此功能
|
||||||
|
• 关闭影响:关闭后需手动打开应用才能接收消息`;
|
||||||
|
}
|
||||||
16
src/services/consent/index.ts
Normal file
16
src/services/consent/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export {
|
||||||
|
AutoStartMode,
|
||||||
|
loadAutoStartConsent,
|
||||||
|
saveAutoStartConsent,
|
||||||
|
getAutoStartConsentSync,
|
||||||
|
isFirstLaunch,
|
||||||
|
consentToAutoStart,
|
||||||
|
rejectAutoStart,
|
||||||
|
setAutoStartMode,
|
||||||
|
isAutoStartAllowed,
|
||||||
|
getCurrentAutoStartMode,
|
||||||
|
resetAutoStartConsent,
|
||||||
|
getAutoStartDescription,
|
||||||
|
getAutoStartPurposeForPrivacy,
|
||||||
|
} from './autoStartConsent';
|
||||||
|
export type { AutoStartConsent } from './autoStartConsent';
|
||||||
@@ -85,9 +85,8 @@ export const addStickerFromUrl = async (
|
|||||||
*/
|
*/
|
||||||
export const deleteSticker = async (stickerId: string): Promise<boolean> => {
|
export const deleteSticker = async (stickerId: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
await api.delete('/stickers', {
|
// api.delete(path, body) 的第二个参数直接作为请求体,不需要 axios 风格的 { data } 包装
|
||||||
data: { sticker_id: stickerId },
|
await api.delete('/stickers', { sticker_id: stickerId });
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除自定义表情失败:', error);
|
console.error('删除自定义表情失败:', error);
|
||||||
@@ -130,9 +129,7 @@ export const batchDeleteStickers = async (stickerIds: string[]): Promise<{ succe
|
|||||||
|
|
||||||
for (const stickerId of stickerIds) {
|
for (const stickerId of stickerIds) {
|
||||||
try {
|
try {
|
||||||
await api.delete('/stickers', {
|
await api.delete('/stickers', { sticker_id: stickerId });
|
||||||
data: { sticker_id: stickerId },
|
|
||||||
});
|
|
||||||
success++;
|
success++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`删除表情 ${stickerId} 失败:`, error);
|
console.error(`删除表情 ${stickerId} 失败:`, error);
|
||||||
|
|||||||
@@ -112,8 +112,10 @@ class JPushService {
|
|||||||
// Register listeners only once (idempotent guard)
|
// Register listeners only once (idempotent guard)
|
||||||
this._registerListeners();
|
this._registerListeners();
|
||||||
|
|
||||||
// 退后台保持极光长连接,确保推送实时到达
|
// 检查用户是否同意自启动,仅在同意后才保持后台长连接
|
||||||
this._setBackgroundKeepLongConn(true);
|
const { isAutoStartAllowed } = await import('../consent/autoStartConsent');
|
||||||
|
const keepLongConn = isAutoStartAllowed();
|
||||||
|
this._setBackgroundKeepLongConn(keepLongConn);
|
||||||
|
|
||||||
// 初始化 SDK
|
// 初始化 SDK
|
||||||
JPush!.init({
|
JPush!.init({
|
||||||
|
|||||||
@@ -39,17 +39,27 @@ export interface VersionCheckResult {
|
|||||||
hasUpdate: boolean;
|
hasUpdate: boolean;
|
||||||
currentVersion: string;
|
currentVersion: string;
|
||||||
latestVersion: string;
|
latestVersion: string;
|
||||||
|
runtimeVersion: string;
|
||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 比较版本号
|
* 比较语义版本号(SemVer 三段数字)
|
||||||
* 返回: 1 表示 v1 > v2, -1 表示 v1 < v2, 0 表示相等
|
* 返回: 1 表示 v1 > v2, -1 表示 v1 < v2, 0 表示相等
|
||||||
|
*
|
||||||
|
* 注意:仅做 x.y.z 形式的纯数字比较;任何一段非数字(NaN)会被当作 0 处理,
|
||||||
|
* 避免运行时版本号(commit count 等)混入时导致方向相反的误判。
|
||||||
*/
|
*/
|
||||||
function compareVersions(v1: string, v2: string): number {
|
function compareVersions(v1: string, v2: string): number {
|
||||||
const parts1 = v1.split('.').map(Number);
|
const parts1 = v1.split('.').map((s) => {
|
||||||
const parts2 = v2.split('.').map(Number);
|
const n = Number.parseInt(s, 10);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
});
|
||||||
|
const parts2 = v2.split('.').map((s) => {
|
||||||
|
const n = Number.parseInt(s, 10);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
});
|
||||||
|
|
||||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||||
const p1 = parts1[i] || 0;
|
const p1 = parts1[i] || 0;
|
||||||
@@ -86,10 +96,10 @@ async function fetchLatestAPKVersion(): Promise<APKVersionInfo | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前应用版本
|
* 获取当前应用语义版本(来自 app.json / app.config.js 的 expo.version)
|
||||||
*/
|
*/
|
||||||
function getCurrentVersion(): string {
|
function getCurrentVersion(): string {
|
||||||
return Constants.expoConfig?.version || '1.0.0';
|
return Constants.expoConfig?.version || '1.0.1';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,6 +154,7 @@ async function downloadAndInstallAPK(downloadUrl: string, version: string): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 文件名带语义版本(用户可读);runtimeVersion 已在 downloadUrl 中体现
|
||||||
const downloadPath = `${FileSystem.documentDirectory}with-you-${version}.apk`;
|
const downloadPath = `${FileSystem.documentDirectory}with-you-${version}.apk`;
|
||||||
|
|
||||||
// 显示下载进度
|
// 显示下载进度
|
||||||
@@ -247,15 +258,18 @@ export async function checkForAPKUpdate(force: boolean = false): Promise<Version
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentVersion = getCurrentVersion();
|
const currentVersion = getCurrentVersion();
|
||||||
|
// 远端必须返回独立的语义版本号;若缺失才回退到 runtimeVersion(不推荐但兜底)
|
||||||
const latestVersion = latestAPK.versionName || latestAPK.runtimeVersion;
|
const latestVersion = latestAPK.versionName || latestAPK.runtimeVersion;
|
||||||
|
const remoteRuntimeVersion = latestAPK.runtimeVersion;
|
||||||
|
|
||||||
// 比较版本
|
// 比较版本(仅基于语义版本,不受 buildNumber / runtimeVersion 影响)
|
||||||
const comparison = compareVersions(currentVersion, latestVersion);
|
const comparison = compareVersions(currentVersion, latestVersion);
|
||||||
|
|
||||||
const result: VersionCheckResult = {
|
const result: VersionCheckResult = {
|
||||||
hasUpdate: comparison < 0,
|
hasUpdate: comparison < 0,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
latestVersion,
|
latestVersion,
|
||||||
|
runtimeVersion: remoteRuntimeVersion,
|
||||||
downloadUrl: latestAPK.downloadUrl,
|
downloadUrl: latestAPK.downloadUrl,
|
||||||
size: latestAPK.size,
|
size: latestAPK.size,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,23 +62,36 @@ class PostSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fetchPosts(params?: GetPostsParams, key: string = 'default'): Promise<PostsResult> {
|
async fetchPosts(params?: GetPostsParams, key: string = 'default'): Promise<PostsResult> {
|
||||||
const store = usePostListStore.getState();
|
|
||||||
store.updatePostsState(key, { isLoading: true, error: null });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const queryParams: GetPostsParams = {
|
const queryParams: GetPostsParams = {
|
||||||
pageSize: params?.pageSize || DEFAULT_PAGE_SIZE,
|
pageSize: params?.pageSize || DEFAULT_PAGE_SIZE,
|
||||||
...params,
|
...params,
|
||||||
cursor: params?.cursor !== undefined ? params.cursor : '',
|
cursor: params?.cursor !== undefined ? params.cursor : '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 判断参数是否变化:参数变化(切换分区/排序/筛选)时直接清空旧数据,
|
||||||
|
// 避免渲染期间残留上一个分区的帖子造成闪烁
|
||||||
|
const prevState = usePostListStore.getState().getPostsState(key);
|
||||||
|
const paramsChanged = JSON.stringify(prevState.lastParams) !== JSON.stringify(queryParams);
|
||||||
|
|
||||||
|
// 参数变化时立即写入空数据 + loading 状态,确保 UI 第一时间进入加载态
|
||||||
|
// 而不是先渲染上一个分区的旧帖子
|
||||||
|
usePostListStore.getState().updatePostsState(key, {
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
...(paramsChanged ? { posts: [], cursor: null, currentPage: 1, hasMore: true } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
const result = await postRepository.getPosts(queryParams);
|
const result = await postRepository.getPosts(queryParams);
|
||||||
const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null;
|
const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null;
|
||||||
|
|
||||||
const currentState = usePostListStore.getState().getPostsState(key);
|
const currentState = usePostListStore.getState().getPostsState(key);
|
||||||
const incomingPosts = Array.isArray(result.posts) ? result.posts : [];
|
const incomingPosts = Array.isArray(result.posts) ? result.posts : [];
|
||||||
const mergeStart = Date.now();
|
const mergeStart = Date.now();
|
||||||
const mergedPosts = mergeRefreshWindow(currentState.posts, incomingPosts);
|
// 参数变化时直接替换;同参数刷新则合并以保留滚动位置上的帖子
|
||||||
|
const mergedPosts = paramsChanged
|
||||||
|
? incomingPosts
|
||||||
|
: mergeRefreshWindow(currentState.posts, incomingPosts);
|
||||||
const mergeCost = Date.now() - mergeStart;
|
const mergeCost = Date.now() - mergeStart;
|
||||||
if (mergeCost >= MERGE_PERF_WARN_THRESHOLD_MS) {
|
if (mergeCost >= MERGE_PERF_WARN_THRESHOLD_MS) {
|
||||||
console.log('[PostPerf] fetchPosts merge cost', { key, costMs: mergeCost, prev: currentState.posts.length, next: incomingPosts.length });
|
console.log('[PostPerf] fetchPosts merge cost', { key, costMs: mergeCost, prev: currentState.posts.length, next: incomingPosts.length });
|
||||||
@@ -116,7 +129,8 @@ class PostSyncService {
|
|||||||
const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null;
|
const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null;
|
||||||
const incomingPosts = Array.isArray(result.posts) ? result.posts : [];
|
const incomingPosts = Array.isArray(result.posts) ? result.posts : [];
|
||||||
const mergeStart = Date.now();
|
const mergeStart = Date.now();
|
||||||
const mergedPosts = mergeRefreshWindow(state.posts, incomingPosts);
|
// refreshPosts 是刷新操作,应直接替换当前列表,避免旧数据残留
|
||||||
|
const mergedPosts = incomingPosts;
|
||||||
const mergeCost = Date.now() - mergeStart;
|
const mergeCost = Date.now() - mergeStart;
|
||||||
if (mergeCost >= MERGE_PERF_WARN_THRESHOLD_MS) {
|
if (mergeCost >= MERGE_PERF_WARN_THRESHOLD_MS) {
|
||||||
console.log('[PostPerf] refreshPosts merge cost', { key, costMs: mergeCost, prev: state.posts.length, next: incomingPosts.length });
|
console.log('[PostPerf] refreshPosts merge cost', { key, costMs: mergeCost, prev: state.posts.length, next: incomingPosts.length });
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export interface UploadResponse {
|
|||||||
height?: number;
|
height?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
// 文件上传专用字段(POST /uploads/files 返回)
|
||||||
|
name?: string;
|
||||||
|
mime_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传服务类
|
// 上传服务类
|
||||||
@@ -130,17 +133,18 @@ class UploadService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传文件(通用)
|
// 上传文件(通用,对接后端 POST /uploads/files)
|
||||||
|
// 后端返回 { url, name, size, mime_type }
|
||||||
async uploadFile(
|
async uploadFile(
|
||||||
file: {
|
file: {
|
||||||
uri: string;
|
uri: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
},
|
},
|
||||||
folder: string = 'general'
|
folder: string = 'chat'
|
||||||
): Promise<UploadResponse | null> {
|
): Promise<UploadResponse | null> {
|
||||||
try {
|
try {
|
||||||
const response = await api.upload<UploadResponse>('/upload/file', {
|
const response = await api.upload<UploadResponse>('/uploads/files', {
|
||||||
uri: file.uri,
|
uri: file.uri,
|
||||||
name: file.name || `file_${Date.now()}`,
|
name: file.name || `file_${Date.now()}`,
|
||||||
type: file.type || 'application/octet-stream',
|
type: file.type || 'application/octet-stream',
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export interface FileSegmentData {
|
|||||||
name: string;
|
name: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
mime_type?: string;
|
mime_type?: string;
|
||||||
|
/** 文件已过期(已被服务端清理),前端据此显示失效状态 */
|
||||||
|
expired?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AtSegmentData {
|
export interface AtSegmentData {
|
||||||
|
|||||||
Reference in New Issue
Block a user