Compare commits
52 Commits
ea2cf7bcff
...
feature/pu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e79dbf655 | ||
|
|
a921aacefd | ||
|
|
96e8de18bf | ||
|
|
b2979311bb | ||
|
|
9b5e76b310 | ||
|
|
b05da4e4a6 | ||
|
|
641de98dc1 | ||
|
|
33a9c2fad1 | ||
|
|
2195fe2c2b | ||
|
|
03a735b6ac | ||
|
|
c06463f576 | ||
|
|
c46260e4c0 | ||
|
|
4c4d28f0e2 | ||
|
|
a31225dce2 | ||
|
|
b0b868593d | ||
|
|
ed06c10f25 | ||
|
|
ce56824c2e | ||
|
|
2ae5999940 | ||
|
|
edde8f1c30 | ||
|
|
1012337e57 | ||
|
|
f3d54a3f4c | ||
|
|
d8ef51fa13 | ||
|
|
97477c3471 | ||
|
|
1e05d2bd54 | ||
|
|
afbbee337d | ||
|
|
b15e0c0b0b | ||
|
|
5c81795d39 | ||
|
|
1f7e25349f | ||
|
|
49e94fc056 | ||
|
|
cabac94aae | ||
|
|
c6d65fe545 | ||
|
|
f79415591d | ||
|
|
3cc3695d0e | ||
|
|
765fd8cce9 | ||
|
|
2e2f6e3467 | ||
|
|
2e6912dddf | ||
|
|
d6a94c7b4d | ||
|
|
8ee6e77cb4 | ||
|
|
52d2581dda | ||
|
|
e7eae716be | ||
|
|
ad06881b85 | ||
|
|
f39288f401 | ||
|
|
70ab00795a | ||
|
|
72c30ed156 | ||
|
|
b86fe324f5 | ||
|
|
313820ed20 | ||
|
|
f4db4eb1ed | ||
|
|
7a17323e8d | ||
|
|
4fde3e403a | ||
|
|
fb67fb6d5b | ||
|
|
404b3fabe7 | ||
|
|
d8d2b03f94 |
15
.claude/settings.local.json
Normal file
15
.claude/settings.local.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); deps={**d.get\\('dependencies',{}\\), **d.get\\('devDependencies',{}\\)}; [print\\(f'{k}: {v}'\\) for k,v in deps.items\\(\\) if 'navigation' in k or 'tab' in k.lower\\(\\)]\")",
|
||||
"Bash(npx tsc *)",
|
||||
"Bash(echo \"EXIT: $?\")",
|
||||
"Bash(npm view *)",
|
||||
"Bash(npx patch-package *)",
|
||||
"Bash(rm -rf node_modules/expo-router)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(python -c \"import struct, os; paths=['D:/codes/carrot_bbs/frontend/assets/splash-icon.png','D:/codes/carrot_bbs/frontend/assets/icon.png','D:/codes/carrot_bbs/frontend/assets/android-icon-foreground.png','D:/codes/carrot_bbs/frontend/assets/android-icon-background.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png'];\\\\nfor p in paths:\\\\n try:\\\\n with open\\(p,'rb'\\) as f: data=f.read\\(33\\)\\\\n sig=data[:8]; w=h=None\\\\n if sig==b'\\\\\\\\x89PNG\\\\\\\\r\\\\\\\\n\\\\\\\\x1a\\\\\\\\n': w,h=struct.unpack\\('>II', data[16:24]\\)\\\\n print\\(f'{p} | exists={os.path.exists\\(p\\)} | bytes={os.path.getsize\\(p\\)} | png={sig==b\"\\\\\\\\x89PNG\\\\\\\\r\\\\\\\\n\\\\\\\\x1a\\\\\\\\n\"} | size={w}x{h}'\\)\\\\n except Exception as e:\\\\n print\\(f'{p} | ERROR {e}'\\)\")",
|
||||
"Bash(python -c 'import struct, os; paths=[\"D:/codes/carrot_bbs/frontend/assets/splash-icon.png\",\"D:/codes/carrot_bbs/frontend/assets/icon.png\",\"D:/codes/carrot_bbs/frontend/assets/android-icon-foreground.png\",\"D:/codes/carrot_bbs/frontend/assets/android-icon-background.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png\"]; sig_png=b\"\\\\x89PNG\\\\r\\\\n\\\\x1a\\\\n\"; [print\\(f\"{p} | bytes={os.path.getsize\\(p\\)} | png={open\\(p,\"rb\"\\).read\\(8\\)==sig_png} | size={struct.unpack\\(\">II\", open\\(p,\"rb\"\\).read\\(24\\)[16:24]\\) if open\\(p,\"rb\"\\).read\\(8\\)==sig_png else None}\"\\) for p in paths]')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,40 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Source control
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Expo caches
|
||||
.expo
|
||||
.expo-shared
|
||||
|
||||
# Build artifacts
|
||||
dist
|
||||
dist-web
|
||||
dist-android
|
||||
dist-ios
|
||||
dist-android-update.zip
|
||||
dist-ios-update.zip
|
||||
android
|
||||
ios
|
||||
web-build
|
||||
|
||||
# Native credentials (never ship into image)
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
|
||||
# Misc
|
||||
screenshots
|
||||
*.log
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
coverage
|
||||
__tests__
|
||||
|
||||
@@ -21,17 +21,18 @@ on:
|
||||
env:
|
||||
REGISTRY: code.littlelan.cn
|
||||
IMAGE_NAME: carrot_bbs/frontend-web
|
||||
OTA_PLATFORM_ANDROID: android
|
||||
OTA_PLATFORM_IOS: ios
|
||||
OTA_PUBLISH_URL: https://updates.littlelan.cn/admin/publish
|
||||
OTA_MANIFEST_URL: https://updates.littlelan.cn/api/manifest
|
||||
|
||||
jobs:
|
||||
ota-android:
|
||||
ota:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_ota == 'true')
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [android, ios]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -40,13 +41,6 @@ jobs:
|
||||
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
|
||||
@@ -54,121 +48,48 @@ jobs:
|
||||
- name: Resolve runtime version
|
||||
id: runtime
|
||||
run: |
|
||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
|
||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.runtimeVersion")"
|
||||
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
||||
|
||||
- name: Export Android update bundle
|
||||
- name: Export update bundle
|
||||
run: |
|
||||
rm -rf dist-android dist-android-update.zip
|
||||
npx expo export --platform android --output-dir dist-android
|
||||
npx expo config --type public --json > dist-android/expoConfig.json
|
||||
rm -rf dist-${{ matrix.platform }} dist-${{ matrix.platform }}-update.zip
|
||||
npx expo export --platform ${{ matrix.platform }} --output-dir dist-${{ matrix.platform }}
|
||||
npx expo config --type public --json > dist-${{ matrix.platform }}/expoConfig.json
|
||||
|
||||
- name: Archive Android update bundle
|
||||
- name: Archive update bundle
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile('dist-android-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, _, files in os.walk('dist-android'):
|
||||
python -c "
|
||||
import os, zipfile
|
||||
p = '${{ matrix.platform }}'
|
||||
dist = f'dist-{p}'
|
||||
with zipfile.ZipFile(f'dist-{p}-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, _, files in os.walk(dist):
|
||||
for name in files:
|
||||
src = os.path.join(root, name)
|
||||
arc = os.path.relpath(src, 'dist-android')
|
||||
arc = os.path.relpath(src, dist)
|
||||
zf.write(src, arc)
|
||||
PY
|
||||
"
|
||||
|
||||
- name: Publish OTA
|
||||
env:
|
||||
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
||||
run: |
|
||||
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
||||
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${OTA_PLATFORM_ANDROID}" \
|
||||
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${{ matrix.platform }}" \
|
||||
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
||||
-H "Content-Type: application/zip" \
|
||||
--data-binary @"dist-android-update.zip"
|
||||
--data-binary @"dist-${{ matrix.platform }}-update.zip"
|
||||
|
||||
- name: Verify OTA manifest
|
||||
run: |
|
||||
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
|
||||
-H "expo-platform: ${OTA_PLATFORM_ANDROID}" \
|
||||
-H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
|
||||
-H "expo-protocol-version: 1" \
|
||||
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\\\"id\\\":.*\\\"extra\\\":\\{.*\\}\\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
|
||||
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-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-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 "Local bundle: ${LOCAL}"
|
||||
test "${REMOTE}" = "${LOCAL}"
|
||||
@@ -178,12 +99,9 @@ jobs:
|
||||
container:
|
||||
image: reactnativecommunity/react-native-android:latest
|
||||
env:
|
||||
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=4 -Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
|
||||
_JAVA_OPTIONS: "-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
|
||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=false -Dorg.gradle.workers.max=1"
|
||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||
NODE_ENV: "production"
|
||||
NDK_NUM_JOBS: "4"
|
||||
CMAKE_BUILD_PARALLEL_LEVEL: "4"
|
||||
GRADLE_USER_HOME: /root/.gradle
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -201,86 +119,25 @@ jobs:
|
||||
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: Cache Android NDK
|
||||
uses: actions/cache@v4
|
||||
id: cache-ndk
|
||||
with:
|
||||
path: /opt/android/ndk/27.0.12077973
|
||||
key: ndk-27.0.12077973-v1
|
||||
|
||||
- name: Install Android NDK
|
||||
if: steps.cache-ndk.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
echo "Existing NDK versions:"
|
||||
ls /opt/android/ndk/ 2>/dev/null || echo "No NDK dir"
|
||||
echo "Installing NDK 27.0.12077973..."
|
||||
yes | sdkmanager --install "ndk;27.0.12077973"
|
||||
echo "NDK after install:"
|
||||
ls /opt/android/ndk/
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
~/.android/build-cache
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-node-modules
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-modules-
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Generate Android native project
|
||||
run: npx expo prebuild --platform android
|
||||
|
||||
- name: Switch Gradle distribution to Tencent mirror
|
||||
run: |
|
||||
PROPS="android/gradle/wrapper/gradle-wrapper.properties"
|
||||
if [ -f "$PROPS" ]; then
|
||||
sed -i 's|distributionUrl=https\\://services.gradle.org/distributions/|distributionUrl=https\\://mirrors.cloud.tencent.com/gradle/|' "$PROPS"
|
||||
echo "Updated gradle-wrapper.properties:"
|
||||
cat "$PROPS"
|
||||
else
|
||||
echo "gradle-wrapper.properties not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Decode Android signing keystore
|
||||
run: |
|
||||
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/app/withyou-release-key.keystore
|
||||
|
||||
- name: Configure Gradle with China Maven mirrors
|
||||
- name: Configure Gradle with signing
|
||||
run: |
|
||||
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'
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://developer.huawei.com/repo/' }
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
@@ -309,6 +166,15 @@ jobs:
|
||||
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 ->
|
||||
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
|
||||
ex.autolinkLibrariesFromCommand()
|
||||
@@ -326,21 +192,22 @@ jobs:
|
||||
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
||||
SETTINGS_EOF
|
||||
|
||||
# Update build.gradle with China Maven mirrors
|
||||
# Update build.gradle
|
||||
cat > build.gradle << 'BUILD_EOF'
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
maven { url 'https://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/' }
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath('com.android.tools.build:gradle')
|
||||
classpath('com.huawei.agconnect:agcp:1.9.1.301') {
|
||||
exclude group: 'com.huawei.agconnect', module: 'agconnect-apms-plugin'
|
||||
}
|
||||
classpath('com.google.gms:google-services:4.4.4')
|
||||
classpath('com.android.tools.build:gradle:8.12.0')
|
||||
classpath('com.facebook.react:react-native-gradle-plugin')
|
||||
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
|
||||
}
|
||||
@@ -348,12 +215,10 @@ jobs:
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://www.jitpack.io' }
|
||||
maven { url 'https://developer.huawei.com/repo/' }
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url 'https://www.jitpack.io' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,13 +226,14 @@ jobs:
|
||||
apply plugin: "com.facebook.react.rootproject"
|
||||
BUILD_EOF
|
||||
|
||||
# Update gradle.properties
|
||||
# Update gradle.properties (without secrets for better cache hit rate)
|
||||
cat > gradle.properties << 'PROPS_EOF'
|
||||
org.gradle.daemon=false
|
||||
org.gradle.parallel=true
|
||||
org.gradle.configureondemand=true
|
||||
org.gradle.workers.max=4
|
||||
org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.parallel=false
|
||||
org.gradle.configureondemand=false
|
||||
org.gradle.workers.max=1
|
||||
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
kotlin.daemon.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m
|
||||
android.enableJetifier=false
|
||||
android.useAndroidX=true
|
||||
hermesEnabled=true
|
||||
@@ -379,11 +245,11 @@ jobs:
|
||||
expo.webp.animated=false
|
||||
ndkVersion=27.1.12297006
|
||||
expo.useLegacyPackaging=false
|
||||
systemProp.org.gradle.internal.http.connectionTimeout=30000
|
||||
systemProp.org.gradle.internal.http.socketTimeout=30000
|
||||
PROPS_EOF
|
||||
|
||||
- name: Configure Gradle signing properties
|
||||
run: |
|
||||
cd android
|
||||
# Append signing properties (secrets appended, not cached)
|
||||
cat >> gradle.properties << SIGNING_PROPS
|
||||
MYAPP_UPLOAD_STORE_FILE=withyou-release-key.keystore
|
||||
MYAPP_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
@@ -391,11 +257,15 @@ jobs:
|
||||
MYAPP_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
SIGNING_PROPS
|
||||
|
||||
# Verify signing config in app/build.gradle
|
||||
echo "=== app/build.gradle signing section ==="
|
||||
grep -A 20 'signingConfigs' app/build.gradle || echo "signingConfigs not found"
|
||||
|
||||
- name: Build Android release APK (arm64 only)
|
||||
run: |
|
||||
cd android
|
||||
chmod +x gradlew
|
||||
./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=4 --parallel
|
||||
taskset -c 0-7 ./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=1 -Dkotlin.daemon.jvm.options="-Xmx2g,XX:MaxMetaspaceSize=1g"
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
@@ -406,7 +276,7 @@ jobs:
|
||||
- name: Resolve runtime version
|
||||
id: runtime
|
||||
run: |
|
||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
|
||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.runtimeVersion")"
|
||||
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
||||
|
||||
@@ -481,8 +351,6 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
provenance: false
|
||||
cache-from: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache
|
||||
cache-to: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache,mode=max
|
||||
|
||||
- name: Show image tags
|
||||
run: |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS builder
|
||||
FROM node:25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -6,7 +6,7 @@ COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npx expo export --platform web --output-dir dist-web
|
||||
RUN node /app/node_modules/.bin/expo export --platform web --output-dir dist-web
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
|
||||
@@ -6,15 +6,21 @@ const releaseApiBaseUrl = 'https://withyou.littlelan.cn/api/v1';
|
||||
const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
|
||||
const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1';
|
||||
|
||||
function getGitShortHash() {
|
||||
function getCommitCount() {
|
||||
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 {
|
||||
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) {
|
||||
const parsed = new URL(baseUrl);
|
||||
@@ -60,9 +66,9 @@ const filteredPlugins = isWeb
|
||||
module.exports = {
|
||||
...expo,
|
||||
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
||||
runtimeVersion: {
|
||||
policy: 'appVersion',
|
||||
},
|
||||
// runtimeVersion 用 build number(commit count + 偏移):单调递增、纯数字、与 version (语义版本) 解耦
|
||||
// 字符串形式,等价于 policy: 'custom'
|
||||
runtimeVersion: buildNumber,
|
||||
updates: {
|
||||
...(expo.updates || {}),
|
||||
url: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
||||
@@ -71,12 +77,12 @@ module.exports = {
|
||||
},
|
||||
ios: {
|
||||
...expo.ios,
|
||||
buildNumber: gitBuildSuffix,
|
||||
buildNumber: buildNumber,
|
||||
},
|
||||
android: {
|
||||
...expo.android,
|
||||
package: 'cn.qczlit.withyou',
|
||||
versionCode: parseInt(gitBuildSuffix, 16),
|
||||
versionCode: parseInt(buildNumber, 10),
|
||||
},
|
||||
// Web 端使用过滤后的插件
|
||||
plugins: filteredPlugins,
|
||||
@@ -95,7 +101,8 @@ module.exports = {
|
||||
// 配置 Web 端别名,避免加载原生模块
|
||||
babel: {
|
||||
dangerouslyAddModulePathsToTranspile: [
|
||||
'react-native-webrtc',
|
||||
'livekit-client',
|
||||
'@livekit',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
31
app.json
31
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "威友",
|
||||
"slug": "qojo",
|
||||
"version": "0.0.2",
|
||||
"version": "1.0.1",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -16,9 +16,8 @@
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"NSMicrophoneUsageDescription": "允许威友访问您的麦克风以进行语音通话",
|
||||
"NSCameraUsageDescription": "允许威友访问您的相机以进行视频通话",
|
||||
"UIBackgroundModes": [
|
||||
"fetch",
|
||||
"remote-notification",
|
||||
"fetch",
|
||||
"remote-notification",
|
||||
"audio"
|
||||
@@ -28,6 +27,7 @@
|
||||
"bundleIdentifier": "cn.qczlit.weiyou"
|
||||
},
|
||||
"android": {
|
||||
"googleServicesFile": "./google-services.json",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#0570F9",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
@@ -35,12 +35,11 @@
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"jsEngine": "hermes",
|
||||
"package": "cn.qczlit.withyou",
|
||||
"versionCode": 7,
|
||||
"permissions": [
|
||||
"VIBRATE",
|
||||
"RECORD_AUDIO",
|
||||
"CAMERA",
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
"WAKE_LOCK",
|
||||
"READ_EXTERNAL_STORAGE",
|
||||
@@ -53,7 +52,6 @@
|
||||
"android.permission.READ_EXTERNAL_STORAGE",
|
||||
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
|
||||
"android.permission.ACCESS_MEDIA_LOCATION",
|
||||
"android.permission.READ_MEDIA_IMAGES",
|
||||
"android.permission.READ_MEDIA_VIDEO",
|
||||
"android.permission.READ_MEDIA_AUDIO",
|
||||
@@ -66,6 +64,8 @@
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"./plugins/withCmakeJobLimit",
|
||||
"./plugins/withJcorePatch",
|
||||
"./plugins/withSigning",
|
||||
"./plugins/withMainActivityConfigChange",
|
||||
[
|
||||
@@ -100,12 +100,13 @@
|
||||
"minimumInterval": 15
|
||||
}
|
||||
],
|
||||
"./plugins/withRemoveAutoStart",
|
||||
[
|
||||
"expo-media-library",
|
||||
{
|
||||
"photosPermission": "允许威友访问您的照片以发布内容",
|
||||
"savePhotosPermission": "允许威友保存照片到您的相册",
|
||||
"isAccessMediaLocationEnabled": true
|
||||
"isAccessMediaLocationEnabled": false
|
||||
}
|
||||
],
|
||||
[
|
||||
@@ -153,7 +154,21 @@
|
||||
"channel": "developer-default"
|
||||
}
|
||||
],
|
||||
"./plugins/withHuaweiPush"
|
||||
"./plugins/withHuaweiPush",
|
||||
"./plugins/withXiaomiPush",
|
||||
"./plugins/withHonorPush",
|
||||
"./plugins/withVivoPush",
|
||||
"./plugins/withOppoPush",
|
||||
"expo-callkit-telecom",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
],
|
||||
"expo-status-bar"
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { Platform, useWindowDimensions } from 'react-native';
|
||||
import { Tabs, usePathname } from 'expo-router';
|
||||
import { Platform, Pressable, useWindowDimensions } from 'react-native';
|
||||
import { Tabs, usePathname, useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import type { PressableProps } from 'react-native';
|
||||
|
||||
import { useAppColors, shadows } from '../../../src/theme';
|
||||
import { BREAKPOINTS } from '../../../src/hooks';
|
||||
import { useHomeTabBarVisibilityStore, useTotalUnreadCount, useHomeTabPressStore } from '../../../src/stores';
|
||||
import { hrefHome } from '../../../src/navigation/hrefs';
|
||||
|
||||
const TAB_BAR_HEIGHT = 56;
|
||||
const TAB_BAR_FLOATING_MARGIN = 12;
|
||||
const TAB_BAR_MARGIN = 20;
|
||||
|
||||
const TabBarButton = ({ children, style, ...rest }: PressableProps) => (
|
||||
<Pressable
|
||||
{...rest}
|
||||
style={style}
|
||||
android_ripple={null}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
export default function TabsLayout() {
|
||||
const colors = useAppColors();
|
||||
const { width } = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const unreadCount = useTotalUnreadCount();
|
||||
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
||||
const triggerHomeTabPress = useHomeTabPressStore((s) => s.triggerPress);
|
||||
@@ -24,11 +37,30 @@ export default function TabsLayout() {
|
||||
|
||||
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
|
||||
|
||||
const handleHomeTabPress = useCallback(() => {
|
||||
if (pathname === '/home') {
|
||||
triggerHomeTabPress();
|
||||
const handleHomeTabPress = useCallback(
|
||||
(e: { preventDefault: () => void }) => {
|
||||
if (!isHomeStackRoute) return;
|
||||
e.preventDefault();
|
||||
if (pathname === '/home') {
|
||||
triggerHomeTabPress();
|
||||
} else {
|
||||
router.navigate(hrefHome());
|
||||
}
|
||||
},
|
||||
[isHomeStackRoute, pathname, triggerHomeTabPress, router]
|
||||
);
|
||||
|
||||
const handleAppsTabPress = useCallback(() => {
|
||||
if (pathname.startsWith('/apps/')) {
|
||||
router.replace('/apps');
|
||||
}
|
||||
}, [pathname, triggerHomeTabPress]);
|
||||
}, [pathname, router]);
|
||||
|
||||
const handleProfileTabPress = useCallback(() => {
|
||||
if (pathname.startsWith('/profile/')) {
|
||||
router.replace('/profile');
|
||||
}
|
||||
}, [pathname, router]);
|
||||
|
||||
const tabBarStyle = useMemo(() => {
|
||||
if (hideTabBar) {
|
||||
@@ -72,6 +104,7 @@ export default function TabsLayout() {
|
||||
marginHorizontal: 2,
|
||||
paddingVertical: 1,
|
||||
},
|
||||
tabBarButton: (props) => <TabBarButton {...props} />,
|
||||
tabBarShowLabel: true,
|
||||
tabBarLabelStyle: { fontSize: 11, fontWeight: '600', marginTop: -1 },
|
||||
}}
|
||||
@@ -104,6 +137,9 @@ export default function TabsLayout() {
|
||||
/>
|
||||
),
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: handleAppsTabPress,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="messages"
|
||||
@@ -131,6 +167,9 @@ export default function TabsLayout() {
|
||||
/>
|
||||
),
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: handleProfileTabPress,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@@ -17,8 +17,6 @@ export default function ProfileStackLayout() {
|
||||
<Stack.Screen name="blocked-users" />
|
||||
<Stack.Screen name="chat-settings" />
|
||||
<Stack.Screen name="about" />
|
||||
<Stack.Screen name="terms" />
|
||||
<Stack.Screen name="privacy" />
|
||||
<Stack.Screen name="verification" />
|
||||
<Stack.Screen name="data-storage" />
|
||||
<Stack.Screen name="privacy-settings" />
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { PrivacyPolicyScreen } from '../../../../src/screens/profile/PrivacyPolicyScreen';
|
||||
|
||||
export default function PrivacyPolicyRoute() {
|
||||
return <PrivacyPolicyScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { TermsOfServiceScreen } from '../../../../src/screens/profile/TermsOfServiceScreen';
|
||||
|
||||
export default function TermsOfServiceRoute() {
|
||||
return <TermsOfServiceScreen />;
|
||||
}
|
||||
@@ -1,83 +1,54 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, View } from 'react-native';
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
|
||||
import { messageManager, useAuthStore } from '../../src/stores';
|
||||
import { jpushService } from '../../src/services/notification/jpushService';
|
||||
|
||||
function getDeviceType(): 'ios' | 'android' | 'web' {
|
||||
switch (Platform.OS) {
|
||||
case 'ios': return 'ios';
|
||||
case 'android': return 'android';
|
||||
default: return 'web';
|
||||
}
|
||||
}
|
||||
|
||||
async function getOrCreateDeviceID(): Promise<string> {
|
||||
try {
|
||||
const AsyncStorage = require('@react-native-async-storage/async-storage').default;
|
||||
const DEVICE_ID_KEY = 'withyou_device_id';
|
||||
let deviceId = await AsyncStorage.getItem(DEVICE_ID_KEY);
|
||||
if (deviceId) return deviceId;
|
||||
const timestamp = Date.now().toString(36);
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
deviceId = `${Platform.OS}_${timestamp}_${random}`;
|
||||
await AsyncStorage.setItem(DEVICE_ID_KEY, deviceId);
|
||||
return deviceId;
|
||||
} catch {
|
||||
return `${Platform.OS}_${Date.now()}`;
|
||||
}
|
||||
}
|
||||
import { useRegisterPushDevice } from '../../src/hooks';
|
||||
import { hrefAuthLogin } from '../../src/navigation/hrefs';
|
||||
import { useAppColors } from '../../src/theme';
|
||||
|
||||
export default function AppLayout() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
|
||||
const userID = useAuthStore((s) => s.currentUser?.id);
|
||||
const deviceRegistered = useRef(false);
|
||||
const colors = useAppColors();
|
||||
const [verified, setVerified] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 冷启动 token 校验:仅鉴权分组 (app) 需要。
|
||||
// 公开页(/privacy、/terms)和 (auth) 分组在根布局直接渲染,不受此校验影响,
|
||||
// 因此冷启动 401 不会把公开页顶到 /login。
|
||||
fetchCurrentUser().finally(() => setVerified(true));
|
||||
}, [fetchCurrentUser]);
|
||||
|
||||
useRegisterPushDevice(isAuthenticated, userID);
|
||||
|
||||
useEffect(() => {
|
||||
messageManager.initialize();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !userID) return;
|
||||
|
||||
if (!deviceRegistered.current) {
|
||||
deviceRegistered.current = true;
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
// Web: register device without push_token (JPush not available on web)
|
||||
getOrCreateDeviceID().then((deviceId) => {
|
||||
const { pushService } = require('../../src/services/notification/pushService');
|
||||
pushService.registerDevice({
|
||||
device_id: deviceId,
|
||||
device_type: 'web',
|
||||
device_name: 'Web Browser',
|
||||
}).catch((err: any) => {
|
||||
console.warn('[Push] web device register failed:', err?.message);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Mobile: JPush handles registration internally in initialize()
|
||||
jpushService.initialize().then((ok) => {
|
||||
if (ok && userID) {
|
||||
jpushService.setAliasForUser(userID);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!isAuthenticated) {
|
||||
deviceRegistered.current = false;
|
||||
jpushService.cleanup();
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated, userID]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Redirect href="/login" />;
|
||||
// 持久化状态显示已登录则立即渲染(后台静默校验)
|
||||
if (isAuthenticated) {
|
||||
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,28 +1,56 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useWindowDimensions } from 'react-native';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, View, useWindowDimensions } from 'react-native';
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell';
|
||||
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
|
||||
import { BREAKPOINTS } from '../../src/hooks';
|
||||
import { messageManager, useAuthStore } from '../../src/stores';
|
||||
import { hrefAuthLogin } from '../../src/navigation/hrefs';
|
||||
import { useAppColors } from '../../src/theme';
|
||||
|
||||
export default function AppLayoutWeb() {
|
||||
const { width } = useWindowDimensions();
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
|
||||
const useDesktopShell = width >= BREAKPOINTS.desktop;
|
||||
const colors = useAppColors();
|
||||
const [verified, setVerified] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 冷启动 token 校验:仅鉴权分组 (app) 需要。
|
||||
// 公开页(/privacy、/terms)和 (auth) 分组在根布局直接渲染,不受此校验影响。
|
||||
fetchCurrentUser().finally(() => setVerified(true));
|
||||
}, [fetchCurrentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
messageManager.initialize();
|
||||
}, []);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Redirect href="/login" />;
|
||||
// 持久化状态显示已登录则立即渲染(后台静默校验)
|
||||
if (isAuthenticated) {
|
||||
if (useDesktopShell) {
|
||||
return <AppDesktopShell />;
|
||||
}
|
||||
return <AppRouteStack />;
|
||||
}
|
||||
|
||||
if (useDesktopShell) {
|
||||
return <AppDesktopShell />;
|
||||
// 未登录且校验未完成,显示 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 <AppRouteStack />;
|
||||
// 校验完成仍未登录,跳转登录页
|
||||
return <Redirect href={hrefAuthLogin()} />;
|
||||
}
|
||||
|
||||
5
app/(app)/chat/message-search.tsx
Normal file
5
app/(app)/chat/message-search.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { MessageSearchScreen } from '../../../src/screens/message';
|
||||
|
||||
export default function MessageSearchRoute() {
|
||||
return <MessageSearchScreen />;
|
||||
}
|
||||
135
app/_layout.tsx
135
app/_layout.tsx
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import '../src/polyfills';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { AppState, AppStateStatus, Platform, View, ActivityIndicator } from 'react-native';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
@@ -10,8 +11,6 @@ import * as SystemUI from 'expo-system-ui';
|
||||
import { useFonts } from 'expo-font';
|
||||
|
||||
import { registerNotificationPresentationHandler } from '@/services/notification';
|
||||
import { api } from '@/services/core';
|
||||
import { wsService } from '@/services/core';
|
||||
import { EventSubscriber } from '../src/infrastructure/EventSubscriber';
|
||||
import {
|
||||
ThemeBootstrap,
|
||||
@@ -23,10 +22,12 @@ import {
|
||||
import AppPromptBar from '../src/components/common/AppPromptBar';
|
||||
import AppDialogHost from '../src/components/common/AppDialogHost';
|
||||
import { installAlertOverride } from '@/services/ui';
|
||||
import { useAuthStore } from '../src/stores';
|
||||
import { checkForAPKUpdate } from '@/services/platform';
|
||||
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
|
||||
import { jpushService } from '@/services/notification/jpushService';
|
||||
import { callKeepService } from '@/services/callkeep';
|
||||
import { callStore } from '@/stores/call';
|
||||
import * as hrefs from '../src/navigation/hrefs';
|
||||
|
||||
registerNotificationPresentationHandler();
|
||||
|
||||
@@ -100,32 +101,6 @@ function SystemChrome() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function SessionGate({ children }: { children: React.ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
|
||||
const colors = useAppColors();
|
||||
|
||||
useEffect(() => {
|
||||
fetchCurrentUser().finally(() => setReady(true));
|
||||
}, [fetchCurrentUser]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function NotificationBootstrap() {
|
||||
const appState = useRef<AppStateStatus>(AppState.currentState);
|
||||
const permissionRequested = useRef(false);
|
||||
@@ -138,6 +113,7 @@ function NotificationBootstrap() {
|
||||
const { systemNotificationService } = await import('@/services/notification');
|
||||
await systemNotificationService.initialize();
|
||||
const { initBackgroundService } = await import('@/services/background');
|
||||
// 默认静默模式,不会注册后台任务,不会触发自启动
|
||||
await initBackgroundService();
|
||||
|
||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||
@@ -165,22 +141,21 @@ function NotificationBootstrap() {
|
||||
|
||||
if (conversationId) {
|
||||
const isGroup = conversationType === 'group';
|
||||
const q = new URLSearchParams();
|
||||
if (isGroup) {
|
||||
q.set('isGroupChat', '1');
|
||||
if (groupId) q.set('groupId', groupId);
|
||||
if (groupName) q.set('groupName', groupName);
|
||||
if (groupAvatar) q.set('groupAvatar', groupAvatar);
|
||||
} else if (senderId) {
|
||||
q.set('userId', senderId);
|
||||
}
|
||||
const qs = q.toString();
|
||||
router.push(`/chat/${encodeURIComponent(conversationId)}${qs ? `?${qs}` : ''}`);
|
||||
router.push(
|
||||
hrefs.hrefChat({
|
||||
conversationId,
|
||||
userId: isGroup ? undefined : senderId,
|
||||
isGroupChat: isGroup,
|
||||
groupId: isGroup ? groupId : undefined,
|
||||
groupName: isGroup ? groupName : undefined,
|
||||
groupAvatar: isGroup ? groupAvatar : undefined,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
router.push('/messages/notifications');
|
||||
router.push(hrefs.hrefNotifications());
|
||||
}
|
||||
} else {
|
||||
router.push('/messages/notifications');
|
||||
router.push(hrefs.hrefNotifications());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -232,6 +207,37 @@ function APKUpdateBootstrap() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function CallKeepBootstrap() {
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'web') return;
|
||||
|
||||
callKeepService.initialize({
|
||||
onSystemAnswered: () => {
|
||||
callStore.getState().handleSystemAnswer();
|
||||
},
|
||||
onSystemEnded: () => {
|
||||
callStore.getState().endCall('ended');
|
||||
},
|
||||
onSystemMuted: (muted: boolean) => {
|
||||
callStore.getState().handleSystemMuted(muted);
|
||||
},
|
||||
onIncomingCallFromPush: (session) => {
|
||||
callStore.getState().handleIncomingFromPush(session);
|
||||
},
|
||||
onOutgoingStarted: () => {
|
||||
// Outgoing call accepted by system — no action needed,
|
||||
// the WS call_invited / call_accepted flow handles everything
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
callKeepService.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ThemedStack() {
|
||||
const router = useRouter();
|
||||
const resolved = useResolvedColorScheme();
|
||||
@@ -241,39 +247,16 @@ function ThemedStack() {
|
||||
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
||||
<SystemChrome />
|
||||
<EventSubscriber />
|
||||
<SessionGate>
|
||||
<NotificationBootstrap />
|
||||
<APKUpdateBootstrap />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(app)" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="terms"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="privacy"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="post/[postId]"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="user/[userId]"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</SessionGate>
|
||||
<NotificationBootstrap />
|
||||
<APKUpdateBootstrap />
|
||||
<CallKeepBootstrap />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(app)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="privacy" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="terms" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
import { useAuthStore } from '../src/stores';
|
||||
import { hrefAuthWelcome, hrefHome } from '../src/navigation/hrefs';
|
||||
|
||||
export default function Index() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
if (isAuthenticated) {
|
||||
return <Redirect href="/home" />;
|
||||
return <Redirect href={hrefHome()} />;
|
||||
}
|
||||
return <Redirect href="/welcome" />;
|
||||
return <Redirect href={hrefAuthWelcome()} />;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 20 KiB |
23
google-services.json
Normal file
23
google-services.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "000000000000",
|
||||
"project_id": "withyou-dummy",
|
||||
"storage_bucket": "withyou-dummy.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
|
||||
"android_client_info": {
|
||||
"package_name": "cn.qczlit.withyou"
|
||||
}
|
||||
},
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDummyKeyForFirebaseInitOnly"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -7,10 +7,6 @@ const config = getDefaultConfig(__dirname);
|
||||
// Add wasm asset support
|
||||
config.resolver.assetExts.push('wasm');
|
||||
|
||||
// Fix for react-native-webrtc event-target-shim import warning
|
||||
// The package doesn't properly export "./index" in its exports field
|
||||
config.resolver.unstable_enablePackageExports = false;
|
||||
|
||||
// Support TypeScript path aliases (@/ -> ./src/)
|
||||
config.resolver.alias = {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
@@ -36,7 +32,6 @@ const webShimPaths = [
|
||||
// These packages import requireNativeComponent directly from 'react-native'
|
||||
// (which resolves to react-native-web on web) and don't have web implementations.
|
||||
const webPackageShims = {
|
||||
'react-native-webrtc': path.resolve(__dirname, 'web-shims/react-native-webrtc/index.js'),
|
||||
};
|
||||
|
||||
const originalResolveRequest = config.resolver.resolveRequest;
|
||||
|
||||
6673
package-lock.json
generated
6673
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
119
package.json
119
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "with_you",
|
||||
"version": "0.0.2",
|
||||
"version": "1.0.1",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -14,65 +14,78 @@
|
||||
"ios:submit": "eas submit --platform ios --profile production"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/ui": "~56.0.17",
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.15.2",
|
||||
"@react-navigation/material-top-tabs": "^7.4.16",
|
||||
"@react-navigation/native": "^7.1.31",
|
||||
"@react-navigation/native-stack": "^7.14.2",
|
||||
"@livekit/react-native": "^2.11.0",
|
||||
"@livekit/react-native-webrtc": "^144.1.0",
|
||||
"@react-native-async-storage/async-storage": "^3.1.1",
|
||||
"@shopify/flash-list": "^2.3.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"axios": "^1.13.6",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~55.0.4",
|
||||
"expo-background-task": "~55.0.10",
|
||||
"expo-camera": "^55.0.10",
|
||||
"expo-constants": "~55.0.7",
|
||||
"expo-dev-client": "~55.0.10",
|
||||
"expo-file-system": "~55.0.10",
|
||||
"expo-font": "~55.0.4",
|
||||
"expo-haptics": "~55.0.8",
|
||||
"expo-image": "^55.0.5",
|
||||
"expo-image-picker": "^55.0.10",
|
||||
"expo-intent-launcher": "~55.0.9",
|
||||
"expo-linear-gradient": "^55.0.8",
|
||||
"expo-media-library": "~55.0.9",
|
||||
"expo-notifications": "~55.0.0",
|
||||
"expo-router": "^55.0.4",
|
||||
"expo-sqlite": "~55.0.10",
|
||||
"expo-status-bar": "~55.0.4",
|
||||
"expo-system-ui": "^55.0.9",
|
||||
"expo-task-manager": "~55.0.9",
|
||||
"expo-updates": "^55.0.12",
|
||||
"expo-video": "^55.0.10",
|
||||
"jcore-react-native": "^2.3.5",
|
||||
"jpush-react-native": "^3.2.6",
|
||||
"katex": "^0.16.42",
|
||||
"markdown-it": "^14.1.1",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@types/react": "~19.2.14",
|
||||
"axios": "^1.16.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"expo": "~56.0.11",
|
||||
"expo-background-task": "~56.0.18",
|
||||
"expo-callkit-telecom": "^0.3.9",
|
||||
"expo-camera": "~56.0.8",
|
||||
"expo-constants": "~56.0.18",
|
||||
"expo-dev-client": "~56.0.20",
|
||||
"expo-document-picker": "~56.0.4",
|
||||
"expo-file-system": "~56.0.8",
|
||||
"expo-font": "~56.0.6",
|
||||
"expo-haptics": "~56.0.3",
|
||||
"expo-image": "~56.0.6",
|
||||
"expo-image-picker": "~56.0.17",
|
||||
"expo-intent-launcher": "~56.0.4",
|
||||
"expo-linear-gradient": "~56.0.4",
|
||||
"expo-linking": "~56.0.14",
|
||||
"expo-media-library": "~56.0.7",
|
||||
"expo-notifications": "~56.0.17",
|
||||
"expo-router": "~56.2.10",
|
||||
"expo-splash-screen": "~56.0.10",
|
||||
"expo-sqlite": "~56.0.5",
|
||||
"expo-status-bar": "~56.0.4",
|
||||
"expo-system-ui": "~56.0.5",
|
||||
"expo-task-manager": "~56.0.18",
|
||||
"expo-updates": "~56.0.19",
|
||||
"expo-video": "~56.1.3",
|
||||
"jcore-react-native": "^2.3.6",
|
||||
"jpush-react-native": "^3.2.7",
|
||||
"katex": "^0.17.0",
|
||||
"livekit-client": "^2.19.2",
|
||||
"markdown-it": "^14.2.0",
|
||||
"pako": "^2.1.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.2",
|
||||
"react-native-gesture-handler": "^2.30.0",
|
||||
"react-native-pager-view": "^8.0.0",
|
||||
"react-native-paper": "^5.15.0",
|
||||
"react-native-reanimated": "^4.2.1",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-share": "^12.2.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.85.3",
|
||||
"react-native-gesture-handler": "^3.0.0",
|
||||
"react-native-pager-view": "^8.0.2",
|
||||
"react-native-paper": "^5.15.3",
|
||||
"react-native-reanimated": "~4.3.1",
|
||||
"react-native-safe-area-context": "~5.8.0",
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-share": "^12.3.1",
|
||||
"react-native-sse": "^1.2.1",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webrtc": "^124.0.7",
|
||||
"react-native-webview": "13.16.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
"react-native-webview": "13.16.1",
|
||||
"react-native-worklets": "^0.8.3",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-native-community/cli": "^20.1.2",
|
||||
"@types/react": "~19.2.2",
|
||||
"@react-native-community/cli": "^20.1.3",
|
||||
"@types/pako": "^2.0.4",
|
||||
"@types/react": "~19.2.16",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"typescript": "~5.9.2"
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"private": true
|
||||
"private": true,
|
||||
"expo": {
|
||||
"install": {
|
||||
"exclude": [
|
||||
"expo-image"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
package/android/expo-gradle-plugin/build.gradle.kts
Normal file
4
package/android/expo-gradle-plugin/build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
kotlin("jvm") version "2.1.20" apply false
|
||||
id("java-gradle-plugin")
|
||||
}
|
||||
22
package/gradle/libs.versions.toml
Normal file
22
package/gradle/libs.versions.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[versions]
|
||||
agp = "8.12.0"
|
||||
gson = "2.8.9"
|
||||
guava = "31.0.1-jre"
|
||||
javapoet = "1.13.0"
|
||||
junit = "4.13.2"
|
||||
kotlin = "2.1.20"
|
||||
assertj = "3.25.1"
|
||||
ktfmt = "0.22.0"
|
||||
|
||||
[libraries]
|
||||
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
||||
android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
|
||||
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
|
||||
guava = { module = "com.google.guava:guava", version.ref = "guava" }
|
||||
javapoet = { module = "com.squareup:javapoet", version.ref = "javapoet" }
|
||||
junit = {module = "junit:junit", version.ref = "junit" }
|
||||
assertj = { module = "org.assertj:assertj-core", version.ref = "assertj" }
|
||||
|
||||
[plugins]
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" }
|
||||
7
package/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
package/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
83
package/react-native-gradle-plugin/build.gradle.kts
Normal file
83
package/react-native-gradle-plugin/build.gradle.kts
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.ktfmt)
|
||||
id("java-gradle-plugin")
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
gradlePlugin {
|
||||
plugins {
|
||||
create("react") {
|
||||
id = "com.facebook.react"
|
||||
implementationClass = "com.facebook.react.ReactPlugin"
|
||||
}
|
||||
create("reactrootproject") {
|
||||
id = "com.facebook.react.rootproject"
|
||||
implementationClass = "com.facebook.react.ReactRootProjectPlugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group = "com.facebook.react"
|
||||
|
||||
dependencies {
|
||||
implementation(project(":shared"))
|
||||
|
||||
implementation(gradleApi())
|
||||
|
||||
// The KGP/AGP version is defined by React Native Gradle plugin.
|
||||
// Therefore we specify an implementation dep rather than a compileOnly.
|
||||
implementation(libs.kotlin.gradle.plugin)
|
||||
implementation(libs.android.gradle.plugin)
|
||||
|
||||
implementation(libs.gson)
|
||||
implementation(libs.guava)
|
||||
implementation(libs.javapoet)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.assertj)
|
||||
testImplementation(project(":shared-testutil"))
|
||||
}
|
||||
|
||||
// We intentionally don't build for Java 17 as users will see a cryptic bytecode version
|
||||
// error first. Instead we produce a Java 11-compatible Gradle Plugin, so that AGP can print their
|
||||
// nice message showing that JDK 11 (or 17) is required first
|
||||
java { targetCompatibility = JavaVersion.VERSION_11 }
|
||||
|
||||
kotlin { jvmToolchain(17) }
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
apiVersion.set(KotlinVersion.KOTLIN_1_8)
|
||||
// See comment above on JDK 11 support
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
allWarningsAsErrors.set(
|
||||
project.properties["enableWarningsAsErrors"]?.toString()?.toBoolean() ?: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
testLogging {
|
||||
exceptionFormat = TestExceptionFormat.FULL
|
||||
showExceptions = true
|
||||
showCauses = true
|
||||
showStackTraces = true
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -1,6 +1,7 @@
|
||||
const {
|
||||
withAppBuildGradle,
|
||||
withProjectBuildGradle,
|
||||
withSettingsGradle,
|
||||
withDangerousMod,
|
||||
withAndroidManifest,
|
||||
} = require('@expo/config-plugins');
|
||||
@@ -8,7 +9,52 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const withHuaweiPush = (config, options = {}) => {
|
||||
const { jpushVersion = '5.8.0' } = options;
|
||||
const { jpushVersion = '6.1.0' } = options;
|
||||
|
||||
config = withSettingsGradle(config, (config) => {
|
||||
const contents = config.modResults.contents;
|
||||
|
||||
// Add Huawei Maven repo to pluginManagement.repositories for buildscript resolution.
|
||||
// NOTE: We intentionally do NOT add `plugins { id 'com.huawei.agconnect' ... }` here.
|
||||
// The Huawei agcp plugin transitively depends on agconnect-apms-plugin:1.6.2.300,
|
||||
// which depends on AGP 4.0.1. That old AGP references BuildCompletionListener (removed
|
||||
// from Gradle 8.x+), causing NoClassDefFoundError at build time. Instead, the plugin
|
||||
// is applied via buildscript classpath in build.gradle with the apms-plugin excluded.
|
||||
if (!contents.includes('developer.huawei.com/repo')) {
|
||||
const repoLine = ` maven { url 'https://developer.huawei.com/repo/' }`;
|
||||
const hasPluginManagement = contents.includes('pluginManagement');
|
||||
const hasRepositoriesInPM = /pluginManagement\s*\{[\s\S]*?repositories\s*\{/.test(contents);
|
||||
|
||||
if (hasRepositoriesInPM) {
|
||||
config.modResults.contents = contents.replace(
|
||||
/(pluginManagement\s*\{[\s\S]*?repositories\s*\{)/,
|
||||
`$1\n ${repoLine}`
|
||||
);
|
||||
} else if (hasPluginManagement) {
|
||||
config.modResults.contents = contents.replace(
|
||||
/(pluginManagement\s*\{)/,
|
||||
`$1\n repositories {\n gradlePluginPortal()\n google()\n mavenCentral()\n ${repoLine}\n }`
|
||||
);
|
||||
} else {
|
||||
config.modResults.contents =
|
||||
`pluginManagement {\n repositories {\n ${repoLine}\n }\n}\n\n` +
|
||||
config.modResults.contents;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure Huawei Maven repo in dependencyResolutionManagement (Gradle 9+)
|
||||
if (!config.modResults.contents.match(/dependencyResolutionManagement[\s\S]*developer\.huawei\.com\/repo/)) {
|
||||
const depMgmtPattern = /dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{/;
|
||||
if (depMgmtPattern.test(config.modResults.contents)) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
depMgmtPattern,
|
||||
`$&\n maven { url 'https://developer.huawei.com/repo/' }`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
config = withProjectBuildGradle(config, (config) => {
|
||||
const contents = config.modResults.contents;
|
||||
@@ -28,24 +74,72 @@ const withHuaweiPush = (config, options = {}) => {
|
||||
config = withProjectBuildGradle(config, (config) => {
|
||||
const contents = config.modResults.contents;
|
||||
|
||||
// Add Huawei AGConnect classpath with apms-plugin excluded.
|
||||
// The apms-plugin transitively depends on AGP 4.0.1 which is incompatible
|
||||
// with Gradle 8.14+ (references removed BuildCompletionListener class).
|
||||
if (!contents.includes('com.huawei.agconnect:agcp')) {
|
||||
if (contents.includes('buildscript')) {
|
||||
const depsPattern = /(buildscript\s*\{[\s\S]*?dependencies\s*\{)/;
|
||||
if (depsPattern.test(contents)) {
|
||||
config.modResults.contents = contents.replace(
|
||||
depsPattern,
|
||||
`$1\n classpath 'com.huawei.agconnect:agcp:1.9.1.301'`
|
||||
`$1\n classpath('com.huawei.agconnect:agcp:1.9.1.301') {\n exclude group: 'com.huawei.agconnect', module: 'agconnect-apms-plugin'\n }`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set explicit AGP version (needed by Huawei plugin validation)
|
||||
if (!contents.includes('com.android.tools.build:gradle:8.12.0')) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
/classpath\(['"]com\.android\.tools\.build:gradle['"]\)/,
|
||||
"classpath('com.android.tools.build:gradle:8.12.0')"
|
||||
);
|
||||
}
|
||||
|
||||
// Google Services plugin classpath — required for Firebase init via google-services.json.
|
||||
if (!contents.includes('com.google.gms:google-services')) {
|
||||
const depsPattern = /(buildscript\s*\{[\s\S]*?dependencies\s*\{[\s\S]*?classpath\(['"]org\.jetbrains\.kotlin:kotlin-gradle-plugin['"]\))/;
|
||||
if (depsPattern.test(config.modResults.contents)) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
depsPattern,
|
||||
`$1\n classpath('com.google.gms:google-services:4.4.4')`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.modResults.contents.includes('developer.huawei.com/repo')) {
|
||||
const bsRepoPattern = /(buildscript\s*\{[\s\S]*?repositories\s*\{)/;
|
||||
if (bsRepoPattern.test(config.modResults.contents)) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
bsRepoPattern,
|
||||
`$1\n maven { url 'https://developer.huawei.com/repo/' }`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
config = withAppBuildGradle(config, (config) => {
|
||||
const contents = config.modResults.contents;
|
||||
|
||||
// Apply Google Services plugin (Firebase) before the Huawei plugin.
|
||||
// The googleServicesFile in app.json only sets up the file copy + plugin
|
||||
// when run through `expo prebuild`, but it does not coexist with this
|
||||
// Huawei plugin reliably, so we apply it explicitly here.
|
||||
if (!contents.includes('com.google.gms.google-services')) {
|
||||
const reactApply = /apply plugin:\s*['"]com\.facebook\.react['"]\s*\n/;
|
||||
if (reactApply.test(contents)) {
|
||||
config.modResults.contents = contents.replace(
|
||||
reactApply,
|
||||
`apply plugin: "com.facebook.react"\napply plugin: 'com.google.gms.google-services'\n`
|
||||
);
|
||||
} else {
|
||||
config.modResults.contents = `apply plugin: 'com.google.gms.google-services'\n` + contents;
|
||||
}
|
||||
}
|
||||
|
||||
if (!contents.includes('com.huawei.agconnect')) {
|
||||
const applyPluginPattern = /apply plugin:\s*['"]com\.google\.gms\.google-services['"]/;
|
||||
if (applyPluginPattern.test(contents)) {
|
||||
@@ -96,6 +190,41 @@ const withHuaweiPush = (config, options = {}) => {
|
||||
console.warn('[withHuaweiPush] agconnect-services.json not found at project root');
|
||||
}
|
||||
|
||||
// Copy google-services.json to android/app/ so the Google Services
|
||||
// Gradle plugin can find it. Without this, FCM / Firebase init fails
|
||||
// at runtime with "default FirebaseApp is not initialized".
|
||||
const gmsSrc = path.join(config.modRequest.projectRoot, 'google-services.json');
|
||||
const gmsDest = path.join(appDir, 'google-services.json');
|
||||
if (fs.existsSync(gmsSrc)) {
|
||||
fs.copyFileSync(gmsSrc, gmsDest);
|
||||
console.log('[withHuaweiPush] copied google-services.json to android/app/');
|
||||
} else {
|
||||
console.warn('[withHuaweiPush] google-services.json not found at project root');
|
||||
}
|
||||
|
||||
// RN 0.85.3 ships Gradle 9.3.1 in its template, but Kotlin 2.1.20 is incompatible
|
||||
// with Gradle 9.x (CompilerEnvironment ClassNotFoundException). Downgrade to 8.14.5.
|
||||
const wrapperFile = path.join(config.modRequest.platformProjectRoot, 'gradle', 'wrapper', 'gradle-wrapper.properties');
|
||||
if (fs.existsSync(wrapperFile)) {
|
||||
let wrapper = fs.readFileSync(wrapperFile, 'utf8');
|
||||
if (wrapper.includes('gradle-9.')) {
|
||||
wrapper = wrapper.replace(/gradle-[\d.]+-bin\.zip/, 'gradle-8.14.5-bin.zip');
|
||||
fs.writeFileSync(wrapperFile, wrapper);
|
||||
console.log('[withHuaweiPush] downgraded Gradle to 8.14.5 for Kotlin 2.1.20 compatibility');
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure local.properties with SDK path exists (gets cleared on prebuild --clean)
|
||||
const localPropsFile = path.join(config.modRequest.platformProjectRoot, 'local.properties');
|
||||
if (!fs.existsSync(localPropsFile)) {
|
||||
const homeDir = process.env.USERPROFILE || process.env.HOME || '';
|
||||
const sdkPath = path.join(homeDir, 'AppData', 'Local', 'Android', 'Sdk');
|
||||
if (fs.existsSync(sdkPath)) {
|
||||
fs.writeFileSync(localPropsFile, `sdk.dir=${sdkPath.replace(/\\/g, '\\\\')}\n`);
|
||||
console.log('[withHuaweiPush] created local.properties with SDK path');
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -95,7 +95,7 @@ const withJPush = (config, options = {}) => {
|
||||
config = withAppDelegate(config, (config) => {
|
||||
const { contents } = config.modResults;
|
||||
|
||||
if (contents.includes('JPush') || contents.includes('JCoreModule')) {
|
||||
if (contents.includes('JPush initialization') && contents.includes('JPUSHService.registerDeviceToken')) {
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -104,7 +104,8 @@ const withJPush = (config, options = {}) => {
|
||||
if (isSwift) {
|
||||
let newContents = contents;
|
||||
|
||||
if (!newContents.includes('import JPushRN')) {
|
||||
// Add UNUserNotifications import if needed
|
||||
if (!newContents.includes('import UserNotifications')) {
|
||||
if (newContents.includes('import UIKit')) {
|
||||
newContents = newContents.replace(
|
||||
/(import UIKit\n)/,
|
||||
@@ -118,56 +119,62 @@ const withJPush = (config, options = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Swift: Add JPush init in application(_:didFinishLaunchingWithOptions:)
|
||||
// Match various Swift signatures
|
||||
const swiftDidFinishPatterns = [
|
||||
/public override func application\(\s*_ application: UIApplication,\s*didFinishLaunchingWithOptions launchOptions: \[UIApplication\.LaunchOptionsKey: Any\]\? = nil\s*\) -> Bool \{/,
|
||||
/func application\(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: \[UIApplication\.LaunchOptionsKey: Any\]\?\?\) -> Bool \{/,
|
||||
];
|
||||
|
||||
let didInsertInit = false;
|
||||
for (const pattern of swiftDidFinishPatterns) {
|
||||
if (pattern.test(newContents)) {
|
||||
newContents = newContents.replace(
|
||||
pattern,
|
||||
`$&\n // JPush initialization\n #if DEBUG\n JCoreModule.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)\n #else\n JCoreModule.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)\n #endif`
|
||||
);
|
||||
didInsertInit = true;
|
||||
break;
|
||||
}
|
||||
// Add UNUserNotificationCenterDelegate conformance if needed
|
||||
if (!newContents.includes('UNUserNotificationCenterDelegate')) {
|
||||
newContents = newContents.replace(
|
||||
/class AppDelegate: ExpoAppDelegate \{/,
|
||||
'class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate {'
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: find the didFinishLaunchingWithOptions method start and insert after the opening brace
|
||||
if (!didInsertInit && newContents.includes('didFinishLaunchingWithOptions')) {
|
||||
const lines = newContents.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('didFinishLaunchingWithOptions') && lines[i].includes('{')) {
|
||||
lines.splice(i + 1, 0,
|
||||
' // JPush initialization',
|
||||
' #if DEBUG',
|
||||
` JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)`,
|
||||
' #else',
|
||||
` JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)`,
|
||||
' #endif',
|
||||
' UNUserNotificationCenter.current().delegate = self'
|
||||
);
|
||||
newContents = lines.join('\n');
|
||||
didInsertInit = true;
|
||||
break;
|
||||
// Swift: Add JPush init in application(_:didFinishLaunchingWithOptions:)
|
||||
// Expo SDK 56+ uses JPUSHService.setup (not JCoreModule.setup)
|
||||
if (!newContents.includes('JPUSHService.setup') && !newContents.includes('JCoreModule.setup')) {
|
||||
const jpushInitBlock = `
|
||||
// JPush initialization
|
||||
#if DEBUG
|
||||
JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)
|
||||
#else
|
||||
JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)
|
||||
#endif
|
||||
UNUserNotificationCenter.current().delegate = self`;
|
||||
|
||||
// Insert after the opening brace of didFinishLaunchingWithOptions
|
||||
// The { may be on the same line as didFinishLaunchingWithOptions or on a subsequent line
|
||||
if (newContents.includes('didFinishLaunchingWithOptions')) {
|
||||
const lines = newContents.split('\n');
|
||||
let foundDidFinish = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('didFinishLaunchingWithOptions')) {
|
||||
foundDidFinish = true;
|
||||
}
|
||||
// Insert after the line that contains the opening brace of this method
|
||||
if (foundDidFinish && lines[i].includes('{')) {
|
||||
const indent = lines[i].match(/^(\s*)/)[1];
|
||||
const initLines = jpushInitBlock.split('\n').map(l => l ? indent + l.trimStart() : l);
|
||||
lines.splice(i + 1, 0, ...initLines);
|
||||
newContents = lines.join('\n');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (newContents.includes('JCoreModule.setup') && !newContents.includes('JPUSHService.setup')) {
|
||||
// Migrate from JCoreModule.setup to JPUSHService.setup
|
||||
newContents = newContents.replace(/JCoreModule\.setup/g, 'JPUSHService.setup');
|
||||
}
|
||||
|
||||
// Also add delegate = self after regex-based insertion if not already present
|
||||
if (didInsertInit && !newContents.includes('UNUserNotificationCenter.current().delegate')) {
|
||||
// Add delegate = self after init insertion if not present
|
||||
if (!newContents.includes('UNUserNotificationCenter.current().delegate')) {
|
||||
newContents = newContents.replace(
|
||||
/(#endif\n)(\s+let delegate)/,
|
||||
`$1 UNUserNotificationCenter.current().delegate = self\n$2`
|
||||
);
|
||||
}
|
||||
|
||||
// Swift: Add delegate methods before the closing brace of the class
|
||||
// Swift: Add JPush notification delegate methods to AppDelegate class (before closing brace)
|
||||
// These go in AppDelegate, NOT ReactNativeDelegate
|
||||
const jpushSwiftMethods = `
|
||||
|
||||
// JPush: Register for remote notifications
|
||||
override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
JPUSHService.registerDeviceToken(deviceToken)
|
||||
@@ -195,10 +202,20 @@ const withJPush = (config, options = {}) => {
|
||||
`;
|
||||
|
||||
if (!newContents.includes('JPUSHService.registerDeviceToken')) {
|
||||
// Find the closing brace of the AppDelegate class
|
||||
const lastBraceIndex = newContents.lastIndexOf('}');
|
||||
if (lastBraceIndex !== -1) {
|
||||
newContents = newContents.slice(0, lastBraceIndex) + jpushSwiftMethods + '\n' + newContents.slice(lastBraceIndex);
|
||||
// Insert before the closing brace of the AppDelegate class
|
||||
// Find the last '}' that closes the AppDelegate class (before ReactNativeDelegate)
|
||||
const classEndPattern = /\n\}\n\nclass ReactNativeDelegate/;
|
||||
if (classEndPattern.test(newContents)) {
|
||||
newContents = newContents.replace(
|
||||
classEndPattern,
|
||||
`${jpushSwiftMethods}}\n\nclass ReactNativeDelegate`
|
||||
);
|
||||
} else {
|
||||
// Fallback: insert before the last closing brace
|
||||
const lastBraceIndex = newContents.lastIndexOf('}');
|
||||
if (lastBraceIndex !== -1) {
|
||||
newContents = newContents.slice(0, lastBraceIndex) + jpushSwiftMethods + '\n' + newContents.slice(lastBraceIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
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;
|
||||
@@ -172,7 +172,7 @@ export function AppDesktopShell() {
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(href: string) => {
|
||||
router.replace(href);
|
||||
router.push(href);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
@@ -14,7 +14,8 @@ export interface BlockEditorHandle {
|
||||
insertTextAtCursor: (text: string) => void;
|
||||
getSegments: () => MessageSegment[];
|
||||
getContent: () => string;
|
||||
hasUploadingImages: () => boolean;
|
||||
getBlocks: () => EditorBlock[];
|
||||
uploadPendingImages: () => Promise<boolean>;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
@@ -38,7 +39,8 @@ const BlockEditor = React.forwardRef<BlockEditorHandle, BlockEditorProps>(
|
||||
insertTextAtCursor: editor.insertTextAtCursor,
|
||||
getSegments: editor.getSegments,
|
||||
getContent: editor.getContent,
|
||||
hasUploadingImages: editor.hasUploadingImages,
|
||||
getBlocks: editor.getBlocks,
|
||||
uploadPendingImages: editor.uploadPendingImages,
|
||||
focus: () => {
|
||||
const firstTextBlock = editor.blocks.find(b => b.type === 'text');
|
||||
if (firstTextBlock) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Dimensions, ActivityIndicator } from 'react-native';
|
||||
import { View, TouchableOpacity, StyleSheet, Dimensions } from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
@@ -41,11 +41,6 @@ const ImageBlockView: React.FC<ImageBlockViewProps> = ({ block, onRemove, style
|
||||
cachePolicy="memory-disk"
|
||||
transition={200}
|
||||
/>
|
||||
{block.uploading && (
|
||||
<View style={styles.uploadingOverlay}>
|
||||
<ActivityIndicator size="small" color={colors.text.inverse} />
|
||||
</View>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={styles.removeButton}
|
||||
onPress={() => onRemove(block.id)}
|
||||
@@ -66,13 +61,6 @@ const styles = StyleSheet.create({
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
},
|
||||
uploadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
removeButton: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface ImageBlock {
|
||||
type: 'image';
|
||||
localUri: string;
|
||||
remoteUrl?: string;
|
||||
uploading: boolean;
|
||||
mimeType?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ export function segmentsToBlocks(segments: MessageSegment[]): EditorBlock[] {
|
||||
type: 'image',
|
||||
localUri: seg.data.url,
|
||||
remoteUrl: seg.data.url,
|
||||
uploading: false,
|
||||
width: seg.data.width,
|
||||
height: seg.data.height,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
||||
const [activeBlockId, setActiveBlockId] = useState<string | null>(null);
|
||||
const inputRefs = useRef<Map<string, TextInput>>(new Map());
|
||||
const blockCursors = useRef<Map<string, { start: number; end: number }>>(new Map());
|
||||
const blocksRef = useRef<EditorBlock[]>(blocks);
|
||||
blocksRef.current = blocks;
|
||||
|
||||
const updateTextBlock = useCallback(
|
||||
(blockId: string, text: string, mentions: Map<number, MentionData>) => {
|
||||
@@ -87,51 +89,47 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
||||
});
|
||||
}, [focusBlock]);
|
||||
|
||||
const uploadImageByLocalUri = useCallback(
|
||||
async (asset: ImagePicker.ImagePickerAsset) => {
|
||||
try {
|
||||
const uploadResult = await uploadService.uploadImage({
|
||||
uri: asset.uri,
|
||||
type: asset.mimeType || 'image/jpeg',
|
||||
});
|
||||
const uploadPendingImages = useCallback(async (): Promise<boolean> => {
|
||||
let allSuccess = true;
|
||||
|
||||
if (uploadResult) {
|
||||
setBlocks(prev =>
|
||||
prev.map(b =>
|
||||
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
|
||||
? {
|
||||
...b,
|
||||
remoteUrl: uploadResult.url,
|
||||
uploading: false,
|
||||
width: uploadResult.width ?? b.width,
|
||||
height: uploadResult.height ?? b.height,
|
||||
}
|
||||
: b
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Upload failed
|
||||
setBlocks(prev =>
|
||||
prev.map(b =>
|
||||
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
|
||||
? { ...b, uploading: false }
|
||||
: b
|
||||
)
|
||||
);
|
||||
Alert.alert('上传失败', '图片上传失败,请重试');
|
||||
const uploadBlock = async () => {
|
||||
const currentBlocks = blocksRef.current;
|
||||
const pendingBlocks = currentBlocks.filter(
|
||||
(b): b is ImageBlock => b.type === 'image' && !b.remoteUrl,
|
||||
);
|
||||
|
||||
for (const block of pendingBlocks) {
|
||||
try {
|
||||
const uploadResult = await uploadService.uploadImage({
|
||||
uri: block.localUri,
|
||||
type: block.mimeType || 'image/jpeg',
|
||||
});
|
||||
|
||||
if (uploadResult) {
|
||||
setBlocks(prev =>
|
||||
prev.map(b =>
|
||||
b.id === block.id && b.type === 'image'
|
||||
? {
|
||||
...b,
|
||||
remoteUrl: uploadResult.url,
|
||||
width: uploadResult.width ?? b.width,
|
||||
height: uploadResult.height ?? b.height,
|
||||
}
|
||||
: b,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
allSuccess = false;
|
||||
}
|
||||
} catch {
|
||||
allSuccess = false;
|
||||
}
|
||||
} catch {
|
||||
setBlocks(prev =>
|
||||
prev.map(b =>
|
||||
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
|
||||
? { ...b, uploading: false }
|
||||
: b
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
await uploadBlock();
|
||||
return allSuccess;
|
||||
}, []);
|
||||
|
||||
const insertImagesFromAssets = useCallback(
|
||||
(assets: ImagePicker.ImagePickerAsset[]) => {
|
||||
@@ -152,13 +150,12 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
||||
? (blockCursors.current.get(activeBlock.id)?.start ?? activeBlock.text.length)
|
||||
: 0;
|
||||
|
||||
// Create image block
|
||||
const imageBlockId = generateBlockId();
|
||||
const imageBlock: ImageBlock = {
|
||||
id: imageBlockId,
|
||||
type: 'image',
|
||||
localUri: asset.uri,
|
||||
uploading: true,
|
||||
mimeType: asset.mimeType,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
};
|
||||
@@ -169,7 +166,6 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
||||
const mentionsBefore = remapMentionsBefore(activeBlock.activeMentions, cursorPos);
|
||||
const mentionsAfter = remapMentionsAfter(activeBlock.activeMentions, cursorPos, activeBlock.text.length);
|
||||
|
||||
// Create new text block for text after cursor
|
||||
const newTextBlockId = generateBlockId();
|
||||
const newTextBlock: TextBlock = {
|
||||
id: newTextBlockId,
|
||||
@@ -178,24 +174,19 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
||||
activeMentions: mentionsAfter,
|
||||
};
|
||||
|
||||
// Update current block to text before cursor
|
||||
updated[insertAfterIndex] = {
|
||||
...activeBlock,
|
||||
text: textBefore,
|
||||
activeMentions: mentionsBefore,
|
||||
};
|
||||
|
||||
// Insert image block and new text block
|
||||
updated.splice(insertAfterIndex + 1, 0, imageBlock, newTextBlock);
|
||||
|
||||
// Update index for next image insertion
|
||||
insertAfterIndex = insertAfterIndex + 2;
|
||||
|
||||
// Focus the new text block after the image
|
||||
setActiveBlockId(newTextBlockId);
|
||||
requestAnimationFrame(() => focusBlock(newTextBlockId));
|
||||
} else {
|
||||
// Insert after the current block (which is an image)
|
||||
updated.splice(insertAfterIndex + 1, 0, imageBlock);
|
||||
insertAfterIndex = insertAfterIndex + 1;
|
||||
}
|
||||
@@ -203,13 +194,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Start async uploads
|
||||
for (const asset of assets) {
|
||||
uploadImageByLocalUri(asset);
|
||||
}
|
||||
},
|
||||
[activeBlockId, focusBlock, uploadImageByLocalUri],
|
||||
[activeBlockId, focusBlock],
|
||||
);
|
||||
|
||||
const insertImage = useCallback(async () => {
|
||||
@@ -282,7 +268,7 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
||||
|
||||
const getSegments = useCallback(() => blocksToSegments(blocks), [blocks]);
|
||||
const getContent = useCallback(() => blocksToContent(blocks), [blocks]);
|
||||
const hasUploadingImages = useCallback(() => blocks.some(b => b.type === 'image' && b.uploading), [blocks]);
|
||||
const getBlocks = useCallback(() => blocks, [blocks]);
|
||||
|
||||
const registerInputRef = useCallback((blockId: string, ref: TextInput | null) => {
|
||||
if (ref) {
|
||||
@@ -308,7 +294,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
||||
focusBlock,
|
||||
getSegments,
|
||||
getContent,
|
||||
hasUploadingImages,
|
||||
getBlocks,
|
||||
uploadPendingImages,
|
||||
registerInputRef,
|
||||
updateCursor,
|
||||
};
|
||||
|
||||
@@ -255,6 +255,7 @@ function createPostCardStyles(colors: AppColors) {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
actionsLeading: {
|
||||
flexDirection: 'row',
|
||||
@@ -661,7 +662,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
) : (
|
||||
<View style={styles.gridNoImagePreview}>
|
||||
<Text style={styles.gridNoImageText} numberOfLines={6}>
|
||||
{highlightKeyword ? <HighlightText text={contentPreview} keyword={highlightKeyword} style={styles.highlight} /> : contentPreview}
|
||||
{highlightKeyword ? <HighlightText text={contentPreview} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : contentPreview}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -675,7 +676,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
|
||||
{!!post.title && (
|
||||
<Text style={styles.gridTitleMain} numberOfLines={hasImage ? 2 : 3}>
|
||||
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} style={styles.highlight} /> : post.title}
|
||||
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : post.title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -747,7 +748,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
|
||||
{!!post.title && (
|
||||
<Text style={styles.title} numberOfLines={isCompact ? 2 : undefined}>
|
||||
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} style={styles.highlight} /> : post.title}
|
||||
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : post.title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -757,7 +758,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
style={styles.content}
|
||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||
>
|
||||
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} style={styles.highlight} /> : content}
|
||||
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : content}
|
||||
</Text>
|
||||
{shouldTruncate && (
|
||||
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
|
||||
|
||||
@@ -61,7 +61,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
||||
return (
|
||||
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
||||
{highlightKeyword && content ? (
|
||||
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
|
||||
<HighlightText text={content} keyword={highlightKeyword} highlightStyle={highlightStyle} />
|
||||
) : (
|
||||
content || ''
|
||||
)}
|
||||
@@ -85,7 +85,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
||||
elements.push(
|
||||
<Text key={key} selectable style={textStyle}>
|
||||
{highlightKeyword ? (
|
||||
<HighlightText text={text} keyword={highlightKeyword} style={highlightStyle} />
|
||||
<HighlightText text={text} keyword={highlightKeyword} highlightStyle={highlightStyle} />
|
||||
) : (
|
||||
text
|
||||
)}
|
||||
@@ -125,7 +125,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
||||
return (
|
||||
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
||||
{highlightKeyword && content ? (
|
||||
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
|
||||
<HighlightText text={content} keyword={highlightKeyword} highlightStyle={highlightStyle} />
|
||||
) : (
|
||||
content || ''
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 检测输入中的 @ 字符,弹出关注者列表供选择
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
@@ -51,7 +51,12 @@ interface PostMentionInputProps {
|
||||
returnKeyType?: 'default' | 'send' | 'done';
|
||||
}
|
||||
|
||||
const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
||||
export interface PostMentionInputHandle {
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
}
|
||||
|
||||
const PostMentionInput = forwardRef<PostMentionInputHandle, PostMentionInputProps>(({
|
||||
value,
|
||||
onChangeText,
|
||||
onSegmentsChange,
|
||||
@@ -63,7 +68,7 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
||||
onPostRefPasted,
|
||||
onSubmitEditing,
|
||||
returnKeyType = 'default',
|
||||
}) => {
|
||||
}, ref) => {
|
||||
const colors = useAppColors();
|
||||
const currentUser = useAuthStore(s => s.currentUser);
|
||||
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
|
||||
@@ -75,6 +80,21 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
||||
const [postSearchTimer, setPostSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
// 暴露 focus / blur 方法给父组件,用于在模式切换(如点击回复)后自动唤起键盘
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
// 展开动画/挂载需要时间,延迟一帧后再聚焦,避免在折叠态未真正展开时调用无效
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
});
|
||||
},
|
||||
blur: () => {
|
||||
inputRef.current?.blur();
|
||||
},
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded && currentUser?.id) {
|
||||
loadFollowing();
|
||||
@@ -361,7 +381,7 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter } from 'expo-router';
|
||||
import Text from '../common/Text';
|
||||
import { useAppColors, spacing, borderRadius } from '../../theme';
|
||||
import type { PostRefSegmentData } from '../../types';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
|
||||
interface PostRefCardProps {
|
||||
data: PostRefSegmentData;
|
||||
@@ -21,7 +22,7 @@ const PostRefCard: React.FC<PostRefCardProps> = ({ data, compact = false, onPres
|
||||
if (onPress) {
|
||||
onPress(data.post_id);
|
||||
} else {
|
||||
router.push(`/post/${data.post_id}` as any);
|
||||
router.push(hrefs.hrefPostDetail(data.post_id));
|
||||
}
|
||||
}, [data.post_id, onPress, router]);
|
||||
|
||||
|
||||
@@ -330,4 +330,4 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default TabBar;
|
||||
export default React.memo(TabBar);
|
||||
|
||||
@@ -551,7 +551,6 @@ function createUserProfileHeaderStyles(colors: AppColors) {
|
||||
minWidth: 160,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
...shadows.lg,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
overflow: 'hidden',
|
||||
|
||||
@@ -159,7 +159,7 @@ function createVoteCardStyles(colors: AppColors) {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
loadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: colors.background.paper + 'CC',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -24,6 +24,7 @@ export { default as VoteEditor } from './VoteEditor';
|
||||
export { default as VotePreview } from './VotePreview';
|
||||
export { default as PostContentRenderer } from './PostContentRenderer';
|
||||
export { default as PostMentionInput } from './PostMentionInput';
|
||||
export type { PostMentionInputHandle } from './PostMentionInput';
|
||||
export { default as BlockEditor } from './BlockEditor';
|
||||
export type { BlockEditorHandle } from './BlockEditor';
|
||||
export { default as ReportDialog } from './ReportDialog';
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
StatusBar,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Track, LocalVideoTrack, RemoteVideoTrack } from 'livekit-client';
|
||||
|
||||
let VideoView: React.ComponentType<any> | null = null;
|
||||
try {
|
||||
VideoView = require('@livekit/react-native').VideoView;
|
||||
} catch {
|
||||
// WebRTC native module not available (e.g. Expo Go)
|
||||
}
|
||||
|
||||
type VideoTrack = LocalVideoTrack | RemoteVideoTrack;
|
||||
import { callStore } from '../../stores/call';
|
||||
import { RTCView } from 'react-native-webrtc';
|
||||
import { liveKitService } from '@/services/livekit';
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
@@ -17,12 +27,9 @@ const formatDuration = (seconds: number): string => {
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
|
||||
const CallScreen: React.FC = () => {
|
||||
const currentCall = callStore((s) => s.currentCall);
|
||||
const callDuration = callStore((s) => s.callDuration);
|
||||
const peerStream = callStore((s) => s.peerStream);
|
||||
const localStream = callStore((s) => s.localStream);
|
||||
const endCall = callStore((s) => s.endCall);
|
||||
const toggleMute = callStore((s) => s.toggleMute);
|
||||
const toggleSpeaker = callStore((s) => s.toggleSpeaker);
|
||||
@@ -30,33 +37,87 @@ const CallScreen: React.FC = () => {
|
||||
const isMinimized = callStore((s) => s.isMinimized);
|
||||
const toggleMinimize = callStore((s) => s.toggleMinimize);
|
||||
|
||||
// Track whether peer has video
|
||||
const [hasPeerVideo, setHasPeerVideo] = useState(false);
|
||||
// Track whether local has video
|
||||
const [hasLocalVideo, setHasLocalVideo] = useState(false);
|
||||
const [remoteVideoTrack, setRemoteVideoTrack] = useState<VideoTrack | null>(null);
|
||||
const [localVideoTrack, setLocalVideoTrack] = useState<VideoTrack | null>(null);
|
||||
|
||||
// Check peer stream for video tracks
|
||||
// Event-driven video track subscription (no polling)
|
||||
useEffect(() => {
|
||||
if (peerStream) {
|
||||
const videoTracks = peerStream.getVideoTracks();
|
||||
const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
|
||||
setHasPeerVideo(hasVideo);
|
||||
} else {
|
||||
setHasPeerVideo(false);
|
||||
}
|
||||
}, [peerStream]);
|
||||
// Check local stream for video tracks
|
||||
useEffect(() => {
|
||||
if (localStream) {
|
||||
const videoTracks = localStream.getVideoTracks();
|
||||
const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
|
||||
setHasLocalVideo(hasVideo);
|
||||
} else {
|
||||
setHasLocalVideo(false);
|
||||
}
|
||||
}, [localStream]);
|
||||
const unsubs: (() => void)[] = [];
|
||||
|
||||
// Sync current tracks immediately
|
||||
const syncTracks = () => {
|
||||
const lp = liveKitService.localParticipant;
|
||||
if (lp) {
|
||||
const videoPub = lp.getTrackPublication(Track.Source.Camera);
|
||||
setLocalVideoTrack((videoPub?.track as VideoTrack | undefined) ?? null);
|
||||
}
|
||||
liveKitService.remoteParticipants.forEach((participant) => {
|
||||
const videoPub = participant.getTrackPublication(Track.Source.Camera);
|
||||
if (videoPub?.track) {
|
||||
setRemoteVideoTrack(videoPub.track as VideoTrack);
|
||||
}
|
||||
});
|
||||
};
|
||||
syncTracks();
|
||||
|
||||
// Remote video track events
|
||||
unsubs.push(
|
||||
liveKitService.on('trackSubscribed', ({ track }) => {
|
||||
if (track.kind === Track.Kind.Video) {
|
||||
setRemoteVideoTrack(track as VideoTrack);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
liveKitService.on('trackUnsubscribed', ({ track }) => {
|
||||
if (track.kind === Track.Kind.Video) {
|
||||
setRemoteVideoTrack(null);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
liveKitService.on('trackMuted', ({ publication }) => {
|
||||
if (publication.source === Track.Source.Camera) {
|
||||
setRemoteVideoTrack(null);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
liveKitService.on('trackUnmuted', ({ publication }) => {
|
||||
if (publication.source === Track.Source.Camera && publication.track) {
|
||||
setRemoteVideoTrack(publication.track as VideoTrack);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Local video track events (replace 500ms polling)
|
||||
unsubs.push(
|
||||
liveKitService.on('localTrackPublished', ({ publication }) => {
|
||||
if (publication.source === Track.Source.Camera && publication.track) {
|
||||
setLocalVideoTrack(publication.track as VideoTrack);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
liveKitService.on('localTrackUnpublished', ({ publication }) => {
|
||||
if (publication.source === Track.Source.Camera) {
|
||||
setLocalVideoTrack(null);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubs.forEach((unsub) => unsub());
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Don't render full screen when minimized or no call
|
||||
if (!currentCall || isMinimized) return null;
|
||||
|
||||
const getStatusText = (): string => {
|
||||
switch (currentCall.status) {
|
||||
case 'calling':
|
||||
@@ -77,63 +138,52 @@ const CallScreen: React.FC = () => {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndCall = () => {
|
||||
endCall('hangup');
|
||||
};
|
||||
const handleToggleMute = () => {
|
||||
toggleMute();
|
||||
};
|
||||
const handleToggleSpeaker = () => {
|
||||
toggleSpeaker();
|
||||
};
|
||||
const handleToggleVideo = () => {
|
||||
toggleVideo();
|
||||
};
|
||||
const handleMinimize = () => {
|
||||
toggleMinimize();
|
||||
};
|
||||
// Determine if we should show video UI
|
||||
const showRemoteVideo = hasPeerVideo;
|
||||
// Use currentCall.isVideoEnabled directly for local video
|
||||
// This is more reliable than checking localStream.getVideoTracks()
|
||||
// because the stream object reference may not trigger useEffect properly
|
||||
const showLocalVideo = currentCall?.isVideoEnabled && localStream;
|
||||
|
||||
const showRemoteVideo = !!remoteVideoTrack;
|
||||
const showLocalVideo = currentCall?.isVideoEnabled && !!localVideoTrack;
|
||||
const isVideoCallActive = showRemoteVideo || showLocalVideo;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||
{/* Background */}
|
||||
<View style={styles.background} />
|
||||
{/* Remote video - full screen when peer has video */}
|
||||
{showRemoteVideo && peerStream && (
|
||||
<RTCView
|
||||
streamURL={peerStream.toURL()}
|
||||
|
||||
{/* Remote video - full screen */}
|
||||
{showRemoteVideo && remoteVideoTrack && VideoView && (
|
||||
<VideoView
|
||||
videoTrack={remoteVideoTrack}
|
||||
style={styles.fullScreenVideo}
|
||||
objectFit="cover"
|
||||
mirror={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Local video - picture in picture */}
|
||||
{showLocalVideo && localStream && (
|
||||
{showLocalVideo && localVideoTrack && VideoView && (
|
||||
<View style={styles.localVideoContainer}>
|
||||
<RTCView
|
||||
streamURL={localStream.toURL()}
|
||||
<VideoView
|
||||
videoTrack={localVideoTrack}
|
||||
style={styles.localVideo}
|
||||
objectFit="cover"
|
||||
mirror={true}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Top area: minimize button */}
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity
|
||||
style={styles.topButton}
|
||||
onPress={handleMinimize}
|
||||
onPress={toggleMinimize}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Center: Peer info - only show when no video */}
|
||||
{!isVideoCallActive && (
|
||||
<View style={styles.centerArea}>
|
||||
@@ -154,6 +204,7 @@ const CallScreen: React.FC = () => {
|
||||
<Text style={styles.status}>{getStatusText()}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Peer name overlay when video is active */}
|
||||
{isVideoCallActive && (
|
||||
<View style={styles.videoOverlayInfo}>
|
||||
@@ -163,13 +214,14 @@ const CallScreen: React.FC = () => {
|
||||
<Text style={styles.videoStatus}>{getStatusText()}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Bottom controls */}
|
||||
<View style={styles.controls}>
|
||||
<View style={styles.controlRow}>
|
||||
{/* Mute */}
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, currentCall.isMuted && styles.controlButtonActive]}
|
||||
onPress={handleToggleMute}
|
||||
onPress={toggleMute}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.controlCircle, currentCall.isMuted && styles.controlCircleActive]}>
|
||||
@@ -185,25 +237,25 @@ const CallScreen: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
{/* Video toggle */}
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, hasLocalVideo && styles.controlButtonActive]}
|
||||
onPress={handleToggleVideo}
|
||||
style={[styles.controlButton, currentCall.isVideoEnabled && styles.controlButtonActive]}
|
||||
onPress={toggleVideo}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.controlCircle, hasLocalVideo && styles.controlCircleActive]}>
|
||||
<View style={[styles.controlCircle, currentCall.isVideoEnabled && styles.controlCircleActive]}>
|
||||
<MaterialCommunityIcons
|
||||
name={hasLocalVideo ? 'video' : 'video-off'}
|
||||
name={currentCall.isVideoEnabled ? 'video' : 'video-off'}
|
||||
size={26}
|
||||
color={hasLocalVideo ? '#333' : '#fff'}
|
||||
color={currentCall.isVideoEnabled ? '#333' : '#fff'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.controlLabel, hasLocalVideo && styles.controlLabelActive]}>
|
||||
{hasLocalVideo ? '关闭摄像头' : '打开摄像头'}
|
||||
<Text style={[styles.controlLabel, currentCall.isVideoEnabled && styles.controlLabelActive]}>
|
||||
{currentCall.isVideoEnabled ? '关闭摄像头' : '打开摄像头'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{/* Speaker */}
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]}
|
||||
onPress={handleToggleSpeaker}
|
||||
onPress={toggleSpeaker}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.controlCircle, currentCall.isSpeakerOn && styles.controlCircleActive]}>
|
||||
@@ -218,7 +270,7 @@ const CallScreen: React.FC = () => {
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{/* End call - prominent red button */}
|
||||
{/* End call */}
|
||||
<TouchableOpacity
|
||||
style={styles.endCallButton}
|
||||
onPress={handleEndCall}
|
||||
@@ -234,17 +286,14 @@ const CallScreen: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const CONTROL_CIRCLE_SIZE = 56;
|
||||
const END_CALL_SIZE = 64;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: '#1A1A2E',
|
||||
zIndex: 9999,
|
||||
},
|
||||
background: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: '#1A1A2E',
|
||||
},
|
||||
topBar: {
|
||||
@@ -306,7 +355,7 @@ const styles = StyleSheet.create({
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
fullScreenVideo: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: '#1A1A2E',
|
||||
},
|
||||
localVideoContainer: {
|
||||
@@ -408,5 +457,4 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
export default CallScreen;
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Track, LocalVideoTrack, RemoteVideoTrack } from 'livekit-client';
|
||||
|
||||
let VideoView: React.ComponentType<any> | null = null;
|
||||
try {
|
||||
VideoView = require('@livekit/react-native').VideoView;
|
||||
} catch {
|
||||
// @livekit/react-native not available on web
|
||||
}
|
||||
|
||||
import { callStore } from '../../stores/call';
|
||||
import { liveKitService } from '@/services/livekit';
|
||||
|
||||
type VideoTrack = LocalVideoTrack | RemoteVideoTrack;
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
@@ -19,80 +32,253 @@ const CallScreen: React.FC = () => {
|
||||
const currentCall = callStore((s) => s.currentCall);
|
||||
const callDuration = callStore((s) => s.callDuration);
|
||||
const endCall = callStore((s) => s.endCall);
|
||||
const toggleMute = callStore((s) => s.toggleMute);
|
||||
const toggleSpeaker = callStore((s) => s.toggleSpeaker);
|
||||
const toggleVideo = callStore((s) => s.toggleVideo);
|
||||
const isMinimized = callStore((s) => s.isMinimized);
|
||||
const toggleMinimize = callStore((s) => s.toggleMinimize);
|
||||
|
||||
const [remoteVideoTrack, setRemoteVideoTrack] = useState<VideoTrack | null>(null);
|
||||
const [localVideoTrack, setLocalVideoTrack] = useState<VideoTrack | null>(null);
|
||||
|
||||
// Subscribe to LiveKit track events
|
||||
useEffect(() => {
|
||||
const unsubs: (() => void)[] = [];
|
||||
|
||||
unsubs.push(
|
||||
liveKitService.on('trackSubscribed', ({ track }) => {
|
||||
if (track.kind === Track.Kind.Video) {
|
||||
setRemoteVideoTrack(track as VideoTrack);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
liveKitService.on('trackUnsubscribed', ({ track }) => {
|
||||
if (track.kind === Track.Kind.Video) {
|
||||
setRemoteVideoTrack(null);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
liveKitService.on('trackMuted', ({ publication }) => {
|
||||
if (publication.source === Track.Source.Camera) {
|
||||
setRemoteVideoTrack(null);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
liveKitService.on('trackUnmuted', ({ publication }) => {
|
||||
if (publication.source === Track.Source.Camera && publication.track) {
|
||||
setRemoteVideoTrack(publication.track as VideoTrack);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const localPollInterval = setInterval(() => {
|
||||
const lp = liveKitService.localParticipant;
|
||||
if (lp) {
|
||||
const videoPub = lp.getTrackPublication(Track.Source.Camera);
|
||||
const track = (videoPub?.track ?? null) as VideoTrack | null;
|
||||
setLocalVideoTrack((prev) => (prev === track ? prev : track));
|
||||
} else {
|
||||
setLocalVideoTrack((prev) => (prev === null ? prev : null));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
unsubs.forEach((unsub) => unsub());
|
||||
clearInterval(localPollInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Don't render full screen when minimized or no call
|
||||
if (!currentCall || isMinimized) return null;
|
||||
|
||||
const getStatusText = (): string => {
|
||||
switch (currentCall.status) {
|
||||
case 'calling':
|
||||
return '正在等待对方接听...';
|
||||
case 'ringing':
|
||||
return '通话功能暂不支持网页端';
|
||||
return '来电响铃中...';
|
||||
case 'connecting':
|
||||
return '通话功能暂不支持网页端';
|
||||
return '连接中...';
|
||||
case 'connected':
|
||||
return '通话功能暂不支持网页端';
|
||||
return formatDuration(callDuration);
|
||||
case 'reconnecting':
|
||||
return '网络重连中...';
|
||||
case 'ended':
|
||||
return '通话已结束';
|
||||
case 'failed':
|
||||
return '连接失败';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndCall = () => {
|
||||
endCall('hangup');
|
||||
};
|
||||
const showRemoteVideo = !!remoteVideoTrack;
|
||||
const showLocalVideo = currentCall?.isVideoEnabled && !!localVideoTrack;
|
||||
const isVideoCallActive = showRemoteVideo || showLocalVideo;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||
|
||||
{/* Background - single consistent color */}
|
||||
<View style={styles.background} />
|
||||
|
||||
{/* Center: Peer info */}
|
||||
<View style={styles.centerArea}>
|
||||
<View style={styles.avatarOuter}>
|
||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||
<MaterialCommunityIcons name="web-off" size={50} color="#fff" />
|
||||
</View>
|
||||
{showRemoteVideo && remoteVideoTrack && VideoView && (
|
||||
<VideoView
|
||||
videoTrack={remoteVideoTrack}
|
||||
style={styles.fullScreenVideo}
|
||||
objectFit="cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showLocalVideo && localVideoTrack && VideoView && (
|
||||
<View style={styles.localVideoContainer}>
|
||||
<VideoView
|
||||
videoTrack={localVideoTrack}
|
||||
style={styles.localVideo}
|
||||
objectFit="cover"
|
||||
mirror={true}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.peerName} numberOfLines={1}>
|
||||
{currentCall.peerName || '未知用户'}
|
||||
</Text>
|
||||
<Text style={styles.status}>{getStatusText()}</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity
|
||||
style={styles.topButton}
|
||||
onPress={toggleMinimize}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Bottom controls */}
|
||||
{!isVideoCallActive && (
|
||||
<View style={styles.centerArea}>
|
||||
<View style={styles.avatarOuter}>
|
||||
{currentCall.peerAvatar ? (
|
||||
<Image source={{ uri: currentCall.peerAvatar }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||
<Text style={styles.avatarText}>
|
||||
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.peerName} numberOfLines={1}>
|
||||
{currentCall.peerName || '未知用户'}
|
||||
</Text>
|
||||
<Text style={styles.status}>{getStatusText()}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isVideoCallActive && (
|
||||
<View style={styles.videoOverlayInfo}>
|
||||
<Text style={styles.videoPeerName} numberOfLines={1}>
|
||||
{currentCall.peerName || '未知用户'}
|
||||
</Text>
|
||||
<Text style={styles.videoStatus}>{getStatusText()}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.controls}>
|
||||
{/* End call - prominent red button */}
|
||||
<View style={styles.controlRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, currentCall.isMuted && styles.controlButtonActive]}
|
||||
onPress={toggleMute}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.controlCircle, currentCall.isMuted && styles.controlCircleActive]}>
|
||||
<MaterialCommunityIcons
|
||||
name={currentCall.isMuted ? 'microphone-off' : 'microphone'}
|
||||
size={26}
|
||||
color={currentCall.isMuted ? '#333' : '#fff'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.controlLabel, currentCall.isMuted && styles.controlLabelActive]}>
|
||||
{currentCall.isMuted ? '取消静音' : '静音'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, currentCall.isVideoEnabled && styles.controlButtonActive]}
|
||||
onPress={toggleVideo}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.controlCircle, currentCall.isVideoEnabled && styles.controlCircleActive]}>
|
||||
<MaterialCommunityIcons
|
||||
name={currentCall.isVideoEnabled ? 'video' : 'video-off'}
|
||||
size={26}
|
||||
color={currentCall.isVideoEnabled ? '#333' : '#fff'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.controlLabel, currentCall.isVideoEnabled && styles.controlLabelActive]}>
|
||||
{currentCall.isVideoEnabled ? '关闭摄像头' : '打开摄像头'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]}
|
||||
onPress={toggleSpeaker}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.controlCircle, currentCall.isSpeakerOn && styles.controlCircleActive]}>
|
||||
<MaterialCommunityIcons
|
||||
name={currentCall.isSpeakerOn ? 'volume-high' : 'cellphone'}
|
||||
size={26}
|
||||
color={currentCall.isSpeakerOn ? '#333' : '#fff'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.controlLabel, currentCall.isSpeakerOn && styles.controlLabelActive]}>
|
||||
{currentCall.isSpeakerOn ? '免提' : '听筒'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.endCallButton}
|
||||
onPress={handleEndCall}
|
||||
onPress={() => endCall('hangup')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.endCallCircle}>
|
||||
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
|
||||
</View>
|
||||
<Text style={styles.endCallLabel}>关闭</Text>
|
||||
<Text style={styles.endCallLabel}>挂断</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const END_CALL_SIZE = 64;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: '#1A1A2E',
|
||||
zIndex: 9999,
|
||||
},
|
||||
background: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: '#1A1A2E',
|
||||
},
|
||||
topBar: {
|
||||
position: 'absolute',
|
||||
top: 50,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 44,
|
||||
zIndex: 100,
|
||||
},
|
||||
topButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
centerArea: {
|
||||
position: 'absolute',
|
||||
top: '28%',
|
||||
@@ -132,20 +318,98 @@ const styles = StyleSheet.create({
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
fullScreenVideo: {
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: '#1A1A2E',
|
||||
},
|
||||
localVideoContainer: {
|
||||
position: 'absolute',
|
||||
top: 100,
|
||||
right: 20,
|
||||
width: 120,
|
||||
height: 160,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#2A2A4E',
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
zIndex: 50,
|
||||
},
|
||||
localVideo: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
videoOverlayInfo: {
|
||||
position: 'absolute',
|
||||
top: 100,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
zIndex: 50,
|
||||
},
|
||||
videoPeerName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
},
|
||||
videoStatus: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
marginTop: 4,
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
},
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
zIndex: 100,
|
||||
},
|
||||
controlRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 24,
|
||||
marginBottom: 24,
|
||||
},
|
||||
controlButton: {
|
||||
alignItems: 'center',
|
||||
width: 70,
|
||||
},
|
||||
controlCircle: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.12)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
controlCircleActive: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
controlButtonActive: {},
|
||||
controlLabel: {
|
||||
fontSize: 11,
|
||||
color: 'rgba(255, 255, 255, 0.55)',
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
controlLabelActive: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '500',
|
||||
},
|
||||
endCallButton: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
endCallCircle: {
|
||||
width: END_CALL_SIZE,
|
||||
height: END_CALL_SIZE,
|
||||
borderRadius: END_CALL_SIZE / 2,
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: '#E54D42',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -6,9 +6,12 @@ import {
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { MaterialCommunityIcons, Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { callStore } from '../../stores/call';
|
||||
|
||||
const BUTTON_SIZE = 40;
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
@@ -22,69 +25,86 @@ const FloatingCallWindow: React.FC = () => {
|
||||
const toggleMinimize = callStore((s) => s.toggleMinimize);
|
||||
const endCall = callStore((s) => s.endCall);
|
||||
|
||||
// Only show when there's an active call and it's minimized
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (!currentCall || !isMinimized) return null;
|
||||
|
||||
const getStatusText = (): string => {
|
||||
switch (currentCall.status) {
|
||||
case 'calling':
|
||||
return '等待接听...';
|
||||
case 'ringing':
|
||||
return '来电响铃...';
|
||||
case 'connecting':
|
||||
return '连接中...';
|
||||
case 'connected':
|
||||
return formatDuration(callDuration);
|
||||
case 'connecting':
|
||||
case 'calling':
|
||||
case 'ringing':
|
||||
return '连接中...';
|
||||
case 'reconnecting':
|
||||
return '重连中...';
|
||||
case 'ended':
|
||||
return '已结束';
|
||||
case 'failed':
|
||||
return '连接失败';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const isConnected = currentCall.status === 'connected';
|
||||
const participantCount = currentCall.participants.size;
|
||||
const isVideoCall = currentCall.mediaType === 'video';
|
||||
const isGroupCall = participantCount > 2;
|
||||
const displayName = isGroupCall
|
||||
? `群组通话 (${participantCount})`
|
||||
: currentCall.peerName || '未知用户';
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.container, { top: insets.top + 4 }]}>
|
||||
<TouchableOpacity
|
||||
style={styles.window}
|
||||
style={styles.bar}
|
||||
onPress={toggleMinimize}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<View style={styles.avatarContainer}>
|
||||
{currentCall.peerAvatar ? (
|
||||
<Image
|
||||
source={{ uri: currentCall.peerAvatar }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
{/* Call type indicator */}
|
||||
<View style={styles.callTypeIcon}>
|
||||
<Ionicons
|
||||
name={isVideoCall ? 'videocam' : 'call'}
|
||||
size={14}
|
||||
color={isConnected ? '#34C759' : 'rgba(255,255,255,0.6)'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Avatar + status indicator ring */}
|
||||
<View style={styles.avatarWrap}>
|
||||
{currentCall.peerAvatar && !isGroupCall ? (
|
||||
<Image source={{ uri: currentCall.peerAvatar }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||
<Text style={styles.avatarText}>
|
||||
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
|
||||
</Text>
|
||||
<View style={[styles.avatar, styles.avatarFallback]}>
|
||||
{isGroupCall ? (
|
||||
<MaterialCommunityIcons name="account-group" size={22} color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.avatarText}>
|
||||
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{isConnected && <View style={styles.statusDot} />}
|
||||
</View>
|
||||
|
||||
{/* Info */}
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{currentCall.peerName || '未知用户'}
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text style={[styles.status, isConnected && styles.statusTimer]}>
|
||||
{getStatusText()}
|
||||
</Text>
|
||||
<Text style={styles.status}>{getStatusText()}</Text>
|
||||
</View>
|
||||
|
||||
{/* End call button */}
|
||||
<TouchableOpacity
|
||||
style={styles.endButton}
|
||||
style={styles.endBtn}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
endCall('hangup');
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<MaterialCommunityIcons name="phone-hangup" size={20} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
@@ -96,25 +116,34 @@ const FloatingCallWindow: React.FC = () => {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
top: 100,
|
||||
left: 16,
|
||||
right: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10000,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
window: {
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#2A2A4E',
|
||||
borderRadius: 16,
|
||||
padding: 12,
|
||||
backgroundColor: 'rgba(20, 20, 40, 0.92)',
|
||||
borderRadius: 18,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 14,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 12,
|
||||
elevation: 10,
|
||||
borderWidth: 0.5,
|
||||
borderColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
avatarContainer: {
|
||||
marginRight: 12,
|
||||
callTypeIcon: {
|
||||
marginRight: 8,
|
||||
width: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarWrap: {
|
||||
position: 'relative',
|
||||
marginRight: 14,
|
||||
},
|
||||
avatar: {
|
||||
width: 44,
|
||||
@@ -122,7 +151,7 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#3A3A5C',
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
avatarFallback: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
@@ -131,28 +160,44 @@ const styles = StyleSheet.create({
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
statusDot: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#34C759',
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(20, 20, 40, 0.92)',
|
||||
},
|
||||
info: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
name: {
|
||||
fontSize: 15,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 2,
|
||||
},
|
||||
status: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
color: 'rgba(255, 255, 255, 0.45)',
|
||||
},
|
||||
endButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
statusTimer: {
|
||||
color: '#34C759',
|
||||
fontWeight: '500',
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
endBtn: {
|
||||
width: BUTTON_SIZE,
|
||||
height: BUTTON_SIZE,
|
||||
borderRadius: BUTTON_SIZE / 2,
|
||||
backgroundColor: '#E54D42',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: 8,
|
||||
marginLeft: 10,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -8,160 +8,190 @@ import {
|
||||
Animated,
|
||||
Modal,
|
||||
StatusBar,
|
||||
Dimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { callStore } from '../../stores/call';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
const AVATAR_SIZE = 104;
|
||||
const BTN_SIZE = 68;
|
||||
const BTN_ICON = 30;
|
||||
|
||||
const IncomingCallModal: React.FC = () => {
|
||||
const incomingCall = callStore((s) => s.incomingCall);
|
||||
const acceptCall = callStore((s) => s.acceptCall);
|
||||
const rejectCall = callStore((s) => s.rejectCall);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
const rippleAnim = useRef(new Animated.Value(0)).current;
|
||||
const rippleAnim1 = useRef(new Animated.Value(0)).current;
|
||||
const rippleAnim2 = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (incomingCall) {
|
||||
blurActiveElement();
|
||||
|
||||
const pulse = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.06,
|
||||
duration: 1000,
|
||||
toValue: 1.05,
|
||||
duration: 1200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
duration: 1200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
]),
|
||||
);
|
||||
|
||||
const ripple = Animated.loop(
|
||||
const ripple1 = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(rippleAnim, {
|
||||
Animated.timing(rippleAnim1, {
|
||||
toValue: 1,
|
||||
duration: 2000,
|
||||
duration: 2200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(rippleAnim, {
|
||||
Animated.timing(rippleAnim1, {
|
||||
toValue: 0,
|
||||
duration: 0,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
]),
|
||||
);
|
||||
|
||||
Animated.parallel([pulse, ripple]).start();
|
||||
const ripple2 = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.delay(1100),
|
||||
Animated.timing(rippleAnim2, {
|
||||
toValue: 1,
|
||||
duration: 2200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(rippleAnim2, {
|
||||
toValue: 0,
|
||||
duration: 0,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
Animated.parallel([pulse, ripple1, ripple2]).start();
|
||||
return () => {
|
||||
pulse.stop();
|
||||
ripple.stop();
|
||||
ripple1.stop();
|
||||
ripple2.stop();
|
||||
};
|
||||
}
|
||||
}, [incomingCall, pulseAnim, rippleAnim]);
|
||||
|
||||
const handleAccept = () => {
|
||||
acceptCall();
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
rejectCall();
|
||||
};
|
||||
}, [incomingCall, pulseAnim, rippleAnim1, rippleAnim2]);
|
||||
|
||||
if (!incomingCall) return null;
|
||||
|
||||
const callerName = incomingCall.callerName || '未知用户';
|
||||
const callerInitial = callerName.charAt(0).toUpperCase();
|
||||
const isVideoCall = incomingCall.callType === 'video';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={!!incomingCall}
|
||||
visible
|
||||
transparent
|
||||
animationType="fade"
|
||||
statusBarTranslucent
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||
<View style={styles.root}>
|
||||
<StatusBar barStyle="light-content" translucent backgroundColor="transparent" />
|
||||
|
||||
{/* Avatar with ripple effect - positioned in upper area */}
|
||||
{/* Top safe area + label */}
|
||||
<View style={[styles.topLabel, { paddingTop: insets.top + 16 }]}>
|
||||
<Text style={styles.topLabelText}>
|
||||
{isVideoCall ? '视频通话邀请' : '语音通话邀请'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Avatar section with ripples */}
|
||||
<View style={styles.avatarSection}>
|
||||
{/* Ripple rings */}
|
||||
{[0, 1, 2].map((i) => (
|
||||
{/* Ripple rings — staggered */}
|
||||
{[
|
||||
{ anim: rippleAnim1, scaleRange: [1, 1.6], opacityRange: [0.2, 0] as const },
|
||||
{ anim: rippleAnim2, scaleRange: [1, 1.4], opacityRange: [0.15, 0] as const },
|
||||
].map(({ anim, scaleRange, opacityRange }, i) => (
|
||||
<Animated.View
|
||||
key={i}
|
||||
style={[
|
||||
styles.rippleRing,
|
||||
styles.ripple,
|
||||
{
|
||||
transform: [
|
||||
{
|
||||
scale: rippleAnim.interpolate({
|
||||
scale: anim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [1, 1.5 + i * 0.2],
|
||||
outputRange: scaleRange,
|
||||
}),
|
||||
},
|
||||
],
|
||||
opacity: rippleAnim.interpolate({
|
||||
inputRange: [0, 0.4, 1],
|
||||
outputRange: [0.25, 0.12, 0],
|
||||
opacity: anim.interpolate({
|
||||
inputRange: [0, 0.5, 1],
|
||||
outputRange: [0, ...opacityRange],
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Animated.View
|
||||
style={[styles.avatarContainer, { transform: [{ scale: pulseAnim }] }]}
|
||||
>
|
||||
{incomingCall.callerAvatar ? (
|
||||
<Image
|
||||
source={{ uri: incomingCall.callerAvatar }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||
<Text style={styles.avatarText}>
|
||||
{incomingCall.callerName?.charAt(0).toUpperCase() || '?'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
|
||||
<View style={styles.avatarBorder}>
|
||||
{incomingCall.callerAvatar ? (
|
||||
<Image source={{ uri: incomingCall.callerAvatar }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={[styles.avatar, styles.avatarFallback]}>
|
||||
<Text style={styles.avatarInitial}>{callerInitial}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Caller info */}
|
||||
<View style={styles.infoSection}>
|
||||
<Text style={styles.callerName} numberOfLines={1}>
|
||||
{incomingCall.callerName || '未知用户'}
|
||||
{callerName}
|
||||
</Text>
|
||||
<Text style={styles.callType}>
|
||||
{incomingCall.callType === 'video' ? '视频通话' : '语音通话'}
|
||||
{isVideoCall ? '邀请你进行视频通话' : '邀请你进行语音通话'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Bottom controls */}
|
||||
<View style={styles.controls}>
|
||||
{/* Bottom action buttons */}
|
||||
<View style={[styles.actions, { paddingBottom: insets.bottom + 32 }]}>
|
||||
{/* Decline */}
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={handleReject}
|
||||
style={styles.actionTouchable}
|
||||
onPress={rejectCall}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.declineCircle}>
|
||||
<MaterialCommunityIcons name="phone-hangup" size={28} color="#fff" />
|
||||
<MaterialCommunityIcons
|
||||
name="phone-hangup"
|
||||
size={BTN_ICON}
|
||||
color="#fff"
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.actionLabel}>拒绝</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Accept */}
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={handleAccept}
|
||||
style={styles.actionTouchable}
|
||||
onPress={acceptCall}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.acceptCircle}>
|
||||
<MaterialCommunityIcons name="phone" size={28} color="#fff" />
|
||||
<MaterialCommunityIcons
|
||||
name="phone"
|
||||
size={BTN_ICON}
|
||||
color="#fff"
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.actionLabel}>接听</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -171,36 +201,45 @@ const IncomingCallModal: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const AVATAR_SIZE = 100;
|
||||
const ACTION_CIRCLE_SIZE = 60;
|
||||
export default IncomingCallModal;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0D0D0D',
|
||||
backgroundColor: '#0A0A14',
|
||||
},
|
||||
avatarSection: {
|
||||
position: 'absolute',
|
||||
top: '18%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: AVATAR_SIZE + 60,
|
||||
justifyContent: 'center',
|
||||
topLabel: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
rippleRing: {
|
||||
topLabelText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: 'rgba(255,255,255,0.35)',
|
||||
letterSpacing: 0.5,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
// ---- Avatar + ripples ----
|
||||
avatarSection: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
maxHeight: 260,
|
||||
},
|
||||
ripple: {
|
||||
position: 'absolute',
|
||||
width: AVATAR_SIZE + 16,
|
||||
height: AVATAR_SIZE + 16,
|
||||
borderRadius: (AVATAR_SIZE + 16) / 2,
|
||||
width: AVATAR_SIZE + 32,
|
||||
height: AVATAR_SIZE + 32,
|
||||
borderRadius: (AVATAR_SIZE + 32) / 2,
|
||||
borderWidth: 2,
|
||||
borderColor: '#4CD964',
|
||||
},
|
||||
avatarContainer: {
|
||||
width: AVATAR_SIZE + 8,
|
||||
height: AVATAR_SIZE + 8,
|
||||
borderRadius: (AVATAR_SIZE + 8) / 2,
|
||||
backgroundColor: '#4CD964',
|
||||
avatarBorder: {
|
||||
width: AVATAR_SIZE + 10,
|
||||
height: AVATAR_SIZE + 10,
|
||||
borderRadius: (AVATAR_SIZE + 10) / 2,
|
||||
borderWidth: 2.5,
|
||||
borderColor: 'rgba(76, 217, 100, 0.4)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
@@ -208,72 +247,80 @@ const styles = StyleSheet.create({
|
||||
width: AVATAR_SIZE,
|
||||
height: AVATAR_SIZE,
|
||||
borderRadius: AVATAR_SIZE / 2,
|
||||
backgroundColor: '#3A3A5C',
|
||||
backgroundColor: '#2A2A4E',
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
avatarFallback: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 36,
|
||||
avatarInitial: {
|
||||
fontSize: 42,
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// ---- Info ----
|
||||
infoSection: {
|
||||
position: 'absolute',
|
||||
top: '42%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 40,
|
||||
marginTop: -20,
|
||||
marginBottom: 40,
|
||||
},
|
||||
callerName: {
|
||||
fontSize: 24,
|
||||
fontSize: 28,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 6,
|
||||
textAlign: 'center',
|
||||
maxWidth: 280,
|
||||
maxWidth: 300,
|
||||
},
|
||||
callType: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
letterSpacing: 0.3,
|
||||
fontSize: 15,
|
||||
fontWeight: '400',
|
||||
color: 'rgba(255,255,255,0.45)',
|
||||
marginTop: 8,
|
||||
},
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
bottom: '12%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
|
||||
// ---- Actions ----
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 60,
|
||||
gap: 72,
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
actionButton: {
|
||||
actionTouchable: {
|
||||
alignItems: 'center',
|
||||
width: 80,
|
||||
},
|
||||
declineCircle: {
|
||||
width: ACTION_CIRCLE_SIZE,
|
||||
height: ACTION_CIRCLE_SIZE,
|
||||
borderRadius: ACTION_CIRCLE_SIZE / 2,
|
||||
width: BTN_SIZE,
|
||||
height: BTN_SIZE,
|
||||
borderRadius: BTN_SIZE / 2,
|
||||
backgroundColor: '#E54D42',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#E54D42',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.45,
|
||||
shadowRadius: 10,
|
||||
elevation: 8,
|
||||
},
|
||||
acceptCircle: {
|
||||
width: ACTION_CIRCLE_SIZE,
|
||||
height: ACTION_CIRCLE_SIZE,
|
||||
borderRadius: ACTION_CIRCLE_SIZE / 2,
|
||||
width: BTN_SIZE,
|
||||
height: BTN_SIZE,
|
||||
borderRadius: BTN_SIZE / 2,
|
||||
backgroundColor: '#4CD964',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#4CD964',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.45,
|
||||
shadowRadius: 10,
|
||||
elevation: 8,
|
||||
},
|
||||
actionLabel: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
marginTop: 10,
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
marginTop: 12,
|
||||
},
|
||||
});
|
||||
|
||||
export default IncomingCallModal;
|
||||
|
||||
@@ -346,7 +346,7 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
},
|
||||
overlayTouchable: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
|
||||
@@ -5,9 +5,10 @@ interface HighlightTextProps {
|
||||
text: string;
|
||||
keyword: string;
|
||||
style?: StyleProp<TextStyle>;
|
||||
highlightStyle?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
const HighlightText: React.FC<HighlightTextProps> = ({ text, keyword, style }) => {
|
||||
const HighlightText: React.FC<HighlightTextProps> = ({ text, keyword, style, highlightStyle }) => {
|
||||
const parts = useMemo(() => {
|
||||
if (!text || !keyword) return [{ text, highlight: false }];
|
||||
|
||||
@@ -37,12 +38,10 @@ const HighlightText: React.FC<HighlightTextProps> = ({ text, keyword, style }) =
|
||||
}, [text, keyword]);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<Text style={style}>
|
||||
{parts.map((part, i) =>
|
||||
part.highlight ? (
|
||||
<Text key={i} style={style}>
|
||||
{part.text}
|
||||
</Text>
|
||||
<Text key={i} style={highlightStyle}>{part.text}</Text>
|
||||
) : (
|
||||
<Text key={i}>{part.text}</Text>
|
||||
)
|
||||
|
||||
@@ -30,8 +30,6 @@ import Animated, {
|
||||
} from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import { spacing, borderRadius, fontSizes } from '../../theme';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
@@ -203,7 +201,10 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
|
||||
// 保存图片到本地相册
|
||||
const handleSaveImage = useCallback(async () => {
|
||||
if (!currentImage || saving) return;
|
||||
if (!currentImage || saving || Platform.OS === 'web') return;
|
||||
|
||||
const MediaLibrary = require('expo-media-library') as typeof import('expo-media-library');
|
||||
const { File, Paths } = require('expo-file-system') as typeof import('expo-file-system');
|
||||
|
||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
@@ -212,6 +213,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
let downloaded: InstanceType<typeof File> | null = null;
|
||||
try {
|
||||
const urlPath = currentImage.url.split('?')[0];
|
||||
const ext = urlPath.split('.').pop()?.toLowerCase() ?? 'jpg';
|
||||
@@ -221,13 +223,14 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
const fileName = `withyou_${Date.now()}.${fileExt}`;
|
||||
const destination = new File(Paths.cache, fileName);
|
||||
|
||||
// File.downloadFileAsync 是新版 expo-file-system/next 的静态方法
|
||||
const downloaded = await File.downloadFileAsync(currentImage.url, destination);
|
||||
// File.downloadFileAsync 是新版 expo-file-system 的静态方法
|
||||
downloaded = await File.downloadFileAsync(currentImage.url, destination, {
|
||||
idempotent: true,
|
||||
});
|
||||
|
||||
await MediaLibrary.saveToLibraryAsync(downloaded.uri);
|
||||
|
||||
// 清理缓存文件
|
||||
downloaded.delete();
|
||||
// expo-media-library v17+ 已移除顶层 saveToLibraryAsync,改用 Asset.create()
|
||||
// 在 Android 上 filePath 必须以 file:/// 开头
|
||||
await MediaLibrary.Asset.create(downloaded.uri);
|
||||
|
||||
onSave?.(currentImage.url);
|
||||
showToast('success');
|
||||
@@ -235,6 +238,14 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
console.error('[ImageGallery] 保存图片失败:', err);
|
||||
showToast('error');
|
||||
} finally {
|
||||
// 清理缓存文件(无论成功失败都清理,避免残留)
|
||||
if (downloaded) {
|
||||
try {
|
||||
downloaded.delete();
|
||||
} catch (cleanupErr) {
|
||||
console.warn('[ImageGallery] 清理缓存文件失败:', cleanupErr);
|
||||
}
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
}, [currentImage, saving, onSave, showToast]);
|
||||
@@ -548,7 +559,7 @@ const styles = StyleSheet.create({
|
||||
height: SCREEN_HEIGHT * 0.8,
|
||||
},
|
||||
errorContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#000',
|
||||
|
||||
@@ -148,7 +148,7 @@ function createImageGridStyles(colors: AppColors) {
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
moreOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
@@ -390,9 +390,11 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
return (
|
||||
<View style={[gridStyles.horizontalContainer, { gap }]}>
|
||||
{displayImages.map((image, index) => {
|
||||
const previewUrl = usePreview && displayMode
|
||||
const mainUri = image.uri || image.url || '';
|
||||
const previewForSmart = usePreview && displayMode
|
||||
? getPreviewImageUrl(image as any, displayMode)
|
||||
: (image.uri || image.url || '');
|
||||
: '';
|
||||
const useSmartPreview = !!(usePreview && previewForSmart && mainUri && previewForSmart !== mainUri);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -408,7 +410,9 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: previewUrl }}
|
||||
source={{ uri: mainUri }}
|
||||
previewUrl={useSmartPreview ? previewForSmart : undefined}
|
||||
usePreview={useSmartPreview}
|
||||
style={gridStyles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
@@ -428,9 +432,11 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
const isLastVisible = index === displayImages.length - 1;
|
||||
const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay;
|
||||
|
||||
const previewUrl = usePreview && displayMode
|
||||
const mainUri = image.uri || image.url || '';
|
||||
const previewForSmart = usePreview && displayMode
|
||||
? getPreviewImageUrl(image as any, displayMode)
|
||||
: (image.uri || image.url || '');
|
||||
: '';
|
||||
const useSmartPreview = !!(usePreview && previewForSmart && mainUri && previewForSmart !== mainUri);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -445,7 +451,9 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: previewUrl }}
|
||||
source={{ uri: mainUri }}
|
||||
previewUrl={useSmartPreview ? previewForSmart : undefined}
|
||||
usePreview={useSmartPreview}
|
||||
style={gridStyles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
@@ -490,9 +498,11 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
: 1;
|
||||
const height = itemWidth / aspectRatio;
|
||||
|
||||
const previewUrl = usePreview && displayMode
|
||||
const mainUri = image.uri || image.url || '';
|
||||
const previewForSmart = usePreview && displayMode
|
||||
? getPreviewImageUrl(image as any, displayMode)
|
||||
: (image.uri || image.url || '');
|
||||
: '';
|
||||
const useSmartPreview = !!(usePreview && previewForSmart && mainUri && previewForSmart !== mainUri);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -508,7 +518,9 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: previewUrl }}
|
||||
source={{ uri: mainUri }}
|
||||
previewUrl={useSmartPreview ? previewForSmart : undefined}
|
||||
usePreview={useSmartPreview}
|
||||
style={gridStyles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
|
||||
@@ -30,7 +30,7 @@ function createSmartImageStyles(colors: AppColors) {
|
||||
flex: 1,
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.disabled,
|
||||
@@ -125,6 +125,7 @@ export const SmartImage: React.FC<SmartImageProps> = ({
|
||||
const [loadState, setLoadState] = useState<ImageLoadState>('loading');
|
||||
const [isVisible, setIsVisible] = useState(() => !lazyLoad || Platform.OS !== 'web');
|
||||
const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false);
|
||||
const isUpgradingFromPreview = useRef(false);
|
||||
const lazyTargetRef = useRef<View>(null);
|
||||
|
||||
// 解析图片源 - 支持 uri 或 url 字段
|
||||
@@ -137,6 +138,7 @@ export const SmartImage: React.FC<SmartImageProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
setShouldLoadOriginal(false);
|
||||
isUpgradingFromPreview.current = false;
|
||||
setLoadState('loading');
|
||||
}, [imageUri]);
|
||||
|
||||
@@ -180,16 +182,25 @@ export const SmartImage: React.FC<SmartImageProps> = ({
|
||||
|
||||
// 处理加载开始
|
||||
const handleLoadStart = useCallback(() => {
|
||||
setLoadState('loading');
|
||||
// 从预览图切换到原图时,不重置为loading状态,避免闪烁
|
||||
if (isUpgradingFromPreview.current) {
|
||||
return;
|
||||
}
|
||||
// 已成功加载的图片不再回到loading状态(列表回收/re-render时onLoadStart可能重新触发)
|
||||
setLoadState(prev => (prev === 'success' ? prev : 'loading'));
|
||||
}, []);
|
||||
|
||||
// 处理加载完成
|
||||
const handleLoad = useCallback(() => {
|
||||
setLoadState('success');
|
||||
// 如果正在加载预览图,切换到原图
|
||||
// 如果正在加载预览图,切换到原图(不重置loading状态,避免闪烁)
|
||||
if (usePreview && !shouldLoadOriginal && previewUri && getImageUrl() === previewUri) {
|
||||
isUpgradingFromPreview.current = true;
|
||||
setShouldLoadOriginal(true);
|
||||
return;
|
||||
}
|
||||
// 原图加载完成(包括从预览图升级的情况)
|
||||
isUpgradingFromPreview.current = false;
|
||||
setLoadState('success');
|
||||
onLoad?.();
|
||||
}, [onLoad, usePreview, shouldLoadOriginal, previewUri, getImageUrl]);
|
||||
|
||||
@@ -198,9 +209,11 @@ export const SmartImage: React.FC<SmartImageProps> = ({
|
||||
(error: any) => {
|
||||
// 如果预览图加载失败,尝试加载原图
|
||||
if (usePreview && !shouldLoadOriginal && getImageUrl() === previewUri && imageUri) {
|
||||
isUpgradingFromPreview.current = true;
|
||||
setShouldLoadOriginal(true);
|
||||
return;
|
||||
}
|
||||
isUpgradingFromPreview.current = false;
|
||||
setLoadState('error');
|
||||
onError?.(error);
|
||||
},
|
||||
@@ -298,6 +311,7 @@ export const SmartImage: React.FC<SmartImageProps> = ({
|
||||
resizeMode === 'center' ? 'scale-down' :
|
||||
resizeMode
|
||||
}
|
||||
transition={200}
|
||||
cachePolicy={cachePolicy}
|
||||
onLoadStart={handleLoadStart}
|
||||
onLoad={handleLoad}
|
||||
|
||||
@@ -152,7 +152,7 @@ export class CacheDataSource implements ICacheDataSource {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const cacheKeys = keys.filter(k => k.startsWith(this.persistPrefix));
|
||||
if (cacheKeys.length > 0) {
|
||||
await AsyncStorage.multiRemove(cacheKeys);
|
||||
await AsyncStorage.removeMany(cacheKeys);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new DataSourceError(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { localDataSource } from '../LocalDataSource';
|
||||
import { CachedMessage } from '../types';
|
||||
import type { ILocalDataSource } from '@/data/datasources/interfaces';
|
||||
import { extractTextFromSegments } from '@/types/dto';
|
||||
|
||||
export interface IMessageRepository {
|
||||
saveMessage(message: CachedMessage): Promise<void>;
|
||||
@@ -17,6 +18,7 @@ export interface IMessageRepository {
|
||||
updateStatus(messageId: string, status: string, clearContent?: boolean): Promise<void>;
|
||||
clearConversation(conversationId: string): Promise<void>;
|
||||
search(keyword: string): Promise<CachedMessage[]>;
|
||||
searchByConversation(conversationId: string, keyword: string, limit?: number, offset?: number): Promise<CachedMessage[]>;
|
||||
getStats(): Promise<{ totalMessages: number; totalConversations: number }>;
|
||||
getLastMessageByConversation(conversationId: string): Promise<CachedMessage | null>;
|
||||
}
|
||||
@@ -143,6 +145,35 @@ export class MessageRepository implements IMessageRepository {
|
||||
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined }));
|
||||
}
|
||||
|
||||
async searchByConversation(
|
||||
conversationId: string,
|
||||
keyword: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<CachedMessage[]> {
|
||||
const likeParam = `%${keyword}%`;
|
||||
const rows = await this.dataSource.query<any>(
|
||||
`SELECT * FROM messages
|
||||
WHERE conversationId = ?
|
||||
AND status != 'deleted'
|
||||
AND (segments LIKE ? OR content LIKE ?)
|
||||
ORDER BY createdAt DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[conversationId, likeParam, likeParam, limit, offset]
|
||||
);
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
return rows
|
||||
.map(r => ({
|
||||
...r,
|
||||
isRead: r.isRead === 1,
|
||||
segments: r.segments ? JSON.parse(r.segments) : undefined,
|
||||
}))
|
||||
.filter(msg => {
|
||||
const text = extractTextFromSegments(msg.segments).toLowerCase();
|
||||
return text.includes(lowerKeyword);
|
||||
});
|
||||
}
|
||||
|
||||
async getStats(): Promise<{ totalMessages: number; totalConversations: number }> {
|
||||
const msgCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM messages`);
|
||||
const convCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM conversations`);
|
||||
|
||||
@@ -70,6 +70,9 @@ export { useMediaCache } from './useMediaCache';
|
||||
|
||||
export type { UseMediaCacheReturn } from './useMediaCache';
|
||||
|
||||
// ==================== 推送设备注册 Hook ====================
|
||||
export { useRegisterPushDevice } from './useRegisterPushDevice';
|
||||
|
||||
// ==================== 差异更新 Hooks ====================
|
||||
export { useDifferentialMessages } from './useDifferentialMessages';
|
||||
|
||||
|
||||
14
src/hooks/useChannels.ts
Normal file
14
src/hooks/useChannels.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { channelService, type ChannelItem } from '@/services/post/channelService';
|
||||
|
||||
export const channelKeys = {
|
||||
all: ['channels'] as const,
|
||||
};
|
||||
|
||||
export function useChannels() {
|
||||
return useQuery({
|
||||
queryKey: channelKeys.all,
|
||||
queryFn: () => channelService.list(),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
@@ -236,6 +236,14 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
|
||||
const currentState = usePostListStore.getState().getPostsState(listKey);
|
||||
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 postsState = state.postsStateMap.get(listKey);
|
||||
if (postsState) syncFromStore(postsState);
|
||||
@@ -277,6 +285,18 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
|
||||
const reset = useCallback(() => {
|
||||
calculatorRef.current?.reset();
|
||||
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([]);
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
@@ -284,7 +304,7 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
|
||||
setHasMore(true);
|
||||
previousPostsRef.current = [];
|
||||
setDiffUpdates({ addedCount: 0, updatedCount: 0, deletedCount: 0, lastUpdateTime: 0 });
|
||||
}, []);
|
||||
}, [listKey]);
|
||||
|
||||
const forceUpdate = useCallback((newPosts: T[]) => {
|
||||
setPosts(newPosts);
|
||||
|
||||
35
src/hooks/useRegisterPushDevice.ts
Normal file
35
src/hooks/useRegisterPushDevice.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import { jpushService } from '../services/notification/jpushService';
|
||||
|
||||
export function useRegisterPushDevice(isAuthenticated: boolean, userID?: string) {
|
||||
const deviceRegistered = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'web' || !isAuthenticated || !userID) return;
|
||||
|
||||
// JPush 初始化只取决于登录态——这是基础推送通道(获取 RegistrationID、
|
||||
// 向服务器注册设备 token),与"是否允许后台自启动/保活"无关。
|
||||
// 后台保活由 backgroundService 控制;退后台是否保持 JPush 长连接由
|
||||
// jpushService._setBackgroundKeepLongConn() 根据 autoStart 同意状态自行处理。
|
||||
// 若在此处用 consent 拦截 init,未同意用户将永远拿不到 RegistrationID,
|
||||
// 即便在前台/已授予通知权限也无法收到推送。
|
||||
if (!deviceRegistered.current) {
|
||||
deviceRegistered.current = true;
|
||||
|
||||
jpushService.initialize().then((ok) => {
|
||||
if (ok && userID) {
|
||||
jpushService.setAliasForUser(userID);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!isAuthenticated) {
|
||||
deviceRegistered.current = false;
|
||||
jpushService.cleanup();
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated, userID]);
|
||||
}
|
||||
18
src/hooks/useUnreadCount.ts
Normal file
18
src/hooks/useUnreadCount.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { messageManager } from '@/stores/message/MessageManager';
|
||||
|
||||
export const messageKeys = {
|
||||
unread: ['messages', 'unread'] as const,
|
||||
};
|
||||
|
||||
export function useUnreadCountQuery() {
|
||||
return useQuery({
|
||||
queryKey: messageKeys.unread,
|
||||
queryFn: async () => {
|
||||
await messageManager.fetchUnreadCount();
|
||||
return null;
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
});
|
||||
}
|
||||
@@ -154,9 +154,9 @@ export class MediaCacheManager {
|
||||
|
||||
if (recordKeys.length === 0) return;
|
||||
|
||||
const records = await AsyncStorage.multiGet(recordKeys);
|
||||
const records = await AsyncStorage.getMany(recordKeys);
|
||||
|
||||
for (const [key, value] of records) {
|
||||
for (const [key, value] of Object.entries(records)) {
|
||||
if (value) {
|
||||
const entry: CacheEntry = JSON.parse(value);
|
||||
// 验证文件是否存在
|
||||
@@ -614,7 +614,7 @@ export class MediaCacheManager {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const recordKeys = keys.filter(k => k.startsWith(CACHE_RECORD_PREFIX));
|
||||
if (recordKeys.length > 0) {
|
||||
await AsyncStorage.multiRemove(recordKeys);
|
||||
await AsyncStorage.removeMany(recordKeys);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
* Expo Router 路径构建(单一事实来源,避免魔法字符串散落)
|
||||
*/
|
||||
import type { SystemMessageResponse } from '../types/dto';
|
||||
import { routePayloadCache } from '../stores';
|
||||
|
||||
export function hrefPostDetail(postId: string, scrollToComments?: boolean): string {
|
||||
const q = scrollToComments ? '?scrollToComments=1' : '';
|
||||
return `/post/${encodeURIComponent(postId)}${q}`;
|
||||
}
|
||||
|
||||
export function hrefTradeDetail(tradeId: string): string {
|
||||
return `/trade/${encodeURIComponent(tradeId)}`;
|
||||
}
|
||||
|
||||
export function hrefUserProfile(userId: string): string {
|
||||
return `/user/${encodeURIComponent(userId)}`;
|
||||
}
|
||||
@@ -87,14 +90,16 @@ export function hrefChat(params: {
|
||||
groupId?: string;
|
||||
groupName?: string;
|
||||
groupAvatar?: string;
|
||||
scrollToSeq?: number;
|
||||
}): string {
|
||||
const { conversationId, userId, isGroupChat, groupId, groupName, groupAvatar } = params;
|
||||
const { conversationId, userId, isGroupChat, groupId, groupName, groupAvatar, scrollToSeq } = params;
|
||||
const q = new URLSearchParams();
|
||||
if (userId) q.set('userId', userId);
|
||||
if (isGroupChat) q.set('isGroupChat', '1');
|
||||
if (groupId != null && groupId !== '') q.set('groupId', String(groupId));
|
||||
if (groupName) q.set('groupName', groupName);
|
||||
if (groupAvatar) q.set('groupAvatar', groupAvatar);
|
||||
if (scrollToSeq != null) q.set('scrollToSeq', String(scrollToSeq));
|
||||
const qs = q.toString();
|
||||
return `/chat/${encodeURIComponent(conversationId)}${qs ? `?${qs}` : ''}`;
|
||||
}
|
||||
@@ -122,15 +127,26 @@ export function hrefGroupMembers(groupId: string): string {
|
||||
}
|
||||
|
||||
export function hrefGroupRequestDetail(message: SystemMessageResponse): string {
|
||||
routePayloadCache.stashSystemMessage(message);
|
||||
return `/group/request?messageId=${encodeURIComponent(message.id)}`;
|
||||
}
|
||||
|
||||
export function hrefGroupInviteDetail(message: SystemMessageResponse): string {
|
||||
routePayloadCache.stashSystemMessage(message);
|
||||
return `/group/invite?messageId=${encodeURIComponent(message.id)}`;
|
||||
}
|
||||
|
||||
export function hrefMessageSearch(params: {
|
||||
conversationId: string;
|
||||
conversationName?: string;
|
||||
isGroupChat?: boolean;
|
||||
}): string {
|
||||
const q = new URLSearchParams({
|
||||
conversationId: params.conversationId,
|
||||
});
|
||||
if (params.conversationName) q.set('conversationName', params.conversationName);
|
||||
if (params.isGroupChat) q.set('isGroupChat', '1');
|
||||
return `/chat/message-search?${q.toString()}`;
|
||||
}
|
||||
|
||||
export function hrefPrivateChatInfo(params: {
|
||||
conversationId: string;
|
||||
userId: string;
|
||||
|
||||
43
src/polyfills.ts
Normal file
43
src/polyfills.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
// Register WebRTC globals for LiveKit (native only — web has built-in WebRTC)
|
||||
if (Platform.OS !== 'web') {
|
||||
// Use @livekit/react-native's registerGlobals which sets up:
|
||||
// - WebRTC native modules (via @livekit/react-native-webrtc)
|
||||
// - iOS audio session management
|
||||
// - LiveKitReactNativeGlobal (so livekit-client detects RN environment)
|
||||
// - RN-specific polyfills (Promise.allSettled, webstreams, crypto, etc.)
|
||||
const { registerGlobals } = require('@livekit/react-native');
|
||||
registerGlobals();
|
||||
}
|
||||
|
||||
if (typeof globalThis.Event === 'undefined') {
|
||||
(globalThis as any).Event = class Event {
|
||||
type: string;
|
||||
bubbles: boolean;
|
||||
cancelable: boolean;
|
||||
defaultPrevented: boolean;
|
||||
target: any;
|
||||
currentTarget: any;
|
||||
constructor(type: string, opts?: { bubbles?: boolean; cancelable?: boolean }) {
|
||||
this.type = type;
|
||||
this.bubbles = opts?.bubbles ?? false;
|
||||
this.cancelable = opts?.cancelable ?? false;
|
||||
this.defaultPrevented = false;
|
||||
this.target = null;
|
||||
this.currentTarget = null;
|
||||
}
|
||||
preventDefault() { this.defaultPrevented = true; }
|
||||
stopPropagation() {}
|
||||
stopImmediatePropagation() {}
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof globalThis.DOMException === 'undefined') {
|
||||
(globalThis as any).DOMException = class DOMException extends Error {
|
||||
constructor(message?: string, name?: string) {
|
||||
super(message);
|
||||
this.name = name || 'DOMException';
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export const RegisterScreen: React.FC = () => {
|
||||
resetRegisterData,
|
||||
goToNextStep,
|
||||
goToPrevStep,
|
||||
} = useRegisterStore();
|
||||
} = useRegisterStore.getState();
|
||||
|
||||
// 本地状态
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
@@ -38,15 +38,26 @@ import {
|
||||
import { Text, ResponsiveContainer } from '../../components/common';
|
||||
import { channelService, postService, showPrompt, voteService } from '../../services';
|
||||
import { ApiError } from '@/services/core';
|
||||
import { uploadService } from '@/services/upload';
|
||||
import VoteEditor from '../../components/business/VoteEditor';
|
||||
import PostMentionInput from '../../components/business/PostMentionInput';
|
||||
import BlockEditor, { BlockEditorHandle } from '../../components/business/BlockEditor';
|
||||
import { segmentsToBlocks } from '../../components/business/BlockEditor/blocksToSegments';
|
||||
import type { EditorBlock } from '../../components/business/BlockEditor/blockEditorTypes';
|
||||
import {
|
||||
generateBlockId,
|
||||
type EditorBlock,
|
||||
type ImageBlock,
|
||||
} from '../../components/business/BlockEditor/blockEditorTypes';
|
||||
import { useResponsive, useResponsiveValue } from '../../hooks';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { MessageSegment } from '../../types';
|
||||
import {
|
||||
type PendingOrRemoteImage,
|
||||
type RemoteImage,
|
||||
generatePendingImageId,
|
||||
makePendingImageFromAsset,
|
||||
getImageDisplayUri,
|
||||
uploadAllPendingImages,
|
||||
} from '../../utils/pendingImages';
|
||||
|
||||
// Props 接口
|
||||
interface CreatePostScreenProps {
|
||||
@@ -130,7 +141,8 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [segments, setSegments] = useState<MessageSegment[]>([]);
|
||||
const [images, setImages] = useState<{ uri: string; uploading?: boolean }[]>([]);
|
||||
// 图片状态:pending 表示本地待上传,remote 表示已是服务器 URL(编辑模式下的已存在图片)
|
||||
const [images, setImages] = useState<PendingOrRemoteImage[]>([]);
|
||||
const [channelOptions, setChannelOptions] = useState<ChannelOption[]>([]);
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
|
||||
const [showChannelPicker, setShowChannelPicker] = useState(false);
|
||||
@@ -213,7 +225,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
}
|
||||
setTitle(existingPost.title || '');
|
||||
setContent(existingPost.content || '');
|
||||
setImages((existingPost.images || []).map((img) => ({ uri: img.url, uploading: false })));
|
||||
setImages((existingPost.images || []).map((img): RemoteImage => ({ id: generatePendingImageId(), kind: 'remote', url: img.url })));
|
||||
|
||||
// 自动判断长文模式:segments 中包含图片块即为长文帖子
|
||||
const hasImageSegments = (existingPost.segments || []).some(s => s.type === 'image');
|
||||
@@ -233,7 +245,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
loadPostForEdit();
|
||||
}, [isEditMode, editPostID, router]);
|
||||
|
||||
// 选择图片(普通模式)
|
||||
// 选择图片(普通模式)—— 仅选择,不立即上传
|
||||
const handlePickImage = async () => {
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
@@ -250,35 +262,12 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets) {
|
||||
const newImages = result.assets.map(asset => ({ uri: asset.uri, uploading: true }));
|
||||
const newImages = result.assets.map(asset => makePendingImageFromAsset(asset));
|
||||
setImages(prev => [...prev, ...newImages]);
|
||||
|
||||
// 上传图片
|
||||
for (let i = 0; i < newImages.length; i++) {
|
||||
const asset = result.assets[i];
|
||||
const uploadResult = await uploadService.uploadImage({
|
||||
uri: asset.uri,
|
||||
type: asset.mimeType || 'image/jpeg',
|
||||
});
|
||||
|
||||
if (uploadResult) {
|
||||
setImages(prev => {
|
||||
const updated = [...prev];
|
||||
const index = prev.findIndex(img => img.uri === asset.uri);
|
||||
if (index !== -1) {
|
||||
updated[index] = { uri: uploadResult.url, uploading: false };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
Alert.alert('上传失败', '图片上传失败,请重试');
|
||||
setImages(prev => prev.filter(img => img.uri !== asset.uri));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 拍照
|
||||
// 拍照 —— 仅添加,不立即上传
|
||||
const handleTakePhoto = async () => {
|
||||
const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
|
||||
|
||||
@@ -294,33 +283,13 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const asset = result.assets[0];
|
||||
setImages(prev => [...prev, { uri: asset.uri, uploading: true }]);
|
||||
|
||||
// 上传图片
|
||||
const uploadResult = await uploadService.uploadImage({
|
||||
uri: asset.uri,
|
||||
type: asset.mimeType || 'image/jpeg',
|
||||
});
|
||||
|
||||
if (uploadResult) {
|
||||
setImages(prev => {
|
||||
const updated = [...prev];
|
||||
const index = prev.findIndex(img => img.uri === asset.uri);
|
||||
if (index !== -1) {
|
||||
updated[index] = { uri: uploadResult.url, uploading: false };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
Alert.alert('上传失败', '图片上传失败,请重试');
|
||||
setImages(prev => prev.filter(img => img.uri !== asset.uri));
|
||||
}
|
||||
setImages(prev => [...prev, makePendingImageFromAsset(asset)]);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除图片
|
||||
const handleRemoveImage = (index: number) => {
|
||||
setImages(images.filter((_, i) => i !== index));
|
||||
setImages(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 选择频道(单选,可再次点击取消)
|
||||
@@ -350,26 +319,44 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
// 切换长文/非长文模式时保留数据
|
||||
const handleToggleLongPostMode = useCallback(() => {
|
||||
if (!isLongPostMode) {
|
||||
// 非长文 -> 长文:将当前文本和图片转为 blocks
|
||||
const segs: MessageSegment[] = [];
|
||||
// 非长文 -> 长文:将当前文本和图片(包括 pending)转为 blocks
|
||||
const blocks: EditorBlock[] = [];
|
||||
if (content.trim()) {
|
||||
segs.push({ type: 'text', data: { text: content } });
|
||||
blocks.push({
|
||||
id: generateBlockId(),
|
||||
type: 'text',
|
||||
text: content,
|
||||
activeMentions: new Map(),
|
||||
});
|
||||
}
|
||||
for (const img of images) {
|
||||
if (img.uri && !img.uploading) {
|
||||
segs.push({ type: 'image', data: { url: img.uri } });
|
||||
const imageBlock: ImageBlock = img.kind === 'remote'
|
||||
? { id: generateBlockId(), type: 'image', localUri: img.url, remoteUrl: img.url }
|
||||
: { id: generateBlockId(), type: 'image', localUri: img.localUri, mimeType: img.mimeType };
|
||||
blocks.push(imageBlock);
|
||||
}
|
||||
setInitialBlocks(blocks.length > 0 ? blocks : undefined);
|
||||
} else {
|
||||
// 长文 -> 非长文:从 BlockEditor 提取文本和所有图片(包括 pending)
|
||||
const editorContent = blockEditorRef.current?.getContent() || '';
|
||||
const editorBlocks = blockEditorRef.current?.getBlocks() || [];
|
||||
if (editorContent) setContent(editorContent);
|
||||
|
||||
const editorImages: PendingOrRemoteImage[] = [];
|
||||
for (const block of editorBlocks) {
|
||||
if (block.type !== 'image') continue;
|
||||
if (block.remoteUrl) {
|
||||
editorImages.push({ id: generatePendingImageId(), kind: 'remote', url: block.remoteUrl });
|
||||
} else {
|
||||
editorImages.push({
|
||||
id: generatePendingImageId(),
|
||||
kind: 'pending',
|
||||
localUri: block.localUri,
|
||||
mimeType: block.mimeType,
|
||||
});
|
||||
}
|
||||
}
|
||||
setInitialBlocks(segs.length > 0 ? segmentsToBlocks(segs) : undefined);
|
||||
} else {
|
||||
// 长文 -> 非长文:从 BlockEditor 提取文本和图片
|
||||
const editorContent = blockEditorRef.current?.getContent() || '';
|
||||
const editorSegments = blockEditorRef.current?.getSegments() || [];
|
||||
if (editorContent) setContent(editorContent);
|
||||
const editorImages = editorSegments
|
||||
.filter((s): s is Extract<typeof s, { type: 'image' }> => s.type === 'image')
|
||||
.map(s => ({ uri: s.data.url, uploading: false }));
|
||||
if (editorImages.length > 0) setImages(editorImages);
|
||||
setImages(editorImages);
|
||||
}
|
||||
setIsLongPostMode(prev => !prev);
|
||||
}, [isLongPostMode, content, images]);
|
||||
@@ -436,25 +423,37 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有图片正在上传
|
||||
if (isLongPostMode) {
|
||||
if (blockEditorRef.current?.hasUploadingImages()) {
|
||||
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const uploadingImages = images.filter(img => img.uploading);
|
||||
if (uploadingImages.length > 0) {
|
||||
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 检查 BlockEditor 中是否有正在上传的图片
|
||||
// (已无此状态,发布时会统一上传,无需检查)
|
||||
|
||||
setPosting(true);
|
||||
|
||||
try {
|
||||
// 普通模式下,发布前上传所有待上传的图片
|
||||
let uploadedImageUrls: string[] = [];
|
||||
if (!isLongPostMode && images.length > 0) {
|
||||
const uploadResult = await uploadAllPendingImages(images, (id, url) => {
|
||||
setImages(prev => prev.map(i => i.id === id ? { id, kind: 'remote' as const, url } : i));
|
||||
});
|
||||
if (!uploadResult.success) {
|
||||
setPosting(false);
|
||||
Alert.alert('上传失败', '部分图片上传失败,请重试');
|
||||
return;
|
||||
}
|
||||
uploadedImageUrls = uploadResult.urls;
|
||||
}
|
||||
|
||||
// 长文模式下,发布前上传 BlockEditor 中所有 pending 图片
|
||||
if (isLongPostMode) {
|
||||
const uploadOk = await blockEditorRef.current?.uploadPendingImages();
|
||||
if (uploadOk === false) {
|
||||
setPosting(false);
|
||||
Alert.alert('上传失败', '部分图片上传失败,请重试');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isVotePost) {
|
||||
const imageUrls = images.map(img => img.uri);
|
||||
// 验证投票选项
|
||||
const validOptions = voteOptions.filter(opt => opt.trim() !== '');
|
||||
if (validOptions.length < 2) {
|
||||
@@ -467,7 +466,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
await voteService.createVotePost({
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
images: imageUrls.length > 0 ? imageUrls : undefined,
|
||||
images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined,
|
||||
channel_id: selectedChannelId || undefined,
|
||||
vote_options: validOptions.map(opt => ({ content: opt })),
|
||||
});
|
||||
@@ -506,13 +505,12 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
}
|
||||
} else {
|
||||
// 普通模式
|
||||
const imageUrls = images.map(img => img.uri);
|
||||
if (isEditMode && editPostID) {
|
||||
const updated = await postService.updatePost(editPostID, {
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
segments: segments.some(s => s.type !== 'text') ? segments : undefined,
|
||||
images: imageUrls.length > 0 ? imageUrls : undefined,
|
||||
images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined,
|
||||
});
|
||||
if (!updated) {
|
||||
throw new Error('更新帖子失败');
|
||||
@@ -524,7 +522,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
segments: segments.some(s => s.type !== 'text') ? segments : undefined,
|
||||
images: imageUrls.length > 0 ? imageUrls : undefined,
|
||||
images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined,
|
||||
channel_id: selectedChannelId || undefined,
|
||||
});
|
||||
showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 });
|
||||
@@ -547,7 +545,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
<View style={styles.imageGrid}>
|
||||
{images.map((img, index) => (
|
||||
<Animated.View
|
||||
key={`${img.uri}-${index}`}
|
||||
key={img.id}
|
||||
style={[
|
||||
styles.imageGridItem,
|
||||
{
|
||||
@@ -558,16 +556,14 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Image source={{ uri: img.uri }} style={styles.gridImage} resizeMode="cover" />
|
||||
{img.uploading && (
|
||||
<View style={styles.uploadingOverlay}>
|
||||
<MaterialCommunityIcons name="loading" size={20} color={colors.text.inverse} />
|
||||
</View>
|
||||
)}
|
||||
<Image
|
||||
source={{ uri: getImageDisplayUri(img) }}
|
||||
style={styles.gridImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.removeImageButton}
|
||||
onPress={() => handleRemoveImage(index)}
|
||||
disabled={img.uploading}
|
||||
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
||||
>
|
||||
<View style={styles.removeImageButtonInner}>
|
||||
@@ -1044,12 +1040,6 @@ function createCreatePostStyles(colors: AppColors) {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
uploadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
removeImageButton: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Post } from '../../types';
|
||||
import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores';
|
||||
import { useCurrentUser, useIsAuthenticated, useIsVerified, useVerificationStore } from '../../stores/auth';
|
||||
import { channelService, postService } from '../../services';
|
||||
import { useChannels } from '../../hooks/useChannels';
|
||||
import { PostCard, SearchBar, ShareSheet } from '../../components/business';
|
||||
import type { PostCardAction } from '../../components/business/PostCard';
|
||||
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem } from '../../components/common';
|
||||
@@ -215,7 +216,7 @@ function createHomeStyles(colors: AppColors, responsivePadding: number) {
|
||||
export const HomeScreen: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { posts: storePosts } = useUserStore();
|
||||
const storePosts = useUserStore((s) => s.posts);
|
||||
const currentUser = useCurrentUser();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const isVerified = useIsVerified();
|
||||
@@ -242,7 +243,11 @@ export const HomeScreen: React.FC = () => {
|
||||
|
||||
const [sortIndex, setSortIndex] = useState(2);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [latestCapsules, setLatestCapsules] = useState<LatestCapsule[]>([{ id: '', name: '全部' }]);
|
||||
const { data: channelList = [] } = useChannels();
|
||||
const latestCapsules = useMemo<LatestCapsule[]>(
|
||||
() => [{ id: '', name: '全部' }, ...channelList.map(item => ({ id: item.id, name: item.name }))],
|
||||
[channelList]
|
||||
);
|
||||
const [activeCapsuleId, setActiveCapsuleId] = useState('');
|
||||
|
||||
// 图片查看器状态
|
||||
@@ -250,6 +255,12 @@ export const HomeScreen: React.FC = () => {
|
||||
const [postImages, setPostImages] = useState<ImageGridItem[]>([]);
|
||||
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||
|
||||
const stableCloseImageViewer = useCallback(() => setShowImageViewer(false), []);
|
||||
const stableGalleryImages = useMemo(() => postImages.map((img, i) => ({
|
||||
id: img.id || img.url || `img-${i}`,
|
||||
url: img.url || img.uri || ''
|
||||
})), [postImages]);
|
||||
|
||||
// 搜索显示状态(用于内嵌搜索页面)
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
|
||||
@@ -353,7 +364,7 @@ export const HomeScreen: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (homeTabPressCount > 0) {
|
||||
// 滚动 FlashList 到顶部
|
||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
|
||||
// 滚动 ScrollView 到顶部(网格模式)
|
||||
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
||||
// 重置底部 Tab 栏状态
|
||||
@@ -426,7 +437,6 @@ export const HomeScreen: React.FC = () => {
|
||||
refreshing: isRefreshing,
|
||||
hasMore,
|
||||
error,
|
||||
reset,
|
||||
} = useDifferentialPosts<Post>(
|
||||
[],
|
||||
{
|
||||
@@ -486,8 +496,9 @@ export const HomeScreen: React.FC = () => {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
reset();
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
// 注意:不再在此处调用 reset()。
|
||||
// fetchPosts 内部会在参数变化时立即清空 store 中的旧数据并设置 loading,
|
||||
// 这样可以避免本地清空后、请求发起前的间隙渲染到上一个分区的帖子造成闪烁。
|
||||
if (!cancelled) {
|
||||
await refresh();
|
||||
}
|
||||
@@ -587,18 +598,6 @@ export const HomeScreen: React.FC = () => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadChannels = async () => {
|
||||
const list = await channelService.list();
|
||||
const capsules: LatestCapsule[] = [
|
||||
{ id: '', name: '全部' },
|
||||
...list.map(item => ({ id: item.id, name: item.name })),
|
||||
];
|
||||
setLatestCapsules(capsules);
|
||||
};
|
||||
loadChannels();
|
||||
}, []);
|
||||
|
||||
// 跳转到搜索页(使用内嵌模式,不再依赖导航)
|
||||
const handleSearchPress = () => {
|
||||
setShowSearch(true);
|
||||
@@ -717,7 +716,7 @@ export const HomeScreen: React.FC = () => {
|
||||
// 跳转到发帖页面(使用 Modal 方式)
|
||||
const handleCreatePost = () => {
|
||||
if (!isAuthenticated) {
|
||||
router.push('/login');
|
||||
router.push(hrefs.hrefAuthLogin());
|
||||
return;
|
||||
}
|
||||
if (!isVerified) {
|
||||
@@ -811,9 +810,10 @@ export const HomeScreen: React.FC = () => {
|
||||
const renderResponsiveGrid = () => {
|
||||
return (
|
||||
<FlashList
|
||||
key={`${listKey}_grid`}
|
||||
key="home-grid"
|
||||
ref={scrollViewRef as any}
|
||||
data={displayPosts}
|
||||
extraData={listKey}
|
||||
renderItem={renderGridItem}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={gridColumns}
|
||||
@@ -865,9 +865,10 @@ export const HomeScreen: React.FC = () => {
|
||||
// 移动端和宽屏都使用单列 FlashList,宽屏下居中显示
|
||||
return (
|
||||
<FlashList
|
||||
key={listKey}
|
||||
key="home-list"
|
||||
ref={flashListRef}
|
||||
data={displayPosts}
|
||||
extraData={listKey}
|
||||
renderItem={renderPostList}
|
||||
keyExtractor={keyExtractor}
|
||||
contentContainerStyle={{
|
||||
@@ -901,6 +902,7 @@ export const HomeScreen: React.FC = () => {
|
||||
<StatusBar barStyle={statusBarStyle} backgroundColor={colors.background.paper} />
|
||||
<SearchScreen
|
||||
onBack={() => setShowSearch(false)}
|
||||
homeTab={homeTab}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
@@ -1050,7 +1052,7 @@ export const HomeScreen: React.FC = () => {
|
||||
floatingButtonBottom !== undefined && { bottom: floatingButtonBottom },
|
||||
]}
|
||||
onPress={homeTab === 'market' ? () => {
|
||||
if (!isAuthenticated) { router.push('/login'); return; }
|
||||
if (!isAuthenticated) { router.push(hrefs.hrefAuthLogin()); return; }
|
||||
if (!isVerified) { router.push(hrefs.hrefVerificationGuide()); return; }
|
||||
setShowCreateTrade(true);
|
||||
} : handleCreatePost}
|
||||
@@ -1062,12 +1064,9 @@ export const HomeScreen: React.FC = () => {
|
||||
{/* 图片查看器 */}
|
||||
<ImageGallery
|
||||
visible={showImageViewer}
|
||||
images={postImages.map(img => ({
|
||||
id: img.id || img.url || String(Math.random()),
|
||||
url: img.url || img.uri || ''
|
||||
}))}
|
||||
images={stableGalleryImages}
|
||||
initialIndex={selectedImageIndex}
|
||||
onClose={() => setShowImageViewer(false)}
|
||||
onClose={stableCloseImageViewer}
|
||||
enableSave
|
||||
/>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from '../../types/trade';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
|
||||
type TradeFilterType = 'all' | 'sell' | 'buy';
|
||||
|
||||
@@ -27,7 +28,7 @@ function createMarketStyles(colors: AppColors, gap: number, padding: number) {
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: padding,
|
||||
paddingTop: gap / 2,
|
||||
paddingTop: padding,
|
||||
paddingBottom: 80,
|
||||
},
|
||||
});
|
||||
@@ -137,7 +138,7 @@ export function MarketView({
|
||||
}, [isLoadingMore, hasMore, cursor, filterType, selectedCategory]);
|
||||
|
||||
const handlePressItem = useCallback((id: string) => {
|
||||
router.push(`/trade/${id}` as any);
|
||||
router.push(hrefs.hrefTradeDetail(id));
|
||||
}, [router]);
|
||||
|
||||
const handleFavorite = useCallback(async (id: string) => {
|
||||
@@ -187,7 +188,8 @@ export function MarketView({
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={numColumns}
|
||||
key={`market_${numColumns}`}
|
||||
key="market-list"
|
||||
extraData={numColumns}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
|
||||
@@ -38,12 +38,19 @@ import {
|
||||
import { Post, Comment, VoteResultDTO, VoteOptionDTO, MessageSegment } from '../../types';
|
||||
import { useUserStore } from '../../stores';
|
||||
import { useCurrentUser } from '../../stores/auth';
|
||||
import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services';
|
||||
import { postService, commentService, authService, showPrompt, voteService } from '../../services';
|
||||
import { postSyncService } from '@/services/post';
|
||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||
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 { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks';
|
||||
import {
|
||||
type PendingOrRemoteImage,
|
||||
makePendingImageFromAsset,
|
||||
getImageDisplayUri,
|
||||
uploadAllPendingImages,
|
||||
} from '../../utils/pendingImages';
|
||||
import { handleError } from '@/services/core';
|
||||
import { formatDateTime as formatDateTimeUtil, formatRelativeTime as formatRelativeTimeUtil } from '@/utils/formatTime';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
@@ -251,7 +258,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
// 评论图片相关状态
|
||||
const [commentImages, setCommentImages] = useState<{ uri: string; uploading?: boolean }[]>([]);
|
||||
const [commentImages, setCommentImages] = useState<PendingOrRemoteImage[]>([]);
|
||||
const [showEmojiPanel, setShowEmojiPanel] = useState(false);
|
||||
const [isFollowing, setIsFollowing] = useState(false);
|
||||
const [isFollowingMe, setIsFollowingMe] = useState(false);
|
||||
@@ -688,31 +695,8 @@ export const PostDetailScreen: React.FC = () => {
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets) {
|
||||
const newImages = result.assets.map(asset => ({ uri: asset.uri, uploading: true }));
|
||||
const newImages = result.assets.map(asset => makePendingImageFromAsset(asset, 'ci'));
|
||||
setCommentImages(prev => [...prev, ...newImages]);
|
||||
|
||||
// 上传图片
|
||||
for (let i = 0; i < newImages.length; i++) {
|
||||
const asset = result.assets[i];
|
||||
const uploadResult = await uploadService.uploadImage({
|
||||
uri: asset.uri,
|
||||
type: asset.mimeType || 'image/jpeg',
|
||||
});
|
||||
|
||||
if (uploadResult) {
|
||||
setCommentImages(prev => {
|
||||
const updated = [...prev];
|
||||
const index = prev.findIndex(img => img.uri === asset.uri);
|
||||
if (index !== -1) {
|
||||
updated[index] = { uri: uploadResult.url, uploading: false };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
Alert.alert('上传失败', '图片上传失败,请重试');
|
||||
setCommentImages(prev => prev.filter(img => img.uri !== asset.uri));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -727,11 +711,17 @@ export const PostDetailScreen: React.FC = () => {
|
||||
const hasContent = commentText.trim() || commentImages.length > 0;
|
||||
if (!hasContent || !post) return;
|
||||
|
||||
// 检查是否有图片正在上传
|
||||
const uploadingImages = commentImages.filter(img => img.uploading);
|
||||
if (uploadingImages.length > 0) {
|
||||
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
|
||||
return;
|
||||
// 上传所有 pending 图片
|
||||
let uploadedImageUrls: string[] = [];
|
||||
if (commentImages.length > 0) {
|
||||
const uploadResult = await uploadAllPendingImages(commentImages, (id, url) => {
|
||||
setCommentImages(prev => prev.map(i => i.id === id ? { id, kind: 'remote' as const, url } : i));
|
||||
});
|
||||
if (!uploadResult.success) {
|
||||
Alert.alert('上传失败', '部分图片上传失败,请重试');
|
||||
return;
|
||||
}
|
||||
uploadedImageUrls = uploadResult.urls;
|
||||
}
|
||||
|
||||
const tempId = `new-${Date.now()}`;
|
||||
@@ -741,9 +731,6 @@ export const PostDetailScreen: React.FC = () => {
|
||||
// 获取根评论 ID:如果被回复评论有 root_id,则使用 root_id;否则使用 parent_id(即被回复的是顶级评论)
|
||||
const rootId = isReply ? (replyingTo.root_id || replyingTo.id) : null;
|
||||
|
||||
// 获取已上传图片的URL列表
|
||||
const uploadedImageUrls = commentImages.filter(img => !img.uploading).map(img => img.uri);
|
||||
|
||||
// 先创建临时评论显示在列表中
|
||||
const tempComment: Comment = {
|
||||
id: tempId,
|
||||
@@ -1084,6 +1071,12 @@ export const PostDetailScreen: React.FC = () => {
|
||||
}));
|
||||
}, [post?.images]);
|
||||
|
||||
// 为 ImageGallery 提供稳定的图片列表,避免 Math.random() 在每次渲染时生成新 ID
|
||||
const stableGalleryImages = useMemo(() => allImages.map((img, i) => ({
|
||||
id: img.id || img.url || `img-${i}`,
|
||||
url: img.url || img.uri || ''
|
||||
})), [allImages]);
|
||||
|
||||
// 渲染帖子头部 - 小红书/微博风格
|
||||
const renderPostHeader = useCallback(() => {
|
||||
if (!post) return null;
|
||||
@@ -1279,10 +1272,22 @@ export const PostDetailScreen: React.FC = () => {
|
||||
// 回复评论
|
||||
const [replyingTo, setReplyingTo] = useState<Comment | null>(null);
|
||||
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) => {
|
||||
setReplyingTo(comment);
|
||||
setIsComposerVisible(true);
|
||||
focusCommentInput();
|
||||
};
|
||||
|
||||
const handleCancelReply = () => {
|
||||
@@ -1371,6 +1376,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
|
||||
const openComposer = () => {
|
||||
setIsComposerVisible(true);
|
||||
focusCommentInput();
|
||||
};
|
||||
|
||||
const closeComposer = () => {
|
||||
@@ -1479,6 +1485,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
|
||||
{/* Text Input —— 小红书风格:无边框、自动扩展 */}
|
||||
<PostMentionInput
|
||||
ref={commentInputRef}
|
||||
value={commentText}
|
||||
onChangeText={setCommentText}
|
||||
onSegmentsChange={setCommentSegments}
|
||||
@@ -1495,13 +1502,12 @@ export const PostDetailScreen: React.FC = () => {
|
||||
{commentImages.length > 0 && (
|
||||
<ScrollView horizontal style={styles.commentImagesPreview} showsHorizontalScrollIndicator={false}>
|
||||
{commentImages.map((image, index) => (
|
||||
<View key={index} style={styles.commentImageWrapper}>
|
||||
<Image source={{ uri: image.uri }} style={styles.commentImageThumbnail} resizeMode="cover" />
|
||||
{image.uploading && (
|
||||
<View style={styles.uploadingOverlay}>
|
||||
<Loading size="sm" />
|
||||
</View>
|
||||
)}
|
||||
<View key={image.id} style={styles.commentImageWrapper}>
|
||||
<Image
|
||||
source={{ uri: getImageDisplayUri(image) }}
|
||||
style={styles.commentImageThumbnail}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.removeImageButton}
|
||||
onPress={() => handleRemoveCommentImage(index)}
|
||||
@@ -1740,10 +1746,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
{/* 图片预览 ImageGallery */}
|
||||
<ImageGallery
|
||||
visible={showImageModal}
|
||||
images={allImages.map(img => ({
|
||||
id: img.id || img.url || String(Math.random()),
|
||||
url: img.url || img.uri || ''
|
||||
}))}
|
||||
images={stableGalleryImages}
|
||||
initialIndex={selectedImageIndex}
|
||||
onClose={() => setShowImageModal(false)}
|
||||
enableSave
|
||||
@@ -1815,10 +1818,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
{/* 图片预览 ImageGallery */}
|
||||
<ImageGallery
|
||||
visible={showImageModal}
|
||||
images={allImages.map(img => ({
|
||||
id: img.id || img.url || String(Math.random()),
|
||||
url: img.url || img.uri || ''
|
||||
}))}
|
||||
images={stableGalleryImages}
|
||||
initialIndex={selectedImageIndex}
|
||||
onClose={() => setShowImageModal(false)}
|
||||
enableSave
|
||||
@@ -2185,17 +2185,6 @@ function createPostDetailStyles(colors: AppColors) {
|
||||
height: 60,
|
||||
borderRadius: borderRadius.sm,
|
||||
},
|
||||
uploadingOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.sm,
|
||||
},
|
||||
removeImageButton: {
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
|
||||
@@ -12,39 +12,49 @@ import {
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
RefreshControl,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import { Post, User } from '../../types';
|
||||
import type { TradeItemDTO } from '../../types/trade';
|
||||
import { useUserStore } from '../../stores';
|
||||
import { postService, authService } from '../../services';
|
||||
import { tradeService } from '../../services/trade/tradeService';
|
||||
import { postSyncService } from '@/services/post';
|
||||
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
||||
import { PostCard, SearchBar } from '../../components/business';
|
||||
import { TradeCard } from '../../components/business/TradeCard/TradeCard';
|
||||
import type { PostCardAction } from '../../components/business/PostCard';
|
||||
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks';
|
||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||
|
||||
const TABS = ['帖子', '用户'];
|
||||
const SQUARE_TABS = ['帖子', '用户'];
|
||||
const MARKET_TABS = ['商品', '用户'];
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
// 与 TabsLayout 中的常量保持一致:tabBarHeight 56 + 上下浮动间距 12*2
|
||||
const FLOATING_TAB_BAR_HEIGHT = 56 + 12 * 2;
|
||||
|
||||
type SearchType = 'posts' | 'users';
|
||||
type SearchType = 'posts' | 'users' | 'trades';
|
||||
|
||||
interface SearchScreenProps {
|
||||
onBack?: () => void;
|
||||
homeTab?: 'square' | 'market';
|
||||
}
|
||||
|
||||
export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 'square' }) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createSearchScreenStyles(colors), [colors]);
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { searchHistory: history, addSearchHistory, clearSearchHistory } = useUserStore();
|
||||
const history = useUserStore((state) => state.searchHistory);
|
||||
const { addSearchHistory, clearSearchHistory } = useUserStore.getState();
|
||||
|
||||
const isMarket = homeTab === 'market';
|
||||
const TABS = isMarket ? MARKET_TABS : SQUARE_TABS;
|
||||
|
||||
// 使用响应式 hook
|
||||
const {
|
||||
@@ -85,6 +95,25 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
// 保存当前搜索关键词,用于Tab切换时重新搜索
|
||||
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 {
|
||||
@@ -114,6 +143,10 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
const [userResults, setUserResults] = useState<User[]>([]);
|
||||
const [userLoading, setUserLoading] = useState(false);
|
||||
|
||||
// 市集搜索结果
|
||||
const [tradeResults, setTradeResults] = useState<TradeItemDTO[]>([]);
|
||||
const [tradeLoading, setTradeLoading] = useState(false);
|
||||
|
||||
const refreshRef = useRef(refresh);
|
||||
refreshRef.current = refresh;
|
||||
const resetRef = useRef(reset);
|
||||
@@ -147,9 +180,16 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
setUserLoading(true);
|
||||
const usersResponse = await authService.searchUsers(trimmedKeyword, 1, 20);
|
||||
setUserResults(usersResponse.list || []);
|
||||
} else if (searchType === 'trades') {
|
||||
setTradeLoading(true);
|
||||
const tradeResponse = await tradeService.getTradeItems({ keyword: trimmedKeyword, page_size: DEFAULT_PAGE_SIZE });
|
||||
setTradeResults(tradeResponse.list || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error);
|
||||
} finally {
|
||||
setUserLoading(false);
|
||||
setTradeLoading(false);
|
||||
}
|
||||
|
||||
setHasSearched(true);
|
||||
@@ -218,6 +258,13 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
|
||||
// 获取当前搜索类型
|
||||
const getSearchType = (): SearchType => {
|
||||
if (isMarket) {
|
||||
switch (activeIndex) {
|
||||
case 0: return 'trades';
|
||||
case 1: return 'users';
|
||||
default: return 'trades';
|
||||
}
|
||||
}
|
||||
switch (activeIndex) {
|
||||
case 0: return 'posts';
|
||||
case 1: return 'users';
|
||||
@@ -425,6 +472,38 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染市集搜索结果
|
||||
const renderTradeResults = () => {
|
||||
if (tradeResults.length === 0 && !tradeLoading) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="未找到相关商品"
|
||||
description="试试其他关键词吧"
|
||||
icon="shopping-search-outline"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={tradeResults}
|
||||
renderItem={({ item }) => (
|
||||
<View style={{ paddingHorizontal: responsivePadding, marginBottom: responsiveGap }}>
|
||||
<TradeCard
|
||||
item={item}
|
||||
variant="list"
|
||||
onPress={(id) => router.push(hrefs.hrefTradeDetail(id))}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={{ paddingVertical: responsiveGap, paddingBottom: listBottomInset }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListFooterComponent={tradeLoading ? <Loading size="sm" /> : null}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染搜索结果
|
||||
const renderSearchResults = () => {
|
||||
const searchType = getSearchType();
|
||||
@@ -437,6 +516,10 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
return renderUserResults();
|
||||
}
|
||||
|
||||
if (searchType === 'trades') {
|
||||
return renderTradeResults();
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -445,10 +528,16 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
if (hasSearched) return null;
|
||||
|
||||
return (
|
||||
<View style={[styles.suggestionsContainer, { paddingHorizontal: responsivePadding }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.suggestionsContainer,
|
||||
{ paddingHorizontal: responsivePadding },
|
||||
{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }
|
||||
]}
|
||||
>
|
||||
{/* 搜索历史 */}
|
||||
{history.length > 0 && (
|
||||
<View style={[styles.section, { marginTop: responsiveGap }]}>
|
||||
<View style={[styles.section, { marginTop: responsiveGap * 1.5 }]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text
|
||||
variant="body"
|
||||
@@ -459,7 +548,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
>
|
||||
搜索历史
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleClearHistory}>
|
||||
<TouchableOpacity onPress={handleClearHistory} activeOpacity={0.7}>
|
||||
<MaterialCommunityIcons name="delete-outline" size={isDesktop ? 22 : 18} color={colors.text.secondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -477,6 +566,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
}
|
||||
]}
|
||||
onPress={() => handleHistoryPress(keyword)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="history" size={isDesktop ? 16 : 14} color={colors.text.secondary} />
|
||||
<Text
|
||||
@@ -494,7 +584,18 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 空状态提示 */}
|
||||
{history.length === 0 && (
|
||||
<View style={styles.emptySuggestions}>
|
||||
<EmptyState
|
||||
title="开始搜索"
|
||||
description="输入关键词搜索帖子、用户或商品"
|
||||
icon="magnify"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -516,8 +617,9 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
onSubmit={handleSearch}
|
||||
placeholder="搜索帖子、用户"
|
||||
placeholder={isMarket ? "搜索商品、用户" : "搜索帖子、用户"}
|
||||
autoFocus
|
||||
compact
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
@@ -535,20 +637,30 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Tab切换 */}
|
||||
{/* Tab切换 - 与主页风格一致的下划线样式 */}
|
||||
<View style={styles.tabWrapper}>
|
||||
<TabBar
|
||||
tabs={TABS}
|
||||
activeIndex={activeIndex}
|
||||
onTabChange={(index) => {
|
||||
setActiveIndex(index);
|
||||
if (currentKeyword && hasSearched) {
|
||||
performSearch(currentKeyword);
|
||||
}
|
||||
}}
|
||||
variant="modern"
|
||||
icons={['file-document-outline', 'account-outline']}
|
||||
/>
|
||||
<View style={[styles.homeTabSwitcher, { paddingHorizontal: responsivePadding }]}>
|
||||
{TABS.map((tab, index) => {
|
||||
const isActive = activeIndex === index;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tab}
|
||||
activeOpacity={0.7}
|
||||
style={[styles.homeTabItem, isActive && styles.homeTabItemActive]}
|
||||
onPress={() => {
|
||||
setActiveIndex(index);
|
||||
if (currentKeyword && hasSearched) {
|
||||
performSearch(currentKeyword);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={isActive ? styles.homeTabTextActive : styles.homeTabText}>
|
||||
{tab}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 内容区域 */}
|
||||
@@ -566,15 +678,12 @@ function createSearchScreenStyles(colors: AppColors) {
|
||||
searchHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: `${colors.divider}70`,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
searchShell: {
|
||||
flex: 1,
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: `${colors.primary.main}12`,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
@@ -584,13 +693,43 @@ function createSearchScreenStyles(colors: AppColors) {
|
||||
fontWeight: '600',
|
||||
},
|
||||
tabWrapper: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: `${colors.divider}50`,
|
||||
backgroundColor: colors.background.default,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
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: {
|
||||
flex: 1,
|
||||
},
|
||||
emptySuggestions: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: -40,
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
@@ -611,13 +750,14 @@ function createSearchScreenStyles(colors: AppColors) {
|
||||
tag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: `${colors.primary.main}10`,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.full,
|
||||
borderWidth: 0,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
tagText: {
|
||||
marginLeft: spacing.xs,
|
||||
color: colors.primary.main,
|
||||
color: colors.text.secondary,
|
||||
fontWeight: '500',
|
||||
},
|
||||
userCard: {
|
||||
|
||||
@@ -27,11 +27,11 @@ import {
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
BackHandler,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { FlashList, ListRenderItem } from '@shopify/flash-list';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useNavigation, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Text, ImageGallery, ImageGridItem } from '../../components/common';
|
||||
import { useAppColors, useResolvedColorScheme } from '../../theme';
|
||||
@@ -111,6 +111,11 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
const [chatImages, setChatImages] = useState<ImageGridItem[]>([]);
|
||||
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||
|
||||
const stableGalleryImages = useMemo(() => chatImages.map((img, i) => ({
|
||||
id: img.id || img.url || `img-${i}`,
|
||||
url: img.url || img.uri || ''
|
||||
})), [chatImages]);
|
||||
|
||||
// 图片点击处理函数
|
||||
const handleImagePress = (images: ImageGridItem[], index: number) => {
|
||||
setChatImages(images);
|
||||
@@ -154,7 +159,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
longPressMenuVisible,
|
||||
selectedMessage,
|
||||
selectedMessageId,
|
||||
setSelectedMessageId,
|
||||
menuPosition,
|
||||
isGroupChat,
|
||||
groupInfo,
|
||||
@@ -202,7 +206,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
handleMentionAll,
|
||||
getSenderInfo,
|
||||
getTypingHint,
|
||||
getInputBottom,
|
||||
handleDismiss,
|
||||
navigateToInfo,
|
||||
navigateToChatSettings,
|
||||
@@ -212,6 +215,28 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
jumpToLatestMessages,
|
||||
setBrowsingHistory,
|
||||
} = useChatScreen(props);
|
||||
|
||||
const stableOnBack = useCallback(() => router.back(), [router]);
|
||||
const stableOnFocusInput = useCallback(() => {
|
||||
if (activePanel !== 'none' && activePanel !== 'mention') {
|
||||
closePanel();
|
||||
}
|
||||
}, [activePanel, closePanel]);
|
||||
const stableFocusTextInput = useCallback(() => textInputRef.current?.focus(), []);
|
||||
const stableOnLayout = useCallback((e: any) => {
|
||||
scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height;
|
||||
}, []);
|
||||
const stableOnScrollBeginDrag = useCallback(() => {
|
||||
isUserDraggingRef.current = true;
|
||||
handleDismiss();
|
||||
}, [handleDismiss]);
|
||||
const stableOnScrollEndDrag = useCallback(() => {
|
||||
isUserDraggingRef.current = false;
|
||||
}, []);
|
||||
const stableOnMomentumScrollEnd = useCallback(() => {
|
||||
isUserDraggingRef.current = false;
|
||||
}, []);
|
||||
|
||||
const displayMessages = useMemo(() => [...messages].reverse(), [messages]);
|
||||
|
||||
const longPressMenuMemberMap = useMemo(() => {
|
||||
@@ -451,7 +476,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
|
||||
>
|
||||
{!props.isEmbedded && <StatusBar style={resolved === 'dark' ? 'light' : 'dark'} backgroundColor={colors.background.paper} />}
|
||||
{!props.isEmbedded && <StatusBar barStyle={resolved === 'dark' ? 'light-content' : 'dark-content'} backgroundColor={colors.background.paper} />}
|
||||
|
||||
{/* 顶部栏 */}
|
||||
<ChatHeader
|
||||
@@ -460,7 +485,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
otherUser={otherUser}
|
||||
routeGroupName={effectiveGroupName ?? undefined}
|
||||
typingHint={typingHint}
|
||||
onBack={props.onEmbeddedBack ? props.onEmbeddedBack : () => router.back()}
|
||||
onBack={props.onEmbeddedBack || stableOnBack}
|
||||
onTitlePress={navigateToInfo}
|
||||
onMorePress={navigateToChatSettings}
|
||||
onGroupInfoPress={handleGroupInfoPress}
|
||||
@@ -470,9 +495,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
{/* 消息列表 */}
|
||||
<View
|
||||
style={styles.messageListContainer}
|
||||
onLayout={e => {
|
||||
scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height;
|
||||
}}
|
||||
onLayout={stableOnLayout}
|
||||
onTouchEnd={handleDismiss}
|
||||
>
|
||||
{loading ? (
|
||||
@@ -493,16 +516,9 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
scrollEnabled={true}
|
||||
drawDistance={250}
|
||||
onScroll={handleMessageListScroll}
|
||||
onScrollBeginDrag={() => {
|
||||
isUserDraggingRef.current = true;
|
||||
handleDismiss();
|
||||
}}
|
||||
onScrollEndDrag={() => {
|
||||
isUserDraggingRef.current = false;
|
||||
}}
|
||||
onMomentumScrollEnd={() => {
|
||||
isUserDraggingRef.current = false;
|
||||
}}
|
||||
onScrollBeginDrag={stableOnScrollBeginDrag}
|
||||
onScrollEndDrag={stableOnScrollEndDrag}
|
||||
onMomentumScrollEnd={stableOnMomentumScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
/>
|
||||
@@ -568,12 +584,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
pendingAttachments={pendingAttachments}
|
||||
onRemovePendingAttachment={removePendingAttachment}
|
||||
onCancelReply={handleCancelReply}
|
||||
onFocus={() => {
|
||||
// 输入框获得焦点时,关闭其他面板(但不要关闭键盘)
|
||||
if (activePanel !== 'none' && activePanel !== 'mention') {
|
||||
closePanel();
|
||||
}
|
||||
}}
|
||||
onFocus={stableOnFocusInput}
|
||||
currentUser={currentUser}
|
||||
otherUser={otherUser}
|
||||
getSenderInfo={getSenderInfo}
|
||||
@@ -587,7 +598,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
onInsertEmoji={handleInsertEmoji}
|
||||
onInsertSticker={handleSendSticker}
|
||||
onClose={closePanel}
|
||||
onFocusInput={() => textInputRef.current?.focus()}
|
||||
onFocusInput={stableFocusTextInput}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -657,10 +668,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
{/* 图片查看器 */}
|
||||
<ImageGallery
|
||||
visible={showImageViewer}
|
||||
images={chatImages.map(img => ({
|
||||
id: img.id || img.url || String(Math.random()),
|
||||
url: img.url || img.uri || ''
|
||||
}))}
|
||||
images={stableGalleryImages}
|
||||
initialIndex={selectedImageIndex}
|
||||
onClose={handleCloseImageViewer}
|
||||
enableSave
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useFocusEffect } from "expo-router/react-navigation";
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
@@ -837,6 +837,19 @@ const GroupInfoScreen: React.FC = () => {
|
||||
thumbColor={isNotificationMuted ? colors.primary.main : colors.background.paper}
|
||||
/>
|
||||
</View>
|
||||
<Divider style={styles.settingDivider} />
|
||||
{renderSettingItem(
|
||||
'magnify',
|
||||
'查找聊天记录',
|
||||
undefined,
|
||||
conversationId
|
||||
? () => router.push(hrefs.hrefMessageSearch({
|
||||
conversationId,
|
||||
conversationName: group?.name || '群聊',
|
||||
isGroupChat: true,
|
||||
}))
|
||||
: undefined,
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ import {
|
||||
import { FlashList, ListRenderItem } from '@shopify/flash-list';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import { useIsFocused } from "expo-router/react-navigation";
|
||||
import { useBottomTabBarHeight } from "expo-router/js-tabs";
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import {
|
||||
spacing,
|
||||
@@ -152,6 +152,11 @@ export const MessageListScreen: React.FC = () => {
|
||||
// 系统通知显示状态 - 用于在移动端显示通知页面
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
|
||||
// Stable callbacks to avoid inline arrows passed to child components
|
||||
const stableCloseNotifications = useCallback(() => setShowNotifications(false), []);
|
||||
const stableClearSelectedConversation = useCallback(() => setSelectedConversation(null), []);
|
||||
const stableCloseScanner = useCallback(() => setScannerVisible(false), []);
|
||||
|
||||
// 系统消息会话对象(用于选中状态)
|
||||
const systemMessageConversation: ConversationResponse = useMemo(() => ({
|
||||
id: SYSTEM_MESSAGE_CHANNEL_ID,
|
||||
@@ -197,14 +202,11 @@ export const MessageListScreen: React.FC = () => {
|
||||
return FLOATING_TAB_BAR_HEIGHT + insets.bottom + spacing.md;
|
||||
}, [isWideScreen, insets.bottom]);
|
||||
|
||||
// 【新架构】页面获得焦点时初始化MessageManager
|
||||
// 初始化MessageManager(仅首次),焦点时轻量刷新会话列表
|
||||
useEffect(() => {
|
||||
if (isFocused) {
|
||||
messageManager.initialize();
|
||||
}
|
||||
}, [isFocused]);
|
||||
messageManager.initialize();
|
||||
}, []);
|
||||
|
||||
// 【新架构】使用focus刷新hook,从ChatScreen返回时自动刷新未读数
|
||||
useMessageListRefresh();
|
||||
|
||||
// 同步未读数到userStore(用于TabBar角标显示)
|
||||
@@ -840,25 +842,25 @@ export const MessageListScreen: React.FC = () => {
|
||||
<View style={styles.chatArea}>
|
||||
{showNotifications ? (
|
||||
// 显示系统通知页面
|
||||
<NotificationsScreen onBack={() => setShowNotifications(false)} />
|
||||
(<NotificationsScreen onBack={stableCloseNotifications} />)
|
||||
) : selectedConversation ? (
|
||||
// 显示选中会话的聊天内容
|
||||
<ChatScreen
|
||||
(<ChatScreen
|
||||
isEmbedded
|
||||
embeddedConversationId={String(selectedConversation.id)}
|
||||
embeddedIsGroupChat={selectedConversation.type === 'group'}
|
||||
embeddedGroupId={selectedConversation.group ? String(selectedConversation.group.id) : undefined}
|
||||
embeddedGroupName={selectedConversation.group?.name}
|
||||
onEmbeddedBack={() => setSelectedConversation(null)}
|
||||
/>
|
||||
onEmbeddedBack={stableClearSelectedConversation}
|
||||
/>)
|
||||
) : (
|
||||
// 默认占位符
|
||||
<View style={styles.chatPlaceholder}>
|
||||
(<View style={styles.chatPlaceholder}>
|
||||
<View style={styles.chatPlaceholderContent}>
|
||||
<MaterialCommunityIcons name="message-text-outline" size={64} color={colors.chat.iconMuted} />
|
||||
<Text style={styles.chatPlaceholderText}>选择一个会话开始聊天</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>)
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
@@ -871,7 +873,7 @@ export const MessageListScreen: React.FC = () => {
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
{showNotifications ? (
|
||||
// 显示系统通知页面,传入 onBack 回调
|
||||
<NotificationsScreen onBack={() => setShowNotifications(false)} />
|
||||
(<NotificationsScreen onBack={stableCloseNotifications} />)
|
||||
) : isSearchMode ? (
|
||||
renderSearchMode()
|
||||
) : isWideScreen ? (
|
||||
@@ -882,7 +884,7 @@ export const MessageListScreen: React.FC = () => {
|
||||
renderConversationList()
|
||||
)}
|
||||
{renderActionMenu()}
|
||||
<QRCodeScanner visible={scannerVisible} onClose={() => setScannerVisible(false)} />
|
||||
<QRCodeScanner visible={scannerVisible} onClose={stableCloseScanner} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
347
src/screens/message/MessageSearchScreen.tsx
Normal file
347
src/screens/message/MessageSearchScreen.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* MessageSearchScreen 聊天记录搜索页
|
||||
* 在单个会话中搜索消息记录,基于本地 SQLite 缓存
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import {
|
||||
spacing,
|
||||
fontSizes,
|
||||
borderRadius,
|
||||
useAppColors,
|
||||
type AppColors,
|
||||
} from '../../theme';
|
||||
import { CachedMessage } from '../../database/types';
|
||||
import { extractTextFromSegments, UserDTO } from '../../types/dto';
|
||||
import { messageRepository } from '../../database';
|
||||
import { userManager } from '../../stores/user';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { Avatar, Text, EmptyState, AppBackButton } from '../../components/common';
|
||||
import { SearchBar } from '../../components/business';
|
||||
import HighlightText from '../../components/common/HighlightText';
|
||||
import { formatTime } from '../../utils/formatTime';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
interface SenderInfo {
|
||||
nickname: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export const MessageSearchScreen: React.FC = () => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createMessageSearchStyles(colors), [colors]);
|
||||
const router = useRouter();
|
||||
const currentUserId = useAuthStore(state => state.currentUser?.id);
|
||||
|
||||
const raw = useLocalSearchParams<{
|
||||
conversationId?: string;
|
||||
conversationName?: string;
|
||||
isGroupChat?: string;
|
||||
}>();
|
||||
const conversationId = raw.conversationId ?? '';
|
||||
const conversationName = raw.conversationName ?? '聊天';
|
||||
const isGroupChat = raw.isGroupChat === '1';
|
||||
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [results, setResults] = useState<CachedMessage[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const senderCache = useRef<Map<string, SenderInfo>>(new Map());
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const doSearch = useCallback(async (text: string, offset = 0) => {
|
||||
if (!text.trim() || !conversationId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const msgs = await messageRepository.searchByConversation(
|
||||
conversationId,
|
||||
text.trim(),
|
||||
PAGE_SIZE,
|
||||
offset,
|
||||
);
|
||||
if (offset === 0) {
|
||||
setResults(msgs);
|
||||
} else {
|
||||
setResults(prev => [...prev, ...msgs]);
|
||||
}
|
||||
setHasMore(msgs.length >= PAGE_SIZE);
|
||||
setSearched(true);
|
||||
|
||||
// prefetch sender info (skip system sender 10000)
|
||||
const senderIds = new Set(msgs.map(m => m.senderId).filter(id => id !== '10000'));
|
||||
for (const sid of senderIds) {
|
||||
if (!senderCache.current.has(sid)) {
|
||||
try {
|
||||
const u = await userManager.getUserById(sid);
|
||||
if (u) {
|
||||
senderCache.current.set(sid, {
|
||||
nickname: u.nickname || u.username || sid,
|
||||
avatar: u.avatar,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
senderCache.current.set(sid, { nickname: sid });
|
||||
}
|
||||
}
|
||||
}
|
||||
// system sender cache entry
|
||||
senderCache.current.set('10000', { nickname: '系统通知' });
|
||||
} catch (e) {
|
||||
console.error('搜索消息失败:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [conversationId]);
|
||||
|
||||
const handleTextChanged = useCallback((text: string) => {
|
||||
setKeyword(text);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (!text.trim()) {
|
||||
setResults([]);
|
||||
setSearched(false);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
doSearch(text, 0);
|
||||
}, 300);
|
||||
}, [doSearch]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (keyword.trim()) {
|
||||
doSearch(keyword, 0);
|
||||
}
|
||||
}, [keyword, doSearch]);
|
||||
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (!loading && hasMore && keyword.trim()) {
|
||||
doSearch(keyword, results.length);
|
||||
}
|
||||
}, [loading, hasMore, keyword, results.length, doSearch]);
|
||||
|
||||
const handlePressResult = useCallback((msg: CachedMessage) => {
|
||||
router.push(
|
||||
hrefs.hrefChat({
|
||||
conversationId,
|
||||
isGroupChat,
|
||||
scrollToSeq: msg.seq,
|
||||
}) as any,
|
||||
);
|
||||
}, [router, conversationId, isGroupChat]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getSender = useCallback((senderId: string): SenderInfo => {
|
||||
if (senderCache.current.has(senderId)) {
|
||||
return senderCache.current.get(senderId)!;
|
||||
}
|
||||
return { nickname: senderId };
|
||||
}, []);
|
||||
|
||||
const renderItem = useCallback(({ item }: { item: CachedMessage }) => {
|
||||
const sender = getSender(item.senderId);
|
||||
const isMe = item.senderId === currentUserId;
|
||||
const displayText = extractTextFromSegments(item.segments) || item.content || '';
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.resultItem}
|
||||
onPress={() => handlePressResult(item)}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<Avatar
|
||||
source={sender.avatar ? { uri: sender.avatar } : undefined}
|
||||
name={sender.nickname}
|
||||
size={36}
|
||||
/>
|
||||
<View style={styles.resultContent}>
|
||||
<View style={styles.resultHeader}>
|
||||
<Text style={styles.senderName} numberOfLines={1}>
|
||||
{isMe ? '我' : sender.nickname}
|
||||
</Text>
|
||||
<Text style={styles.resultTime}>
|
||||
{formatTime(item.createdAt)}
|
||||
</Text>
|
||||
</View>
|
||||
<HighlightText
|
||||
text={displayText}
|
||||
keyword={keyword}
|
||||
style={styles.resultText}
|
||||
highlightStyle={styles.highlightText}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}, [styles, keyword, currentUserId, getSender, handlePressResult]);
|
||||
|
||||
const ListEmptyComponent = useMemo(() => {
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!searched) {
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<EmptyState
|
||||
icon="magnify"
|
||||
title="搜索聊天记录"
|
||||
description="输入关键词搜索聊天记录"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<EmptyState
|
||||
icon="message-off-outline"
|
||||
title="未找到相关消息"
|
||||
description="换个关键词试试"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}, [loading, searched, styles, colors]);
|
||||
|
||||
const ListFooterComponent = useMemo(() => {
|
||||
if (loading && results.length > 0) {
|
||||
return (
|
||||
<View style={styles.footerLoader}>
|
||||
<ActivityIndicator size="small" color={colors.primary.main} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [loading, results.length, styles, colors]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<View style={styles.header}>
|
||||
<AppBackButton onPress={() => router.back()} />
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
{conversationName}
|
||||
</Text>
|
||||
<View style={styles.headerSpacer} />
|
||||
</View>
|
||||
|
||||
<View style={styles.searchWrap}>
|
||||
<SearchBar
|
||||
value={keyword}
|
||||
onChangeText={handleTextChanged}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder="搜索聊天记录"
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
|
||||
<FlashList
|
||||
data={results}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={item => item.id}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.3}
|
||||
ListEmptyComponent={ListEmptyComponent}
|
||||
ListFooterComponent={ListFooterComponent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
function createMessageSearchStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
headerTitle: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
textAlign: 'center',
|
||||
marginHorizontal: spacing.sm,
|
||||
},
|
||||
headerSpacer: {
|
||||
width: 40,
|
||||
},
|
||||
searchWrap: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
resultItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
resultContent: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
resultHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 2,
|
||||
},
|
||||
senderName: {
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '500',
|
||||
color: colors.text.primary,
|
||||
flex: 1,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
resultTime: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.text.hint,
|
||||
},
|
||||
resultText: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.secondary,
|
||||
lineHeight: fontSizes.sm * 1.4,
|
||||
},
|
||||
highlightText: {
|
||||
color: colors.primary.main,
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingTop: spacing.xl * 3,
|
||||
},
|
||||
footerLoader: {
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { FlashList, ListRenderItem } from '@shopify/flash-list';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import { useIsFocused } from "expo-router/react-navigation";
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { spacing, borderRadius, useAppColors, useResolvedColorScheme, type AppColors } from '../../theme';
|
||||
@@ -38,7 +38,7 @@ import { SystemMessageItem } from '../../components/business';
|
||||
import { Text, ResponsiveContainer, AppBackButton } from '../../components/common';
|
||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { useMessageManagerSystemUnreadCount } from '../../stores';
|
||||
import { useMessageManagerSystemUnreadCount, routePayloadCache } from '../../stores';
|
||||
import { messageManager } from '../../stores/message';
|
||||
|
||||
const MESSAGE_TYPES = [
|
||||
@@ -331,8 +331,10 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
||||
router.push(hrefs.hrefUserProfile(extra_data.actor_id_str));
|
||||
}
|
||||
} else if (system_type === 'group_join_apply') {
|
||||
routePayloadCache.stashSystemMessage(message);
|
||||
router.push(hrefs.hrefGroupRequestDetail(message));
|
||||
} else if (system_type === 'group_invite') {
|
||||
routePayloadCache.stashSystemMessage(message);
|
||||
router.push(hrefs.hrefGroupInviteDetail(message));
|
||||
}
|
||||
// 其他类型暂不处理跳转
|
||||
|
||||
@@ -145,8 +145,11 @@ const PrivateChatInfoScreen: React.FC = () => {
|
||||
|
||||
// 查找聊天记录
|
||||
const handleSearchMessages = () => {
|
||||
// TODO: 实现聊天记录搜索功能
|
||||
Alert.alert('提示', '聊天记录搜索功能开发中');
|
||||
router.push(hrefs.hrefMessageSearch({
|
||||
conversationId,
|
||||
conversationName: userName || user?.nickname || '私聊',
|
||||
isGroupChat: false,
|
||||
}));
|
||||
};
|
||||
|
||||
// 查看用户资料
|
||||
|
||||
@@ -374,7 +374,7 @@ export const GroupInfoPanel: React.FC<GroupInfoPanelProps> = ({
|
||||
function createGroupInfoPanelStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
zIndex: 100,
|
||||
},
|
||||
|
||||
@@ -379,7 +379,7 @@ export const GroupInfoPanel: React.FC<GroupInfoPanelProps> = ({
|
||||
function createGroupInfoPanelStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.35)',
|
||||
zIndex: 100,
|
||||
},
|
||||
|
||||
@@ -476,23 +476,102 @@ const renderVideoSegment = (data: VideoSegmentData, isMe: boolean): React.ReactN
|
||||
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
|
||||
* 当 data.expired 为 true 时显示"文件已过期"失效态,禁用点击。
|
||||
*/
|
||||
const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ data, isMe }) => {
|
||||
const styles = useSegmentStyles();
|
||||
const themeColors = useAppColors();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
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 (
|
||||
<TouchableOpacity
|
||||
style={[styles.fileContainer, isMe ? styles.fileMe : styles.fileOther]}
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.fileIcon}>
|
||||
<View style={[styles.fileIcon, { backgroundColor: `${visual.color}22` }]}>
|
||||
<MaterialCommunityIcons
|
||||
name="file-document"
|
||||
size={28}
|
||||
color={isMe ? themeColors.primary.contrast : themeColors.primary.main}
|
||||
name={visual.icon as any}
|
||||
size={26}
|
||||
color={isMe ? themeColors.primary.contrast : visual.color}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.fileInfo}>
|
||||
@@ -502,8 +581,15 @@ const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ d
|
||||
>
|
||||
{data.name}
|
||||
</Text>
|
||||
{fileSize ? <Text style={styles.fileSize}>{fileSize}</Text> : null}
|
||||
<Text style={styles.fileSize}>
|
||||
{downloading ? '下载中…' : (fileSize || (data.mime_type || '文件'))}
|
||||
</Text>
|
||||
</View>
|
||||
<MaterialCommunityIcons
|
||||
name={downloading ? 'progress-download' : 'download-outline'}
|
||||
size={20}
|
||||
color={isMe ? themeColors.primary.contrast : themeColors.chat.textTertiary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -1021,6 +1107,14 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// 文件过期失效态
|
||||
fileExpired: {
|
||||
opacity: 0.65,
|
||||
},
|
||||
fileExpiredText: {
|
||||
color: colors.chat.textTertiary,
|
||||
},
|
||||
|
||||
// 链接 - QQ风格:卡片式设计
|
||||
linkContainer: {
|
||||
borderRadius: 16,
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
/**
|
||||
* 消息气泡样式工具
|
||||
* 参考 Element X 设计,实现动态圆角和现代化气泡样式
|
||||
*/
|
||||
|
||||
import { StyleSheet, ViewStyle, TextStyle } from 'react-native';
|
||||
import { spacing, type AppColors } from '../../../../theme';
|
||||
import { useChatSettingsStore } from '../../../../stores/settings';
|
||||
|
||||
// 默认圆角,但会从 store 读取
|
||||
export const BUBBLE_RADIUS = 16;
|
||||
|
||||
// 获取动态圆角值
|
||||
export function useBubbleRadius(): number {
|
||||
return useChatSettingsStore((s) => s.messageRadius);
|
||||
}
|
||||
|
||||
export function getBubbleColors(colors: AppColors, outgoingBubbleColor?: string) {
|
||||
return {
|
||||
outgoing: {
|
||||
background: outgoingBubbleColor || colors.chat.bubbleOutgoing,
|
||||
text: colors.chat.textPrimary,
|
||||
},
|
||||
incoming: {
|
||||
background: colors.chat.bubbleIncoming,
|
||||
text: colors.chat.textPrimary,
|
||||
},
|
||||
replyHighlight: {
|
||||
background: colors.chat.replyTint,
|
||||
borderLeft: colors.chat.replyBorder,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type MessageGroupPosition = 'single' | 'first' | 'middle' | 'last';
|
||||
|
||||
// 使用动态圆角的 hook
|
||||
export function useBubbleBorderRadius(isMe: boolean, position: MessageGroupPosition): ViewStyle {
|
||||
const radius = useBubbleRadius();
|
||||
|
||||
if (isMe) {
|
||||
switch (position) {
|
||||
case 'single':
|
||||
return {
|
||||
borderTopLeftRadius: radius,
|
||||
borderTopRightRadius: radius,
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: 4,
|
||||
};
|
||||
case 'first':
|
||||
return {
|
||||
borderTopLeftRadius: radius,
|
||||
borderTopRightRadius: radius,
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: 4,
|
||||
};
|
||||
case 'middle':
|
||||
return {
|
||||
borderTopLeftRadius: radius,
|
||||
borderTopRightRadius: 4,
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: 4,
|
||||
};
|
||||
case 'last':
|
||||
return {
|
||||
borderTopLeftRadius: radius,
|
||||
borderTopRightRadius: 4,
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: radius,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
switch (position) {
|
||||
case 'single':
|
||||
return {
|
||||
borderTopLeftRadius: 4,
|
||||
borderTopRightRadius: radius,
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: radius,
|
||||
};
|
||||
case 'first':
|
||||
return {
|
||||
borderTopLeftRadius: 4,
|
||||
borderTopRightRadius: radius,
|
||||
borderBottomLeftRadius: 4,
|
||||
borderBottomRightRadius: radius,
|
||||
};
|
||||
case 'middle':
|
||||
return {
|
||||
borderTopLeftRadius: 4,
|
||||
borderTopRightRadius: radius,
|
||||
borderBottomLeftRadius: 4,
|
||||
borderBottomRightRadius: radius,
|
||||
};
|
||||
case 'last':
|
||||
return {
|
||||
borderTopLeftRadius: radius,
|
||||
borderTopRightRadius: radius,
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: radius,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保持原有的静态函数用于兼容
|
||||
export const getBubbleBorderRadius = (isMe: boolean, position: MessageGroupPosition): ViewStyle => {
|
||||
const radius = BUBBLE_RADIUS;
|
||||
|
||||
if (isMe) {
|
||||
switch (position) {
|
||||
case 'single':
|
||||
return {
|
||||
borderTopLeftRadius: radius,
|
||||
borderTopRightRadius: radius,
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: 4,
|
||||
};
|
||||
case 'first':
|
||||
return {
|
||||
borderTopLeftRadius: radius,
|
||||
borderTopRightRadius: radius,
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: 4,
|
||||
};
|
||||
case 'middle':
|
||||
return {
|
||||
borderTopLeftRadius: radius,
|
||||
borderTopRightRadius: 4,
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: 4,
|
||||
};
|
||||
case 'last':
|
||||
return {
|
||||
borderTopLeftRadius: radius,
|
||||
borderTopRightRadius: 4,
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: radius,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
switch (position) {
|
||||
case 'single':
|
||||
return {
|
||||
borderTopLeftRadius: 4,
|
||||
borderTopRightRadius: radius,
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: radius,
|
||||
};
|
||||
case 'first':
|
||||
return {
|
||||
borderTopLeftRadius: 4,
|
||||
borderTopRightRadius: radius,
|
||||
borderBottomLeftRadius: 4,
|
||||
borderBottomRightRadius: radius,
|
||||
};
|
||||
case 'middle':
|
||||
return {
|
||||
borderTopLeftRadius: 4,
|
||||
borderTopRightRadius: radius,
|
||||
borderBottomLeftRadius: 4,
|
||||
borderBottomRightRadius: radius,
|
||||
};
|
||||
case 'last':
|
||||
return {
|
||||
borderTopLeftRadius: radius,
|
||||
borderTopRightRadius: radius,
|
||||
borderBottomLeftRadius: radius,
|
||||
borderBottomRightRadius: radius,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getMessageGroupPosition = (
|
||||
index: number,
|
||||
messages: Array<{ sender_id: string }>,
|
||||
currentUserId: string
|
||||
): MessageGroupPosition => {
|
||||
const currentMessage = messages[index];
|
||||
if (!currentMessage) return 'single';
|
||||
|
||||
const prevMessage = index > 0 ? messages[index - 1] : null;
|
||||
const nextMessage = index < messages.length - 1 ? messages[index + 1] : null;
|
||||
|
||||
const isSameSenderAsPrev = prevMessage && prevMessage.sender_id === currentMessage.sender_id;
|
||||
const isSameSenderAsNext = nextMessage && nextMessage.sender_id === currentMessage.sender_id;
|
||||
|
||||
if (!isSameSenderAsPrev && !isSameSenderAsNext) {
|
||||
return 'single';
|
||||
} else if (!isSameSenderAsPrev && isSameSenderAsNext) {
|
||||
return 'first';
|
||||
} else if (isSameSenderAsPrev && isSameSenderAsNext) {
|
||||
return 'middle';
|
||||
} else {
|
||||
return 'last';
|
||||
}
|
||||
};
|
||||
|
||||
export const shouldShowSenderInfo = (
|
||||
index: number,
|
||||
messages: Array<{ sender_id: string }>
|
||||
): boolean => {
|
||||
if (index === 0) return true;
|
||||
|
||||
const currentMessage = messages[index];
|
||||
const prevMessage = messages[index - 1];
|
||||
|
||||
return prevMessage.sender_id !== currentMessage.sender_id;
|
||||
};
|
||||
|
||||
export const getBubbleBaseStyle = (isMe: boolean, colors: AppColors, outgoingBubbleColor?: string): ViewStyle => {
|
||||
const bc = getBubbleColors(colors, outgoingBubbleColor);
|
||||
return {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm + 4,
|
||||
minWidth: 60,
|
||||
maxWidth: '75%',
|
||||
backgroundColor: isMe ? bc.outgoing.background : bc.incoming.background,
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
};
|
||||
};
|
||||
|
||||
// 使用动态字号的 hook
|
||||
export function useChatFontSize(): number {
|
||||
return useChatSettingsStore((s) => s.fontSize);
|
||||
}
|
||||
|
||||
export const getBubbleTextStyle = (isMe: boolean, colors: AppColors, customFontSize?: number): TextStyle => {
|
||||
const bc = getBubbleColors(colors);
|
||||
const fontSize = customFontSize ?? 16;
|
||||
return {
|
||||
color: isMe ? bc.outgoing.text : bc.incoming.text,
|
||||
fontSize,
|
||||
lineHeight: Math.round(fontSize * 1.4),
|
||||
letterSpacing: 0.2,
|
||||
fontWeight: '400',
|
||||
};
|
||||
};
|
||||
|
||||
export function createBubbleStyles(colors: AppColors, customFontSize?: number, outgoingBubbleColor?: string) {
|
||||
const bc = getBubbleColors(colors, outgoingBubbleColor);
|
||||
const fontSize = customFontSize ?? 16;
|
||||
return StyleSheet.create({
|
||||
bubble: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm + 4,
|
||||
minWidth: 60,
|
||||
},
|
||||
outgoing: {
|
||||
backgroundColor: bc.outgoing.background,
|
||||
},
|
||||
incoming: {
|
||||
backgroundColor: bc.incoming.background,
|
||||
},
|
||||
text: {
|
||||
fontSize,
|
||||
lineHeight: Math.round(fontSize * 1.4),
|
||||
letterSpacing: 0.2,
|
||||
fontWeight: '400',
|
||||
},
|
||||
shadow: {
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
longPressShadow: {
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 3 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 8,
|
||||
elevation: 6,
|
||||
},
|
||||
replyHighlight: {
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.primary.main,
|
||||
backgroundColor: bc.replyHighlight.background,
|
||||
},
|
||||
recalled: {
|
||||
backgroundColor: colors.chat.surfaceMuted,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.chat.border,
|
||||
borderStyle: 'dashed',
|
||||
borderRadius: 12,
|
||||
},
|
||||
systemNotice: {
|
||||
alignItems: 'center',
|
||||
marginVertical: spacing.sm,
|
||||
},
|
||||
systemNoticeText: {
|
||||
fontSize: 12,
|
||||
color: colors.chat.textSecondary,
|
||||
backgroundColor: colors.chat.surfaceMuted,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
BUBBLE_RADIUS,
|
||||
getBubbleColors,
|
||||
getBubbleBorderRadius,
|
||||
getMessageGroupPosition,
|
||||
shouldShowSenderInfo,
|
||||
getBubbleBaseStyle,
|
||||
getBubbleTextStyle,
|
||||
createBubbleStyles,
|
||||
};
|
||||
@@ -178,13 +178,6 @@ export const MORE_ACTIONS: MoreAction[] = [
|
||||
color: '#5C6BC0',
|
||||
gradientColors: ['#7986CB', '#3F51B5'],
|
||||
},
|
||||
{
|
||||
id: 'location',
|
||||
icon: 'map-marker',
|
||||
name: '位置',
|
||||
color: '#EC407A',
|
||||
gradientColors: ['#F06292', '#D81B60'],
|
||||
},
|
||||
];
|
||||
|
||||
// 消息撤回时间限制(毫秒)
|
||||
|
||||
@@ -208,11 +208,6 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.96)',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: 'rgba(0, 0, 0, 0.08)',
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 6,
|
||||
elevation: 4,
|
||||
},
|
||||
jumpToLatestFabText: {
|
||||
fontSize: 13,
|
||||
@@ -312,11 +307,6 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
||||
messageBubbleOuter: {
|
||||
borderRadius: messageRadius,
|
||||
minWidth: 44,
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
myBubbleOuter: {
|
||||
borderTopRightRadius: Math.max(4, messageRadius * 0.3), // 箭头在右上
|
||||
@@ -556,7 +546,7 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
||||
|
||||
// 输入框@高亮
|
||||
inputHighlightOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
paddingTop: 8,
|
||||
@@ -1077,7 +1067,7 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
||||
|
||||
// 遮罩层
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -1147,7 +1137,7 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
manageModalBackdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
manageModalHandle: {
|
||||
@@ -1170,7 +1160,7 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
||||
borderColor: colors.chat.replyBorder,
|
||||
},
|
||||
stickerCheckOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: 'rgba(127, 182, 230, 0.14)',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'flex-start',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* ChatScreen 类型定义
|
||||
*/
|
||||
|
||||
import { MessageResponse, UserDTO, GroupMemberResponse, GroupResponse } from '../../../../types/dto';
|
||||
import { MessageResponse, UserDTO, GroupMemberResponse } from '../../../../types/dto';
|
||||
|
||||
// 面板类型
|
||||
export type PanelType = 'none' | 'emoji' | 'more' | 'mention';
|
||||
@@ -33,6 +33,7 @@ export interface ChatRouteParams {
|
||||
isGroupChat?: boolean;
|
||||
groupId?: string;
|
||||
groupName?: string;
|
||||
scrollToSeq?: number;
|
||||
}
|
||||
|
||||
// 发送者信息
|
||||
@@ -214,39 +215,3 @@ export interface SwipeableMessageBubbleProps {
|
||||
onReply: () => void;
|
||||
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,12 +20,15 @@ import {
|
||||
import { useLocalSearchParams, router } from 'expo-router';
|
||||
import { formatChatTime } from '@/utils/formatTime';
|
||||
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 { uploadService } from '@/services/upload';
|
||||
import { ApiError } from '@/services/core';
|
||||
// 【新架构】使用 MessageManager
|
||||
import { useChat, useGroupTyping, useGroupMuted, messageManager, callStore } from '../../../../stores';
|
||||
import { useMessageStore } from '../../../../stores/message';
|
||||
import { groupService } from '@/services/message';
|
||||
import { userManager } from '../../../../stores/user';
|
||||
import { groupManager } from '../../../../stores/group';
|
||||
@@ -97,6 +100,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
groupId?: string | string[];
|
||||
groupName?: string | string[];
|
||||
groupAvatar?: string | string[];
|
||||
scrollToSeq?: string | string[];
|
||||
}>();
|
||||
// 路由参数(动态段 + query);群 ID 勿用 Number(),避免超过 2^53-1 时精度丢失
|
||||
// 嵌入式模式下优先使用 props,否则从路由参数获取
|
||||
@@ -112,6 +116,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
};
|
||||
}
|
||||
const isGroupFlag = firstRouteParam(rawParams.isGroupChat);
|
||||
const scrollToSeqParam = firstRouteParam(rawParams.scrollToSeq);
|
||||
return {
|
||||
conversationId: firstRouteParam(rawParams.conversationId) ?? null,
|
||||
userId: firstRouteParam(rawParams.userId) ?? null,
|
||||
@@ -119,6 +124,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
groupId: firstRouteParam(rawParams.groupId),
|
||||
groupName: firstRouteParam(rawParams.groupName),
|
||||
groupAvatar: firstRouteParam(rawParams.groupAvatar),
|
||||
scrollToSeq: scrollToSeqParam ? Number(scrollToSeqParam) : null,
|
||||
};
|
||||
}, [
|
||||
props?.embeddedConversationId,
|
||||
@@ -131,9 +137,10 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
rawParams.groupId,
|
||||
rawParams.groupName,
|
||||
rawParams.groupAvatar,
|
||||
rawParams.scrollToSeq,
|
||||
]);
|
||||
|
||||
const { conversationId: routeConversationId, userId: routeUserId, isGroupChat, groupId: routeGroupId, groupName: routeGroupName, groupAvatar: routeGroupAvatar } = routeParams;
|
||||
const { conversationId: routeConversationId, userId: routeUserId, isGroupChat, groupId: routeGroupId, groupName: routeGroupName, groupAvatar: routeGroupAvatar, scrollToSeq: routeScrollToSeq } = routeParams;
|
||||
|
||||
// 当前会话ID(可能在新建私聊会话后动态更新)
|
||||
const [conversationId, setConversationId] = useState<string | null>(routeConversationId);
|
||||
@@ -176,8 +183,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [lastSeq, setLastSeq] = useState<number>(0);
|
||||
const [firstSeq, setFirstSeq] = useState<number>(0);
|
||||
const [otherUserLastReadSeq, setOtherUserLastReadSeq] = useState<number>(0);
|
||||
const [activePanel, setActivePanel] = useState<PanelType>('none');
|
||||
const [sendingImage, setSendingImage] = useState(false);
|
||||
@@ -191,15 +196,14 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
// 滚动状态机 refs(Telegram/Element 风格:底部粘附 + 阅读锚点)
|
||||
const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0, viewportHeight: 0 });
|
||||
const hasInitialAnchorDoneRef = useRef(false);
|
||||
const prevMessageCountRef = useRef(0);
|
||||
const prevLatestSeqRef = useRef(0);
|
||||
const prevMarkedReadSeqRef = useRef(0);
|
||||
const enterMarkedKeyRef = useRef<string>('');
|
||||
const isProgrammaticScrollRef = useRef(false);
|
||||
const suppressAutoFollowRef = useRef(false);
|
||||
const isBrowsingHistoryRef = useRef(false);
|
||||
const hasShownMessageListRef = useRef(false);
|
||||
const historyLoadingLockUntilRef = useRef(0);
|
||||
const scrollToSeqRef = useRef<number | null>(null);
|
||||
|
||||
// 回复消息状态
|
||||
const [replyingTo, setReplyingTo] = useState<GroupMessage | null>(null);
|
||||
@@ -223,6 +227,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
setGroupInfo(prev => prev ? prev : { name: name || undefined, avatar: avatar || null });
|
||||
}
|
||||
}, [isGroupChat, effectiveGroupName, effectiveGroupAvatar]);
|
||||
|
||||
const [currentUserRole, setCurrentUserRole] = useState<UserRole>('member');
|
||||
const [mentionQuery, setMentionQuery] = useState<string>('');
|
||||
const [selectedMentions, setSelectedMentions] = useState<string[]>([]);
|
||||
@@ -290,17 +295,14 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
}, [followRestricted, myPrivateSentCount]);
|
||||
|
||||
// 加载态语义修正:
|
||||
// isLoadingMessages 在分页加载历史时也会短暂为 true。
|
||||
// 若直接驱动 ChatScreen 的 loading,会导致消息列表被卸载重挂载,触发“回到底部”。
|
||||
// 这里只在“首屏且尚未展示过消息列表”时展示 loading 占位。
|
||||
// 首屏加载:尚未展示过消息列表 且 无消息 → 显示 loading(避免 FlashList 以空 data 挂载后 inverted 布局异常)
|
||||
// 一旦有消息或加载完成(isLoadingMessages 归 false),即解除 loading
|
||||
useEffect(() => {
|
||||
if (messageManagerMessages.length > 0) {
|
||||
hasShownMessageListRef.current = true;
|
||||
}
|
||||
const shouldShowInitialLoading =
|
||||
!hasShownMessageListRef.current &&
|
||||
isLoadingMessages &&
|
||||
messageManagerMessages.length === 0;
|
||||
!hasShownMessageListRef.current && messageManagerMessages.length === 0 && isLoadingMessages;
|
||||
setLoading(shouldShowInitialLoading);
|
||||
}, [isLoadingMessages, messageManagerMessages.length]);
|
||||
|
||||
@@ -366,10 +368,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
};
|
||||
}, [isGroupChat, otherUserId]);
|
||||
|
||||
// 进入新会话时重置滚动状态
|
||||
// 进入新会话时重置滚动/草稿状态
|
||||
useEffect(() => {
|
||||
hasInitialAnchorDoneRef.current = false;
|
||||
prevMessageCountRef.current = 0;
|
||||
prevLatestSeqRef.current = 0;
|
||||
prevMarkedReadSeqRef.current = 0;
|
||||
enterMarkedKeyRef.current = '';
|
||||
@@ -377,9 +378,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
isBrowsingHistoryRef.current = false;
|
||||
hasShownMessageListRef.current = false;
|
||||
historyLoadingLockUntilRef.current = 0;
|
||||
}, [conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToSeqRef.current = routeScrollToSeq ?? null;
|
||||
setPendingAttachments([]);
|
||||
}, [conversationId]);
|
||||
|
||||
@@ -399,14 +398,10 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => {
|
||||
if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked())) return;
|
||||
// inverted 列表下,最新消息端对应 offset=0
|
||||
isProgrammaticScrollRef.current = true;
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: 0,
|
||||
animated,
|
||||
});
|
||||
setTimeout(() => {
|
||||
isProgrammaticScrollRef.current = false;
|
||||
}, animated ? 220 : 32);
|
||||
}, [flatListRef, isHistoryLoadingLocked]);
|
||||
|
||||
const isNearBottom = useCallback(() => {
|
||||
@@ -415,18 +410,24 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
return scrollY <= 100;
|
||||
}, [scrollPositionRef]);
|
||||
|
||||
// 首屏加载完成后仅锚定一次到最新消息端
|
||||
// 首屏加载完成后仅锚定一次到最新消息端(有 scrollToSeq 时跳过)
|
||||
// 注意:必须在消息首次出现时立刻把 hasInitialAnchorDoneRef 置为 true,
|
||||
// 不能再依赖 viewportHeight 等通过 ref 同步的值——否则后续首次 loadMoreHistory
|
||||
// 让 messages.length 变化时此 effect 会被再度命中,并以 force=true 调用
|
||||
// scrollToLatest 强行把列表拉回最底端,造成"上滑首次加载更多就回底"的问题。
|
||||
useEffect(() => {
|
||||
if (
|
||||
loading ||
|
||||
loadingMore ||
|
||||
messages.length === 0 ||
|
||||
hasInitialAnchorDoneRef.current ||
|
||||
scrollPositionRef.current.viewportHeight <= 0
|
||||
hasInitialAnchorDoneRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
hasInitialAnchorDoneRef.current = true;
|
||||
if (scrollToSeqRef.current != null) return; // 由下方 scrollToSeq effect 处理
|
||||
// inverted FlashList 天然从 offset=0(最新端)开始;
|
||||
// 这里仅做一次保险锚定,即使布局尚未就绪也不会有副作用。
|
||||
const timer = setTimeout(() => {
|
||||
scrollToLatest(false, true, 'initial-anchor');
|
||||
}, 0);
|
||||
@@ -437,10 +438,8 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
// 仅当"最新端消息 seq 增长"且用户在底部附近时才跟随;
|
||||
// 历史加载只会增加旧消息,不会提升 latest seq,因此不会触发回底。
|
||||
useEffect(() => {
|
||||
const currentCount = messages.length;
|
||||
const latestSeq = currentCount > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
|
||||
const latestSeq = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
|
||||
const prevLatestSeq = prevLatestSeqRef.current;
|
||||
prevMessageCountRef.current = currentCount;
|
||||
prevLatestSeqRef.current = latestSeq;
|
||||
|
||||
if (loading || loadingMore) return;
|
||||
@@ -570,33 +569,20 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
};
|
||||
}, [routeUserId, conversationId, currentUserId, isGroupChat]);
|
||||
|
||||
// 加载更多历史消息(inverted 下保持阅读锚点)
|
||||
// 加载更多历史消息(inverted + maintainVisibleContentPosition 由列表原生保持位置)
|
||||
const loadMoreHistory = useCallback(async () => {
|
||||
if (!conversationId || !hasMoreHistory || loadingMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 历史加载期间禁止“新消息自动跟随到底”
|
||||
// 历史加载期间禁止"新消息自动跟随到底"
|
||||
suppressAutoFollowRef.current = true;
|
||||
isBrowsingHistoryRef.current = true;
|
||||
historyLoadingLockUntilRef.current = Date.now() + 3000;
|
||||
|
||||
// 保存加载前的滚动位置和内容高度
|
||||
const scrollYBefore = scrollPositionRef.current.scrollY;
|
||||
const contentHeightBefore = scrollPositionRef.current.contentHeight;
|
||||
setLoadingMore(true);
|
||||
|
||||
try {
|
||||
await loadMoreMessages();
|
||||
|
||||
// 更新 firstSeq
|
||||
if (messages.length > 0) {
|
||||
const minSeq = Math.min(...messages.map(m => m.seq));
|
||||
setFirstSeq(minSeq);
|
||||
}
|
||||
|
||||
// inverted + maintainVisibleContentPosition 下由列表原生保持位置
|
||||
// 不做手动 scrollToOffset,避免与原生锚点冲突导致回到底部
|
||||
} catch (error) {
|
||||
console.error('加载历史消息失败:', error);
|
||||
} finally {
|
||||
@@ -604,7 +590,37 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
// 加载结束后继续保留短暂锁窗,避免布局结算阶段误触自动回底
|
||||
historyLoadingLockUntilRef.current = Date.now() + 800;
|
||||
}
|
||||
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]);
|
||||
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages]);
|
||||
|
||||
// 从搜索结果跳转:滚动到目标 seq
|
||||
useEffect(() => {
|
||||
const targetSeq = scrollToSeqRef.current;
|
||||
if (targetSeq == null || !hasInitialAnchorDoneRef.current) return;
|
||||
if (loading || loadingMore) return;
|
||||
|
||||
const found = messages.find(m => m.seq === targetSeq);
|
||||
if (found) {
|
||||
scrollToSeqRef.current = null;
|
||||
isBrowsingHistoryRef.current = true;
|
||||
suppressAutoFollowRef.current = true;
|
||||
const ascendingIndex = messages.findIndex(m => m.seq === targetSeq);
|
||||
const displayIndex = messages.length - 1 - ascendingIndex;
|
||||
setTimeout(() => {
|
||||
flatListRef.current?.scrollToIndex({
|
||||
index: displayIndex,
|
||||
animated: false,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasMoreHistory) {
|
||||
loadMoreHistory();
|
||||
} else {
|
||||
scrollToSeqRef.current = null;
|
||||
}
|
||||
}, [loading, loadingMore, messages, hasMoreHistory, loadMoreHistory]);
|
||||
|
||||
// 列表内容尺寸变化:仅同步内容高度,锚点补偿由 loadMoreHistory 统一处理
|
||||
const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
|
||||
@@ -623,13 +639,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
isBrowsingHistoryRef.current = false;
|
||||
// FlashList 在 inverted 模式下 scrollToOffset({ offset: 0 }) 可能无法真正滚到最底部,
|
||||
// 因为列表内容高度变化后最小 offset 可能不是 0。先尝试滚到负值确保到底,再补偿回 0。
|
||||
isProgrammaticScrollRef.current = true;
|
||||
flatListRef.current?.scrollToOffset({ offset: -99999, animated: false });
|
||||
requestAnimationFrame(() => {
|
||||
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
setTimeout(() => {
|
||||
isProgrammaticScrollRef.current = false;
|
||||
}, 220);
|
||||
});
|
||||
}, [flatListRef]);
|
||||
|
||||
@@ -663,6 +675,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
// 自动标记已读(QQ/Telegram 风格):
|
||||
// 仅当出现更大的 latest seq,且用户在最新端附近时才上报。
|
||||
// 历史加载/浏览历史不会触发 read。
|
||||
// 注意:不能复用 prevLatestSeqRef 做判定——它被新消息跟随 effect 无条件推进,
|
||||
// 会导致这里的去重判断永远成立,标记已读逻辑事实上不会执行。
|
||||
// 这里只依赖专属的 prevMarkedReadSeqRef 做去重。
|
||||
useEffect(() => {
|
||||
if (!conversationId || messages.length === 0) return;
|
||||
if (loading || loadingMore) return;
|
||||
@@ -672,10 +687,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
const latestSeq = Math.max(...messages.map(m => m.seq), 0);
|
||||
if (latestSeq <= 0) return;
|
||||
|
||||
// 没有新增最新消息,不重复上报
|
||||
if (latestSeq <= prevLatestSeqRef.current) return;
|
||||
prevLatestSeqRef.current = latestSeq;
|
||||
|
||||
// 避免对同一 seq 重复标记
|
||||
if (latestSeq <= prevMarkedReadSeqRef.current) return;
|
||||
prevMarkedReadSeqRef.current = latestSeq;
|
||||
@@ -685,18 +696,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
});
|
||||
}, [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(() => {
|
||||
const keyboardWillShow = (e: KeyboardEvent) => {
|
||||
@@ -1014,6 +1013,38 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
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 trimmedText = inputText.trim();
|
||||
@@ -1069,7 +1100,10 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
const segments: MessageSegment[] = [...buildTextSegments(trimmedText, replyingTo)];
|
||||
// 有文本或回复时才构建文本段,纯图片时不塞空 text
|
||||
const segments: MessageSegment[] = (trimmedText || replyingTo)
|
||||
? [...buildTextSegments(trimmedText, replyingTo)]
|
||||
: [];
|
||||
for (const url of uploadedUrls) {
|
||||
segments.push({
|
||||
type: 'image',
|
||||
@@ -1215,6 +1249,89 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
setActivePanel('none');
|
||||
}, [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) => {
|
||||
switch (actionId) {
|
||||
@@ -1243,17 +1360,12 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
handleTakePhoto();
|
||||
break;
|
||||
case 'file':
|
||||
Alert.alert('提示', '文件功能即将上线');
|
||||
setActivePanel('none');
|
||||
break;
|
||||
case 'location':
|
||||
Alert.alert('提示', '位置功能即将上线');
|
||||
setActivePanel('none');
|
||||
handlePickFile();
|
||||
break;
|
||||
default:
|
||||
setActivePanel('none');
|
||||
}
|
||||
}, [handlePickImage, handleTakePhoto, isGroupChat, otherUser, conversationId]);
|
||||
}, [handlePickImage, handleTakePhoto, handlePickFile, isGroupChat, otherUser, conversationId]);
|
||||
|
||||
// 插入表情
|
||||
const handleInsertEmoji = useCallback((emoji: string) => {
|
||||
@@ -1326,20 +1438,15 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
setActivePanel('none');
|
||||
}, []);
|
||||
|
||||
// 撤回消息 - 现在通过 MessageManager 处理
|
||||
// 撤回消息 - 现在通过 MessageManager 处理(撤回事件由 MessageManager 同步状态)
|
||||
const handleRecall = useCallback(async (messageId: string) => {
|
||||
try {
|
||||
if (isGroupChat && effectiveGroupId) {
|
||||
await messageService.recallMessage(messageId);
|
||||
} else {
|
||||
await messageService.recallMessage(messageId);
|
||||
}
|
||||
// 不需要手动更新状态,MessageManager 会处理撤回事件
|
||||
await messageService.recallMessage(messageId);
|
||||
} catch (error) {
|
||||
console.error('撤回消息失败:', error);
|
||||
Alert.alert('撤回失败', '无法撤回消息');
|
||||
}
|
||||
}, [isGroupChat, effectiveGroupId, conversationId]);
|
||||
}, []);
|
||||
|
||||
// 长按消息显示操作菜单
|
||||
const handleLongPressMessage = useCallback((message: GroupMessage, position?: MenuPosition) => {
|
||||
@@ -1363,24 +1470,21 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
const handleDeleteMessage = useCallback(async (messageId: string) => {
|
||||
try {
|
||||
await messageService.deleteMessage(messageId);
|
||||
// 从本地状态移除(MessageManager 不会自动处理删除)
|
||||
const updatedMessages = messages.filter(m => m.id !== messageId);
|
||||
// 注意:这里我们依赖 MessageManager 的数据,所以需要通过其他方式刷新
|
||||
// 暂时只删除本地数据库
|
||||
await messageRepository.delete(messageId);
|
||||
if (conversationId) {
|
||||
useMessageStore.getState().removeMessage(conversationId, messageId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除消息失败:', error);
|
||||
Alert.alert('删除失败', '无法删除消息');
|
||||
}
|
||||
}, [messages]);
|
||||
}, [conversationId]);
|
||||
|
||||
// 清空会话的所有聊天记录
|
||||
const handleClearConversation = useCallback(async () => {
|
||||
if (!conversationId) return;
|
||||
|
||||
try {
|
||||
setLastSeq(0);
|
||||
setFirstSeq(0);
|
||||
setHasMoreHistory(true);
|
||||
await messageRepository.clearConversation(conversationId);
|
||||
// 刷新消息列表
|
||||
@@ -1389,9 +1493,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
console.error('清空会话失败:', error);
|
||||
Alert.alert('清空失败', '无法清空聊天记录');
|
||||
}
|
||||
}, [conversationId, refreshMessages]);
|
||||
|
||||
// 回复消息
|
||||
}, [conversationId, refreshMessages]); // 回复消息
|
||||
const handleReplyMessage = useCallback((message: GroupMessage) => {
|
||||
setReplyingTo(message);
|
||||
textInputRef.current?.focus();
|
||||
@@ -1444,26 +1546,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
}
|
||||
}, [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(() => {
|
||||
Keyboard.dismiss();
|
||||
@@ -1507,7 +1589,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
currentUserId,
|
||||
keyboardHeight,
|
||||
loading,
|
||||
sending,
|
||||
activePanel,
|
||||
sendingImage,
|
||||
uploadingAttachments,
|
||||
@@ -1517,16 +1598,12 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
longPressMenuVisible,
|
||||
selectedMessage,
|
||||
selectedMessageId,
|
||||
setSelectedMessageId,
|
||||
menuPosition,
|
||||
isGroupChat,
|
||||
groupInfo,
|
||||
groupMembers,
|
||||
// 【改造】使用 MessageManager 的输入状态
|
||||
typingUsers: groupTypingUsers,
|
||||
currentUserRole,
|
||||
mentionQuery,
|
||||
selectedMentions,
|
||||
isMuted,
|
||||
muteAll,
|
||||
followRestrictionHint,
|
||||
@@ -1548,9 +1625,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
shouldShowTime,
|
||||
handleInputChange,
|
||||
handleSend,
|
||||
handlePickImage,
|
||||
removePendingAttachment,
|
||||
handleTakePhoto,
|
||||
handleMoreAction,
|
||||
handleInsertEmoji,
|
||||
handleSendSticker,
|
||||
@@ -1569,9 +1644,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
handleMentionAll,
|
||||
getSenderInfo,
|
||||
getTypingHint,
|
||||
getPanelHeight,
|
||||
getInputBottom,
|
||||
getListPaddingBottom,
|
||||
handleDismiss,
|
||||
navigateToInfo,
|
||||
navigateToChatSettings,
|
||||
|
||||
@@ -12,3 +12,4 @@ export { default as PrivateChatInfoScreen } from './PrivateChatInfoScreen';
|
||||
export { default as JoinGroupScreen } from './JoinGroupScreen';
|
||||
export { default as GroupRequestDetailScreen } from './GroupRequestDetailScreen';
|
||||
export { default as GroupInviteDetailScreen } from './GroupInviteDetailScreen';
|
||||
export { MessageSearchScreen } from './MessageSearchScreen';
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
@@ -13,7 +15,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
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 { useResponsive, useResponsiveSpacing } from '../../hooks';
|
||||
import { useAuthStore } from '../../stores';
|
||||
@@ -33,6 +35,9 @@ function createAccountDeletionStyles(colors: AppColors) {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
keyboardView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingVertical: spacing.lg,
|
||||
},
|
||||
@@ -41,53 +46,153 @@ function createAccountDeletionStyles(colors: AppColors) {
|
||||
alignSelf: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
section: {
|
||||
marginBottom: spacing['2xl'],
|
||||
|
||||
// 顶部叙述区:左对齐、有呼吸感
|
||||
heroSection: {
|
||||
paddingHorizontal: spacing['2xl'],
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
// 警告卡片 - 扁平化风格
|
||||
warningCard: {
|
||||
backgroundColor: colors.error.light + '20',
|
||||
borderRadius: 14,
|
||||
padding: spacing.lg,
|
||||
marginHorizontal: spacing['2xl'],
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
warningTitle: {
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.md,
|
||||
heroEyebrow: {
|
||||
fontSize: 13,
|
||||
color: colors.text.hint,
|
||||
letterSpacing: 1,
|
||||
marginBottom: spacing.sm,
|
||||
color: colors.error.main,
|
||||
},
|
||||
warningText: {
|
||||
color: colors.error.dark,
|
||||
marginBottom: spacing.xs,
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
// 状态卡片 - 扁平化风格
|
||||
statusCard: {
|
||||
backgroundColor: colors.warning.light + '30',
|
||||
borderRadius: 14,
|
||||
padding: spacing.lg,
|
||||
marginHorizontal: spacing['2xl'],
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
statusTitle: {
|
||||
heroTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
lineHeight: 32,
|
||||
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,
|
||||
fontWeight: '700',
|
||||
color: colors.warning.dark,
|
||||
textAlign: 'center',
|
||||
marginVertical: spacing.md,
|
||||
lineHeight: 36,
|
||||
},
|
||||
// 内容区域
|
||||
sectionContent: {
|
||||
marginHorizontal: spacing['2xl'],
|
||||
marginBottom: spacing.xl,
|
||||
countdownUnit: {
|
||||
fontSize: 11,
|
||||
color: colors.text.secondary,
|
||||
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: {
|
||||
fontWeight: '600',
|
||||
@@ -95,47 +200,40 @@ function createAccountDeletionStyles(colors: AppColors) {
|
||||
color: colors.text.secondary,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
listItem: {
|
||||
|
||||
// 输入框
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
listBullet: {
|
||||
marginRight: spacing.sm,
|
||||
marginTop: 2,
|
||||
},
|
||||
// 输入框 - 扁平化风格
|
||||
input: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: 14,
|
||||
padding: spacing.md,
|
||||
fontSize: 16,
|
||||
paddingHorizontal: spacing.lg,
|
||||
height: 56,
|
||||
marginHorizontal: spacing['2xl'],
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
color: colors.text.primary,
|
||||
fontSize: fontSizes.md,
|
||||
height: 56,
|
||||
},
|
||||
// 按钮行
|
||||
eyeButton: {
|
||||
padding: 4,
|
||||
marginLeft: 4,
|
||||
},
|
||||
|
||||
// 按钮行:次按钮在左、危险按钮在右,与全站保持一致
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.lg,
|
||||
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: {
|
||||
flex: 1,
|
||||
height: 56,
|
||||
@@ -165,11 +263,38 @@ function createAccountDeletionStyles(colors: AppColors) {
|
||||
fontWeight: '600',
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
opacity: 0.5,
|
||||
},
|
||||
cancelButton: {
|
||||
marginTop: spacing.lg,
|
||||
primaryButton: {
|
||||
height: 56,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginHorizontal: spacing['2xl'],
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// 页脚:联系客服
|
||||
footer: {
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xl,
|
||||
paddingHorizontal: spacing['2xl'],
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 13,
|
||||
color: colors.text.hint,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
footerLink: {
|
||||
color: colors.primary.main,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -180,14 +305,14 @@ export const AccountDeletionScreen: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { isMobile } = useResponsive();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { logout } = useAuthStore();
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const [status, setStatus] = useState<DeletionStatusDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
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 loadStatus = useCallback(async () => {
|
||||
@@ -284,121 +409,244 @@ export const AccountDeletionScreen: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// 待注销状态
|
||||
if (status?.is_pending_deletion) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<StatusBar style="auto" />
|
||||
<SimpleHeader title="注销账号" onBack={() => router.back()} />
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
{/* 顶部叙述 */}
|
||||
<View style={styles.heroSection}>
|
||||
<Text style={styles.heroEyebrow}>ACCOUNT · 注销申请中</Text>
|
||||
<Text style={styles.heroTitle}>我们将在倒计时结束后清除你的账号</Text>
|
||||
<Text style={styles.heroDesc}>
|
||||
在此期间,你随时可以撤销申请,账号会立即恢复正常使用。
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 倒计时 */}
|
||||
<View style={styles.countdownSection}>
|
||||
<View style={styles.countdownRing}>
|
||||
<Text style={styles.countdownNumber}>{status.cool_down_days || 90}</Text>
|
||||
<Text style={styles.countdownUnit}>天</Text>
|
||||
</View>
|
||||
<View style={styles.countdownTextWrap}>
|
||||
<Text style={styles.countdownTitle}>距离永久删除</Text>
|
||||
<Text style={styles.countdownDesc}>
|
||||
再次登录或点击下方按钮可立即取消注销。
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 取消按钮 */}
|
||||
<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>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// 正常状态:申请注销
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<StatusBar style="auto" />
|
||||
<SimpleHeader title="注销账号" onBack={() => router.back()} />
|
||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
||||
<View style={styles.content}>
|
||||
{status?.is_pending_deletion ? (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.statusCard}>
|
||||
<Text variant="body" style={styles.statusTitle}>
|
||||
账号注销申请中
|
||||
</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 ? (
|
||||
<ActivityIndicator size="small" color={colors.text.primary} />
|
||||
) : (
|
||||
<Text style={styles.secondaryButtonText}>取消注销申请</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<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>
|
||||
</View>
|
||||
|
||||
{/* 注销影响 - 列表式(无背景) */}
|
||||
<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.section}>
|
||||
{/* 警告卡片 */}
|
||||
<View style={styles.warningCard}>
|
||||
<Text variant="body" style={styles.warningTitle}>
|
||||
警告:账号注销后将无法恢复
|
||||
|
||||
{/* 红色提示 - 左侧细线代替大色块 */}
|
||||
<View style={styles.noticeSection}>
|
||||
<View style={styles.noticeBar} />
|
||||
<View style={styles.noticeTextWrap}>
|
||||
<Text style={styles.noticeTitle}>这是不可恢复的操作</Text>
|
||||
<Text style={styles.noticeDesc}>
|
||||
90 天后所有数据将被永久删除,届时无法通过任何方式找回。请确认你已备份好需要保留的内容。
|
||||
</Text>
|
||||
<Text variant="body" style={styles.warningText}>
|
||||
注销后,您的账号将在90天后永久删除。
|
||||
</Text>
|
||||
<Text variant="body" style={styles.warningText}>
|
||||
在此期间,您可以通过重新登录来取消注销。
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 密码确认 */}
|
||||
<View style={styles.formSection}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text variant="caption" style={styles.sectionTitle}>
|
||||
身份验证
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 注销说明 */}
|
||||
<View style={styles.sectionContent}>
|
||||
<Text style={styles.sectionTitle}>注销后将发生什么</Text>
|
||||
<View style={styles.listItem}>
|
||||
<MaterialCommunityIcons name="close-circle" size={16} color={colors.error.main} style={styles.listBullet} />
|
||||
<Text variant="body" color={colors.text.secondary}>
|
||||
您的个人资料将被删除
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.listItem}>
|
||||
<MaterialCommunityIcons name="information" size={16} color={colors.warning.main} style={styles.listBullet} />
|
||||
<Text variant="body" color={colors.text.secondary}>
|
||||
您发布的帖子、评论将保留,但显示为「已注销用户」
|
||||
</Text>
|
||||
</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}>
|
||||
您的关注、粉丝关系将被清除
|
||||
</Text>
|
||||
</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}>
|
||||
您的收藏、点赞记录将被删除
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 密码确认 */}
|
||||
<View style={styles.sectionContent}>
|
||||
<Text style={styles.sectionTitle}>请输入密码确认</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons
|
||||
name="lock-outline"
|
||||
size={20}
|
||||
color={colors.text.secondary}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="请输入密码"
|
||||
placeholder="请输入登录密码以确认"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
secureTextEntry
|
||||
secureTextEntry={!showPassword}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
autoCapitalize="none"
|
||||
returnKeyType="done"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 按钮行 */}
|
||||
<View style={styles.buttonRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => router.back()}
|
||||
onPress={() => setShowPassword(!showPassword)}
|
||||
style={styles.eyeButton}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>返回</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.dangerButton, submitting && styles.buttonDisabled]}
|
||||
onPress={handleRequestDeletion}
|
||||
disabled={submitting || !password.trim()}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator size="small" color={colors.text.inverse} />
|
||||
) : (
|
||||
<Text style={styles.dangerButtonText}>确认注销</Text>
|
||||
)}
|
||||
<MaterialCommunityIcons
|
||||
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* 按钮行 */}
|
||||
<View style={styles.buttonRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => router.back()}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>再想想</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.dangerButton,
|
||||
(submitting || !password.trim()) && styles.buttonDisabled,
|
||||
]}
|
||||
onPress={handleRequestDeletion}
|
||||
disabled={submitting || !password.trim()}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator size="small" color={colors.text.inverse} />
|
||||
) : (
|
||||
<Text style={styles.dangerButtonText}>确认申请注销</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 页脚 */}
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>
|
||||
遇到问题?可以联系<Text style={styles.footerLink}> 客服 </Text>
|
||||
我们会帮你处理
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* 聊天设置页 ChatSettingsScreen
|
||||
* 威友 - 聊天个性化设置
|
||||
*/
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Dimensions,
|
||||
PanResponder,
|
||||
} from 'react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
@@ -30,7 +29,6 @@ import {
|
||||
CHAT_THEMES,
|
||||
} from '../../stores/settings';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
const CARD_MAX_WIDTH = 720;
|
||||
|
||||
interface SliderProps {
|
||||
@@ -43,7 +41,6 @@ interface SliderProps {
|
||||
showValue?: boolean;
|
||||
}
|
||||
|
||||
// 自定义滑块组件(支持拖动)
|
||||
const Slider: React.FC<SliderProps> = ({
|
||||
value,
|
||||
min,
|
||||
@@ -177,7 +174,6 @@ function createStyles(colors: AppColors) {
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
// 聊天预览样式
|
||||
previewContainer: {
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
borderRadius: 14,
|
||||
@@ -211,7 +207,6 @@ function createStyles(colors: AppColors) {
|
||||
previewText: {
|
||||
color: colors.text.primary,
|
||||
},
|
||||
// 主题选择样式
|
||||
themeList: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
@@ -254,12 +249,6 @@ function createStyles(colors: AppColors) {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
themeName: {
|
||||
fontSize: fontSizes.xs,
|
||||
textAlign: 'center',
|
||||
marginTop: 4,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
sliderContainer: {
|
||||
paddingVertical: 8,
|
||||
},
|
||||
@@ -272,7 +261,6 @@ export const ChatSettingsScreen: React.FC = () => {
|
||||
const styles = useMemo(() => createStyles(colors), [colors]);
|
||||
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
||||
|
||||
// 从 store 获取状态
|
||||
const fontSize = useChatSettingsStore((s) => s.fontSize);
|
||||
const messageRadius = useChatSettingsStore((s) => s.messageRadius);
|
||||
const themeIndex = useChatSettingsStore((s) => s.themeIndex);
|
||||
@@ -281,7 +269,6 @@ export const ChatSettingsScreen: React.FC = () => {
|
||||
|
||||
const currentTheme = CHAT_THEMES[themeIndex];
|
||||
|
||||
// 渲染聊天预览
|
||||
const renderChatPreview = () => (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>预览</Text>
|
||||
@@ -316,7 +303,6 @@ export const ChatSettingsScreen: React.FC = () => {
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染字号滑块
|
||||
const renderFontSizeSlider = () => (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>消息字号</Text>
|
||||
@@ -333,7 +319,6 @@ export const ChatSettingsScreen: React.FC = () => {
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染主题颜色选择
|
||||
const renderThemeColors = () => (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>主题颜色</Text>
|
||||
@@ -366,7 +351,6 @@ export const ChatSettingsScreen: React.FC = () => {
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染圆角滑块
|
||||
const renderRadiusSlider = () => (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>消息圆角</Text>
|
||||
|
||||
@@ -63,13 +63,19 @@ export const DataStorageScreen: React.FC = () => {
|
||||
if (Platform.OS === 'web') return 0;
|
||||
try {
|
||||
const cacheDir = Paths.cache;
|
||||
const sdImageCacheDir = new Directory(cacheDir, 'defaultDiskCache');
|
||||
const expoImageCacheDir = new Directory(cacheDir, 'expo-image');
|
||||
// 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> => {
|
||||
let size = 0;
|
||||
try {
|
||||
if (!(await dir.exists)) return 0;
|
||||
if (!dir.exists) return 0;
|
||||
const items = dir.list();
|
||||
for (const item of items) {
|
||||
try {
|
||||
@@ -87,7 +93,10 @@ export const DataStorageScreen: React.FC = () => {
|
||||
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;
|
||||
} catch (error) {
|
||||
console.warn('读取 expo-image 缓存大小失败:', error);
|
||||
@@ -107,9 +116,9 @@ export const DataStorageScreen: React.FC = () => {
|
||||
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
if (keys.length > 0) {
|
||||
const items = await AsyncStorage.multiGet(keys);
|
||||
const items = await AsyncStorage.getMany(keys);
|
||||
let totalSize = 0;
|
||||
for (const [, value] of items) {
|
||||
for (const value of Object.values(items)) {
|
||||
if (value) {
|
||||
totalSize += value.length * 2;
|
||||
}
|
||||
@@ -144,6 +153,9 @@ export const DataStorageScreen: React.FC = () => {
|
||||
await mediaCacheManager.clearAll();
|
||||
await Image.clearDiskCache();
|
||||
await Image.clearMemoryCache();
|
||||
// expo-image 的磁盘缓存清理由原生库(SDWebImage / Glide)在后台线程执行,
|
||||
// 立即读取目录大小可能仍是清理前的值,这里短暂等待以确保文件落盘删除完成。
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
await loadStats();
|
||||
Alert.alert('完成', '缓存已清除');
|
||||
} catch (error) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useNavigation } from "expo-router/react-navigation";
|
||||
import { useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
@@ -68,7 +68,7 @@ function createEditProfileStyles(colors: AppColors) {
|
||||
alignItems: 'center',
|
||||
},
|
||||
coverOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
@@ -279,7 +279,8 @@ export const EditProfileScreen: React.FC = () => {
|
||||
const styles = useMemo(() => createEditProfileStyles(colors), [colors]);
|
||||
const navigation = useNavigation();
|
||||
const router = useRouter();
|
||||
const { currentUser, updateUser } = useAuthStore();
|
||||
const currentUser = useAuthStore((s) => s.currentUser);
|
||||
const updateUser = useAuthStore((s) => s.updateUser);
|
||||
const { isWideScreen, isMobile, width } = useResponsive();
|
||||
const responsivePadding = useResponsiveSpacing({ xs: 0, sm: 0, md: 0, lg: 24, xl: 32 });
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -32,8 +32,8 @@ const FollowListScreen: React.FC = () => {
|
||||
const { userId = '', type: typeParam } = useLocalSearchParams<{ userId?: string; type?: string }>();
|
||||
const type = typeParam === 'followers' ? 'followers' : 'following';
|
||||
|
||||
const { currentUser } = useAuthStore();
|
||||
const { followUser, unfollowUser } = useUserStore();
|
||||
const currentUser = useAuthStore((s) => s.currentUser);
|
||||
const { followUser, unfollowUser } = useUserStore.getState();
|
||||
|
||||
// 响应式布局
|
||||
const { isWideScreen, isDesktop, width } = useResponsive();
|
||||
|
||||
@@ -29,6 +29,12 @@ import {
|
||||
} from '@/services/notification';
|
||||
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
||||
import { backgroundSyncManager, BackgroundSyncMode } from '@/services/background';
|
||||
import {
|
||||
loadAutoStartConsent,
|
||||
consentToAutoStart,
|
||||
rejectAutoStart,
|
||||
getAutoStartDescription,
|
||||
} from '@/services/consent';
|
||||
|
||||
// 内容最大宽度
|
||||
const CONTENT_MAX_WIDTH = 720;
|
||||
@@ -49,8 +55,9 @@ export const NotificationSettingsScreen: React.FC = () => {
|
||||
const [vibrationEnabled, setVibrationEnabledState] = useState(true);
|
||||
const [pushEnabled, setPushEnabled] = 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 [autoStartConsented, setAutoStartConsented] = useState(false);
|
||||
const { isWideScreen, isMobile } = useResponsive();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -67,6 +74,10 @@ export const NotificationSettingsScreen: React.FC = () => {
|
||||
setSoundEnabled(prefs.soundEnabled);
|
||||
setSyncMode(backgroundSyncManager.getMode());
|
||||
|
||||
// 加载自启动同意状态
|
||||
const consent = await loadAutoStartConsent();
|
||||
setAutoStartConsented(consent.consented);
|
||||
|
||||
if (Platform.OS !== 'web') {
|
||||
const enabled = await jpushService.checkNotificationPermission();
|
||||
setSystemPushEnabled(enabled);
|
||||
@@ -106,6 +117,41 @@ export const NotificationSettingsScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
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) {
|
||||
Alert.alert(
|
||||
'实时模式',
|
||||
@@ -130,24 +176,24 @@ export const NotificationSettingsScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
const syncModeOptions: { mode: BackgroundSyncMode; title: string; subtitle: string; icon: string }[] = [
|
||||
{
|
||||
mode: BackgroundSyncMode.DISABLED,
|
||||
title: '静默模式',
|
||||
subtitle: '不自启动,仅在使用时接收消息',
|
||||
icon: 'bell-off-outline',
|
||||
},
|
||||
{
|
||||
mode: BackgroundSyncMode.BATTERY_SAVER,
|
||||
title: '省电模式',
|
||||
subtitle: '系统后台任务,每 15 分钟检查一次',
|
||||
title: '后台模式',
|
||||
subtitle: '允许自启动,每 15 分钟检查一次',
|
||||
icon: 'leaf',
|
||||
},
|
||||
{
|
||||
mode: BackgroundSyncMode.REALTIME,
|
||||
title: '实时模式',
|
||||
subtitle: '通知栏常驻保活,即时同步消息',
|
||||
subtitle: '允许自启动,通知栏常驻保活',
|
||||
icon: 'lightning-bolt',
|
||||
},
|
||||
{
|
||||
mode: BackgroundSyncMode.DISABLED,
|
||||
title: '禁用',
|
||||
subtitle: '仅在应用打开时接收消息',
|
||||
icon: 'close-circle-outline',
|
||||
},
|
||||
];
|
||||
|
||||
const handleRequestSystemPermission = async () => {
|
||||
@@ -304,12 +350,30 @@ export const NotificationSettingsScreen: React.FC = () => {
|
||||
))}
|
||||
</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 && (
|
||||
<View style={styles.tipContainer}>
|
||||
<MaterialCommunityIcons name="shield-check-outline" size={16} color={colors.text.hint} />
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.tipText}>
|
||||
实时模式已开启,应用将在通知栏显示常驻通知以保持后台运行。如需更稳定的后台运行,请在系统设置中将本应用加入电池优化白名单。
|
||||
实时模式已开启,应用允许自启动并在通知栏显示常驻通知以保持后台运行。如需更稳定的后台运行,请在系统设置中将本应用加入电池优化白名单。
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -30,7 +30,7 @@ const THEME_COLORS = {
|
||||
};
|
||||
|
||||
// 政策最后更新日期
|
||||
const LAST_UPDATED = '2026年4月28日';
|
||||
const LAST_UPDATED = '2026年6月15日';
|
||||
|
||||
// 隐私政策内容
|
||||
const PRIVACY_SECTIONS = [
|
||||
@@ -159,29 +159,60 @@ const PRIVACY_SECTIONS = [
|
||||
title: '七、第三方SDK目录',
|
||||
content: `为了保障App的稳定运行或实现特定功能,我们可能接入第三方SDK。截至本隐私政策更新之日,我们主要使用以下服务:
|
||||
|
||||
1. 极光推送(JPush)
|
||||
• 提供方:深圳市和讯华谷信息技术有限公司
|
||||
• 使用目的:实现消息推送功能,向您的设备发送通知提醒
|
||||
• 收集的个人信息:设备标识符(Registration ID、Android ID、IMEI、OAID)、设备型号、操作系统版本、IP地址、应用列表信息
|
||||
• 隐私政策:https://www.jiguang.cn/privacy
|
||||
|
||||
2. Expo推送服务
|
||||
• 提供方:Expo(650 Industries, Inc.)
|
||||
• 使用目的:辅助消息推送通道管理
|
||||
• 收集的个人信息:设备推送令牌(Push Token)、设备标识符
|
||||
1. 极光推送 - JPush SDK - Android
|
||||
所属系统:安卓、iOS
|
||||
第三方公司名称:深圳市和讯华谷信息技术有限公司
|
||||
第三方收集的个人信息类型:
|
||||
1. 设备标识符(Registration ID、Android ID、IMEI、OAID);2. 设备型号、操作系统版本;3. IP地址;4. 应用列表信息
|
||||
我们从第三方获取的个人信息:设备标识符(Registration ID)
|
||||
第三方可能调用的权限:无
|
||||
实现功能及场景描述:消息推送
|
||||
处理目的:实现消息推送功能,向您的设备发送通知提醒
|
||||
处理方式:SDK采集
|
||||
联系方式:官网 https://www.jiguang.cn/
|
||||
第三方隐私政策链接:https://www.jiguang.cn/license/privacy
|
||||
|
||||
如您后续接入其他第三方SDK(如微信登录、分享等功能),我们将在本章节更新相关说明,并告知您对应SDK收集的信息类型和用途。更新后的SDK目录将在应用内公布,请以最新版本为准。`,
|
||||
},
|
||||
{
|
||||
title: '八、隐私政策的更新',
|
||||
content: `8.1 我们可能会不时更新本隐私政策。更新后的隐私政策将在应用内公布,并标注更新日期。
|
||||
title: '八、应用自启动与关联启动说明',
|
||||
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: `如果您对本隐私政策有任何疑问、意见或建议,或者您希望行使您的权利,请通过以下方式与我们联系:
|
||||
|
||||
• 邮箱:system@qczlit.cn
|
||||
|
||||
@@ -218,7 +218,7 @@ function createSettingsStyles(colors: AppColors) {
|
||||
|
||||
export const SettingsScreen: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { logout } = useAuthStore();
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { isMobile } = useResponsive();
|
||||
const responsivePadding = useResponsiveSpacing({ xs: 0, sm: 0, md: 0, lg: 24, xl: 32 });
|
||||
|
||||
@@ -103,7 +103,7 @@ const TERMS_SECTIONS = [
|
||||
|
||||
6.3 我们将采取合理的技术和管理措施保护您的个人信息安全,但不对因不可抗力或第三方原因导致的信息泄露承担责任。
|
||||
|
||||
6.4 本应用集成了极光推送(JPush)等第三方SDK,用于实现消息推送等功能。第三方SDK可能会收集和处理您的设备标识符、设备信息等必要数据。具体的第三方SDK信息请参见《隐私政策》中的"第三方SDK目录"章节。`,
|
||||
6.4 本应用集成了极光推送 - JPush SDK - Android等第三方SDK,用于实现消息推送等功能。第三方SDK可能会收集和处理您的设备标识符、设备信息等必要数据。具体的第三方SDK信息请参见《隐私政策》中的"第三方SDK目录"章节。`,
|
||||
},
|
||||
{
|
||||
title: '七、免责声明',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 支持桌面端双栏布局
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
RefreshControl,
|
||||
@@ -16,10 +16,72 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useAppColors } from '../../theme';
|
||||
import { Post } from '../../types';
|
||||
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 { useUserProfile, ProfileMode, TABS, TAB_ICONS, createSharedProfileStyles } from './useUserProfile';
|
||||
|
||||
// 稳定的头部组件:切换 tab 时不会导致 UserProfileHeader 重渲染
|
||||
// 因为 activeTab 只影响 TabBar,UserProfileHeader 的 props 不变
|
||||
interface ProfileListHeaderProps {
|
||||
user: any;
|
||||
isCurrentUser: boolean;
|
||||
isBlocked: boolean;
|
||||
handleFollow: () => void;
|
||||
handleSettings?: () => void;
|
||||
handleEditProfile?: () => void;
|
||||
handleMessage?: () => Promise<void>;
|
||||
handleBlock?: () => Promise<void>;
|
||||
handleFollowingPress: () => void;
|
||||
handleFollowersPress: () => void;
|
||||
activeTab: number;
|
||||
setActiveTab: (tab: number) => void;
|
||||
tabBarContainerStyle: any;
|
||||
}
|
||||
|
||||
const ProfileListHeader = React.memo(function ProfileListHeader({
|
||||
user,
|
||||
isCurrentUser,
|
||||
isBlocked,
|
||||
handleFollow,
|
||||
handleSettings,
|
||||
handleEditProfile,
|
||||
handleMessage,
|
||||
handleBlock,
|
||||
handleFollowingPress,
|
||||
handleFollowersPress,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
tabBarContainerStyle,
|
||||
}: ProfileListHeaderProps) {
|
||||
if (!user) return null;
|
||||
return (
|
||||
<>
|
||||
<UserProfileHeader
|
||||
user={user}
|
||||
isCurrentUser={isCurrentUser}
|
||||
isBlocked={isBlocked}
|
||||
onFollow={handleFollow}
|
||||
onSettings={handleSettings}
|
||||
onEditProfile={handleEditProfile}
|
||||
onMessage={handleMessage}
|
||||
onBlock={handleBlock}
|
||||
onFollowingPress={handleFollowingPress}
|
||||
onFollowersPress={handleFollowersPress}
|
||||
/>
|
||||
<View style={tabBarContainerStyle}>
|
||||
<TabBar
|
||||
tabs={TABS}
|
||||
activeIndex={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
variant="modern"
|
||||
icons={TAB_ICONS}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface UserProfileScreenProps {
|
||||
mode: ProfileMode;
|
||||
userId?: string;
|
||||
@@ -55,6 +117,39 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
||||
currentUser,
|
||||
} = 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;
|
||||
|
||||
@@ -69,12 +164,12 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
||||
]}>
|
||||
<PostCard
|
||||
post={item}
|
||||
onAction={(action) => handlePostAction(item, action)}
|
||||
onAction={(action) => onPostAction(item, action)}
|
||||
isPostAuthor={isPostAuthor}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}, [currentUser?.id, handlePostAction, currentPosts.length]);
|
||||
}, [currentUser?.id, onPostAction, currentPosts.length]);
|
||||
|
||||
const postKeyExtractor = useCallback((item: Post) => item.id, []);
|
||||
|
||||
@@ -110,50 +205,7 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
||||
);
|
||||
}, [loading, activeTab, mode, isBlockedProfile]);
|
||||
|
||||
// 渲染用户信息头部(与 TabBar 分离,避免切换 tab 时重渲染)
|
||||
const renderUserHeader = useMemo(() => {
|
||||
if (!user) return null;
|
||||
return (
|
||||
<UserProfileHeader
|
||||
user={user}
|
||||
isCurrentUser={isCurrentUser}
|
||||
isBlocked={isBlocked}
|
||||
onFollow={handleFollow}
|
||||
onSettings={handleSettings}
|
||||
onEditProfile={handleEditProfile}
|
||||
onMessage={handleMessage}
|
||||
onBlock={handleBlock}
|
||||
onFollowingPress={handleFollowingPress}
|
||||
onFollowersPress={handleFollowersPress}
|
||||
/>
|
||||
);
|
||||
}, [user, isCurrentUser, isBlocked, handleFollow, handleSettings, handleEditProfile, handleMessage, handleBlock, handleFollowingPress, handleFollowersPress]);
|
||||
|
||||
// 渲染 TabBar
|
||||
const renderTabBar = useMemo(() => (
|
||||
<View style={sharedStyles.tabBarContainer}>
|
||||
<TabBar
|
||||
tabs={TABS}
|
||||
activeIndex={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
variant="modern"
|
||||
icons={TAB_ICONS}
|
||||
/>
|
||||
</View>
|
||||
), [activeTab, setActiveTab, sharedStyles.tabBarContainer]);
|
||||
|
||||
// 渲染 FlashList 头部(用户信息 + TabBar)
|
||||
const renderListHeader = useCallback(() => {
|
||||
if (!user) return null;
|
||||
return (
|
||||
<>
|
||||
{renderUserHeader}
|
||||
{renderTabBar}
|
||||
</>
|
||||
);
|
||||
}, [user, renderUserHeader, renderTabBar]);
|
||||
|
||||
// 未登录/用户不存在状态
|
||||
// 渲染帖子项/用户不存在状态
|
||||
if (mode === 'self' && !currentUser) {
|
||||
return (
|
||||
<SafeAreaView style={sharedStyles.container} edges={hasHeader ? ['bottom'] : ['top', 'bottom']}>
|
||||
@@ -200,7 +252,21 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
||||
/>
|
||||
}
|
||||
>
|
||||
{renderListHeader()}
|
||||
<ProfileListHeader
|
||||
user={user}
|
||||
isCurrentUser={isCurrentUser}
|
||||
isBlocked={isBlocked}
|
||||
handleFollow={handleFollow}
|
||||
handleSettings={handleSettings}
|
||||
handleEditProfile={handleEditProfile}
|
||||
handleMessage={handleMessage}
|
||||
handleBlock={handleBlock}
|
||||
handleFollowingPress={handleFollowingPress}
|
||||
handleFollowersPress={handleFollowersPress}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
tabBarContainerStyle={sharedStyles.tabBarContainer}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
@@ -237,6 +303,15 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
||||
</View>
|
||||
</View>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* 图片查看器 */}
|
||||
<ImageGallery
|
||||
visible={showImageViewer}
|
||||
images={stableGalleryImages}
|
||||
initialIndex={selectedImageIndex}
|
||||
onClose={closeImageViewer}
|
||||
enableSave
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -248,7 +323,23 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
||||
data={currentPosts}
|
||||
renderItem={renderPostItem}
|
||||
keyExtractor={postKeyExtractor}
|
||||
ListHeaderComponent={renderListHeader}
|
||||
ListHeaderComponent={
|
||||
<ProfileListHeader
|
||||
user={user}
|
||||
isCurrentUser={isCurrentUser}
|
||||
isBlocked={isBlocked}
|
||||
handleFollow={handleFollow}
|
||||
handleSettings={handleSettings}
|
||||
handleEditProfile={handleEditProfile}
|
||||
handleMessage={handleMessage}
|
||||
handleBlock={handleBlock}
|
||||
handleFollowingPress={handleFollowingPress}
|
||||
handleFollowersPress={handleFollowersPress}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
tabBarContainerStyle={sharedStyles.tabBarContainer}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={renderEmptyPosts}
|
||||
contentContainerStyle={{ paddingBottom: scrollBottomInset }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -262,6 +353,15 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
||||
}
|
||||
drawDistance={250}
|
||||
/>
|
||||
|
||||
{/* 图片查看器 */}
|
||||
<ImageGallery
|
||||
visible={showImageViewer}
|
||||
images={stableGalleryImages}
|
||||
initialIndex={selectedImageIndex}
|
||||
onClose={closeImageViewer}
|
||||
enableSave
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ export const UserScreen: React.FC = () => {
|
||||
<UserProfileScreen
|
||||
mode={isSelfProfile ? 'self' : 'other'}
|
||||
userId={isSelfProfile ? undefined : userId}
|
||||
hasHeader={false}
|
||||
hasHeader
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user