Compare commits
70 Commits
cbe708b53b
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b589f1f32b | ||
|
|
995e0664d5 | ||
|
|
45df579b72 | ||
|
|
f9b0999112 | ||
|
|
d8386b5f76 | ||
|
|
be77a9d04c | ||
|
|
2bad59afbb | ||
|
|
761f315a60 | ||
|
|
488323377c | ||
|
|
705b365536 | ||
|
|
94a202506b | ||
|
|
c1f8acd4fc | ||
|
|
1018cd7ea2 | ||
|
|
ce0cb75248 | ||
|
|
9cba25da00 | ||
|
|
3c452cfbd3 | ||
|
|
0e79dbf655 | ||
|
|
a921aacefd | ||
|
|
96e8de18bf | ||
|
|
b2979311bb | ||
|
|
9b5e76b310 | ||
|
|
b05da4e4a6 | ||
|
|
641de98dc1 | ||
|
|
33a9c2fad1 | ||
|
|
2195fe2c2b | ||
|
|
03a735b6ac | ||
|
|
c06463f576 | ||
|
|
c46260e4c0 | ||
|
|
4c4d28f0e2 | ||
|
|
a31225dce2 | ||
|
|
b0b868593d | ||
|
|
ed06c10f25 | ||
|
|
ce56824c2e | ||
|
|
2ae5999940 | ||
|
|
edde8f1c30 | ||
|
|
1012337e57 | ||
|
|
f3d54a3f4c | ||
|
|
d8ef51fa13 | ||
|
|
97477c3471 | ||
|
|
1e05d2bd54 | ||
|
|
afbbee337d | ||
|
|
b15e0c0b0b | ||
|
|
5c81795d39 | ||
|
|
1f7e25349f | ||
|
|
49e94fc056 | ||
|
|
cabac94aae | ||
|
|
c6d65fe545 | ||
|
|
f79415591d | ||
|
|
3cc3695d0e | ||
|
|
765fd8cce9 | ||
|
|
2e2f6e3467 | ||
|
|
2e6912dddf | ||
|
|
d6a94c7b4d | ||
|
|
8ee6e77cb4 | ||
|
|
52d2581dda | ||
|
|
e7eae716be | ||
|
|
ad06881b85 | ||
|
|
f39288f401 | ||
|
|
70ab00795a | ||
|
|
72c30ed156 | ||
|
|
b86fe324f5 | ||
|
|
313820ed20 | ||
|
|
f4db4eb1ed | ||
|
|
7a17323e8d | ||
|
|
4fde3e403a | ||
|
|
fb67fb6d5b | ||
|
|
404b3fabe7 | ||
|
|
d8d2b03f94 | ||
|
|
ea2cf7bcff | ||
|
|
4308077232 |
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
|
||||
android
|
||||
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,32 +21,28 @@ 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
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- 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
|
||||
@@ -54,121 +50,48 @@ jobs:
|
||||
- name: Resolve runtime version
|
||||
id: runtime
|
||||
run: |
|
||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
|
||||
RUNTIME_VERSION="$(node -p "require('./app.config.js').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,18 +101,17 @@ 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
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v4
|
||||
@@ -201,183 +123,84 @@ 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 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
|
||||
cat > settings.gradle << 'SETTINGS_EOF'
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
def reactNativeGradlePlugin = new File(
|
||||
providers.exec {
|
||||
workingDir(rootDir)
|
||||
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
|
||||
}.standardOutput.asText.get().trim()
|
||||
).getParentFile().absolutePath
|
||||
includeBuild(reactNativeGradlePlugin)
|
||||
|
||||
def expoPluginsPath = new File(
|
||||
providers.exec {
|
||||
workingDir(rootDir)
|
||||
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
|
||||
}.standardOutput.asText.get().trim(),
|
||||
"../android/expo-gradle-plugin"
|
||||
).absolutePath
|
||||
includeBuild(expoPluginsPath)
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.facebook.react.settings")
|
||||
id("expo-autolinking-settings")
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
|
||||
}
|
||||
|
||||
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
||||
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
|
||||
ex.autolinkLibrariesFromCommand()
|
||||
} else {
|
||||
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
|
||||
}
|
||||
}
|
||||
expoAutolinking.useExpoModules()
|
||||
|
||||
rootProject.name = '威友'
|
||||
|
||||
expoAutolinking.useExpoVersionCatalog()
|
||||
|
||||
include ':app'
|
||||
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
||||
SETTINGS_EOF
|
||||
|
||||
# Update build.gradle with China Maven mirrors
|
||||
cat > build.gradle << 'BUILD_EOF'
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath('com.android.tools.build:gradle')
|
||||
classpath('com.facebook.react:react-native-gradle-plugin')
|
||||
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://www.jitpack.io' }
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: "expo-root-project"
|
||||
apply plugin: "com.facebook.react.rootproject"
|
||||
BUILD_EOF
|
||||
|
||||
# Update gradle.properties
|
||||
cat > gradle.properties << 'PROPS_EOF'
|
||||
org.gradle.daemon=false
|
||||
org.gradle.parallel=true
|
||||
org.gradle.configureondemand=true
|
||||
org.gradle.workers.max=4
|
||||
org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.enableJetifier=false
|
||||
android.useAndroidX=true
|
||||
hermesEnabled=true
|
||||
reactNativeArchitectures=arm64-v8a
|
||||
newArchEnabled=true
|
||||
edgeToEdgeEnabled=true
|
||||
expo.gif.enabled=true
|
||||
expo.webp.enabled=true
|
||||
expo.webp.animated=false
|
||||
expo.useLegacyPackaging=false
|
||||
PROPS_EOF
|
||||
|
||||
- name: Configure Gradle signing properties
|
||||
run: |
|
||||
cd android
|
||||
cat >> gradle.properties << SIGNING_PROPS
|
||||
MYAPP_UPLOAD_STORE_FILE=withyou-release-key.keystore
|
||||
MYAPP_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
MYAPP_UPLOAD_KEY_ALIAS=withyou-key
|
||||
MYAPP_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
SIGNING_PROPS
|
||||
# Decode Android signing keystore
|
||||
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > app/withyou-release-key.keystore
|
||||
|
||||
# Append signing properties to gradle.properties (secrets appended after prebuild for better cache hit rate).
|
||||
# IMPORTANT 1: each echo line writes a column-0 string — gradle.properties keys cannot have
|
||||
# leading whitespace, otherwise hasProperty() returns false and release signing breaks
|
||||
# (see app/build.gradle signingConfigs.release).
|
||||
# IMPORTANT 2: prebuild emits gradle.properties WITHOUT a trailing newline (last line is
|
||||
# `expo.inlineModules.watchedDirectories=[]`). A naive `>>` append concatenates the first
|
||||
# new key onto that last line, corrupting MYAPP_UPLOAD_STORE_FILE. We unconditionally
|
||||
# prepend a newline before the appended block to guarantee a clean line break.
|
||||
# NB: this block runs with `set -e`; if any echo fails the step aborts.
|
||||
{
|
||||
printf '\n'
|
||||
echo "MYAPP_UPLOAD_STORE_FILE=withyou-release-key.keystore"
|
||||
echo "MYAPP_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}"
|
||||
echo "MYAPP_UPLOAD_KEY_ALIAS=withyou-key"
|
||||
echo "MYAPP_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}"
|
||||
} >> gradle.properties
|
||||
|
||||
# CI build tuning (low-memory container): force single-worker, reduce JVM heap,
|
||||
# plus restore properties that prebuild does NOT generate but the build needs.
|
||||
# printf '\n' below is defensive — even though the signing block above already
|
||||
# ensured a trailing newline, both blocks should be independently safe.
|
||||
{
|
||||
printf '\n'
|
||||
echo "org.gradle.daemon=false"
|
||||
echo "org.gradle.parallel=false"
|
||||
echo "org.gradle.configureondemand=false"
|
||||
echo "org.gradle.workers.max=1"
|
||||
echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError"
|
||||
echo "kotlin.daemon.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m"
|
||||
echo "ndkVersion=27.1.12297006"
|
||||
echo "systemProp.org.gradle.internal.http.connectionTimeout=30000"
|
||||
echo "systemProp.org.gradle.internal.http.socketTimeout=30000"
|
||||
# ExpoAutolinkingPlugin requires this; without it Gradle calls
|
||||
# `node ... mirror-kotlin-inline-modules --watched-directories-serialized ''`
|
||||
# and node JSON.parse('') throws SyntaxError, exiting 1.
|
||||
# See ExpoAutolinkingPlugin.kt:51 (findProperty fallback to emptyList)
|
||||
# and ExpoAutolinkingPlugin.kt:67 (passes value to node as-is).
|
||||
echo "expo.inlineModules.watchedDirectories=[]"
|
||||
} >> gradle.properties
|
||||
|
||||
# Verify signing config in app/build.gradle
|
||||
echo "=== app/build.gradle signing section ==="
|
||||
grep -A 20 'signingConfigs' app/build.gradle || echo "signingConfigs not found"
|
||||
|
||||
# Diagnostic: dump full gradle.properties + verify all 4 signing keys are at column 0.
|
||||
# Each grep -c MUST print 1; 0 means the key was concatenated onto a previous line
|
||||
# (typically because prebuild's gradle.properties had no trailing newline — see fix above).
|
||||
echo "=== gradle.properties (final) ==="
|
||||
cat gradle.properties
|
||||
echo "=== signing keys column-0 check (each must be 1) ==="
|
||||
for k in MYAPP_UPLOAD_STORE_FILE MYAPP_UPLOAD_STORE_PASSWORD MYAPP_UPLOAD_KEY_ALIAS MYAPP_UPLOAD_KEY_PASSWORD; do
|
||||
count=$(grep -c "^${k}=" gradle.properties || true)
|
||||
echo "${k}=${count}"
|
||||
if [ "${count}" != "1" ]; then
|
||||
echo "FATAL: ${k} not found at column 0 — release signing will fail."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- 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
|
||||
@@ -388,7 +211,7 @@ jobs:
|
||||
- name: Resolve runtime version
|
||||
id: runtime
|
||||
run: |
|
||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
|
||||
RUNTIME_VERSION="$(node -p "require('./app.config.js').runtimeVersion")"
|
||||
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
||||
|
||||
@@ -463,8 +286,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,10 @@ const filteredPlugins = isWeb
|
||||
module.exports = {
|
||||
...expo,
|
||||
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
||||
runtimeVersion: {
|
||||
policy: 'appVersion',
|
||||
},
|
||||
// runtimeVersion 用语义版本(expo.version):同一版本号的所有 build 共享 OTA 通道,
|
||||
// 改原生代码/配置后应升级 version 来切换通道。
|
||||
// versionCode / buildNumber 仍用 commit count + 偏移,保证应用商店版本号单调递增。
|
||||
runtimeVersion: appJson.expo.version,
|
||||
updates: {
|
||||
...(expo.updates || {}),
|
||||
url: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
||||
@@ -71,12 +78,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 +102,8 @@ module.exports = {
|
||||
// 配置 Web 端别名,避免加载原生模块
|
||||
babel: {
|
||||
dangerouslyAddModulePathsToTranspile: [
|
||||
'react-native-webrtc',
|
||||
'livekit-client',
|
||||
'@livekit',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
32
app.json
32
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
|
||||
}
|
||||
],
|
||||
[
|
||||
@@ -152,7 +153,22 @@
|
||||
"appKey": "2cbe4da12e2c24aa3dca4610",
|
||||
"channel": "developer-default"
|
||||
}
|
||||
]
|
||||
],
|
||||
"./plugins/withHuaweiPush",
|
||||
"./plugins/withXiaomiPush",
|
||||
"./plugins/withHonorPush",
|
||||
"./plugins/withVivoPush",
|
||||
"./plugins/withOppoPush",
|
||||
"expo-callkit-telecom",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
],
|
||||
"expo-status-bar"
|
||||
],
|
||||
"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,7 @@ 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="help" />
|
||||
<Stack.Screen name="verification" />
|
||||
<Stack.Screen name="data-storage" />
|
||||
<Stack.Screen name="privacy-settings" />
|
||||
|
||||
5
app/(app)/(tabs)/profile/help.tsx
Normal file
5
app/(app)/(tabs)/profile/help.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { HelpScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function HelpRoute() {
|
||||
return <HelpScreen />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
145
app/_layout.tsx
145
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) => {
|
||||
@@ -152,6 +128,13 @@ function NotificationBootstrap() {
|
||||
console.log('[NotificationBootstrap] JPush notification:', message.notificationEventType, message);
|
||||
if (message.notificationEventType !== 'notificationOpened') return;
|
||||
|
||||
// 点击任意一条通知即清除通知栏所有通知(与 QQ 一致:点一条清全部)
|
||||
// JPush 的 notificationOpened 在通知被点击时触发,此时清除系统通知栏中
|
||||
// 本应用的所有通知,避免用户需要逐条点击消除。
|
||||
systemNotificationService.clearAllNotifications().catch((error) => {
|
||||
console.warn('[NotificationBootstrap] clearAllNotifications failed:', error);
|
||||
});
|
||||
|
||||
const extras = message.extras || {};
|
||||
const notifType = extras.notification_type || message.notificationType;
|
||||
|
||||
@@ -165,22 +148,24 @@ 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}` : ''}`);
|
||||
// 使用 navigate 而非 push,避免:
|
||||
// 1. 在已有聊天页上创建重复实例(用户需要退出两次)
|
||||
// 2. 从帖子等页面打开聊天通知后返回到帖子(而非消息列表)
|
||||
router.navigate(
|
||||
hrefs.hrefChat({
|
||||
conversationId,
|
||||
userId: isGroup ? undefined : senderId,
|
||||
isGroupChat: isGroup,
|
||||
groupId: isGroup ? groupId : undefined,
|
||||
groupName: isGroup ? groupName : undefined,
|
||||
groupAvatar: isGroup ? groupAvatar : undefined,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
router.push('/messages/notifications');
|
||||
router.navigate(hrefs.hrefNotifications());
|
||||
}
|
||||
} else {
|
||||
router.push('/messages/notifications');
|
||||
router.navigate(hrefs.hrefNotifications());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -232,6 +217,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 +257,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"
|
||||
}
|
||||
46
jest.config.js
Normal file
46
jest.config.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Jest 配置
|
||||
*
|
||||
* 仅对消息状态核心逻辑(store 幂等性 / 实时事件管道 / 未读计数原子性)做单测,
|
||||
* 不覆盖 RN / Expo UI。所有平台依赖(@/services/core、@/database、@/core/events、
|
||||
* @/stores/auth、@/services/message)在测试中通过 moduleNameMapper 映射到 mock。
|
||||
*/
|
||||
const { pathsToModuleNameMapper } = require('ts-jest');
|
||||
|
||||
const tsconfig = require('./tsconfig.json');
|
||||
const tsconfigBase = require('expo/tsconfig.base.json');
|
||||
|
||||
// 合并 base + 项目 paths
|
||||
const paths = {
|
||||
...(tsconfigBase.compilerOptions.paths || {}),
|
||||
...(tsconfig.compilerOptions.paths || {}),
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
moduleNameMapper: {
|
||||
// 平台依赖统一映射到 mock(必须放在 @/* 通配之前,否则会被通配吞掉)
|
||||
'^@/services/core$': '<rootDir>/src/stores/message/__tests__/mocks/coreMock.ts',
|
||||
'^@/services/message$': '<rootDir>/src/stores/message/__tests__/mocks/messageServiceMock.ts',
|
||||
'^@/database$': '<rootDir>/src/stores/message/__tests__/mocks/databaseMock.ts',
|
||||
'^@/core/events/EventBus$': '<rootDir>/src/stores/message/__tests__/mocks/eventBusMock.ts',
|
||||
'^@/stores/auth/authStore$': '<rootDir>/src/stores/message/__tests__/mocks/authStoreMock.ts',
|
||||
'^@react-native-async-storage/async-storage$':
|
||||
'<rootDir>/src/stores/message/__tests__/mocks/asyncStorageMock.ts',
|
||||
// @/* 路径别名 → 真实 src(通配兜底,放最后)
|
||||
...pathsToModuleNameMapper(paths, { prefix: '<rootDir>/' }),
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: '<rootDir>/src/stores/message/__tests__/tsconfig.json',
|
||||
isolatedModules: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
clearMocks: true,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
7462
package-lock.json
generated
7462
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
131
package.json
131
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",
|
||||
@@ -11,68 +11,91 @@
|
||||
"ios:simulator": "eas build --platform ios --profile ios-simulator",
|
||||
"ios:preview": "eas build --platform ios --profile preview",
|
||||
"ios:prod": "eas build --platform ios --profile production",
|
||||
"ios:submit": "eas submit --platform ios --profile production"
|
||||
"ios:submit": "eas submit --platform ios --profile production",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/ui": "~56.0.18",
|
||||
"@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.12",
|
||||
"expo-background-task": "~56.0.19",
|
||||
"expo-callkit-telecom": "^0.3.9",
|
||||
"expo-camera": "~56.0.8",
|
||||
"expo-constants": "~56.0.18",
|
||||
"expo-dev-client": "~56.0.20",
|
||||
"expo-document-picker": "~56.0.4",
|
||||
"expo-file-system": "~56.0.8",
|
||||
"expo-font": "~56.0.7",
|
||||
"expo-haptics": "~56.0.3",
|
||||
"expo-image": "~56.0.6",
|
||||
"expo-image-picker": "~56.0.18",
|
||||
"expo-intent-launcher": "~56.0.4",
|
||||
"expo-linear-gradient": "~56.0.4",
|
||||
"expo-linking": "~56.0.14",
|
||||
"expo-media-library": "~56.0.7",
|
||||
"expo-notifications": "~56.0.18",
|
||||
"expo-router": "~56.2.11",
|
||||
"expo-splash-screen": "~56.0.10",
|
||||
"expo-sqlite": "~56.0.5",
|
||||
"expo-status-bar": "~56.0.4",
|
||||
"expo-system-ui": "~56.0.5",
|
||||
"expo-task-manager": "~56.0.19",
|
||||
"expo-updates": "~56.0.19",
|
||||
"expo-video": "~56.1.4",
|
||||
"jcore-react-native": "^2.3.6",
|
||||
"jpush-react-native": "^3.2.7",
|
||||
"katex": "^0.17.0",
|
||||
"livekit-client": "^2.19.2",
|
||||
"markdown-it": "^14.2.0",
|
||||
"pako": "^2.1.0",
|
||||
"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/jest": "^29.5.12",
|
||||
"@types/pako": "^2.0.4",
|
||||
"@types/react": "~19.2.16",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"typescript": "~5.9.2"
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.2",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"private": true
|
||||
"private": true,
|
||||
"expo": {
|
||||
"install": {
|
||||
"exclude": [
|
||||
"expo-image",
|
||||
"@react-native-async-storage/async-storage",
|
||||
"@shopify/flash-list",
|
||||
"react-native-pager-view",
|
||||
"react-native-gesture-handler",
|
||||
"react-native-safe-area-context"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
1
plugins/agconnect-services.json
Normal file
1
plugins/agconnect-services.json
Normal file
@@ -0,0 +1 @@
|
||||
{"agcgw":{"url":"connect-drcn.dbankcloud.cn","backurl":"connect-drcn.hispace.hicloud.com","websocketurl":"connect-ws-drcn.hispace.dbankcloud.cn","websocketbackurl":"connect-ws-drcn.hispace.dbankcloud.com"},"agcgw_all":{"SG":"connect-dra.dbankcloud.cn","SG_back":"connect-dra.hispace.hicloud.com","CN":"connect-drcn.dbankcloud.cn","CN_back":"connect-drcn.hispace.hicloud.com","RU":"connect-drru.hispace.dbankcloud.ru","RU_back":"connect-drru.hispace.dbankcloud.cn","DE":"connect-dre.dbankcloud.cn","DE_back":"connect-dre.hispace.hicloud.com"},"websocketgw_all":{"SG":"connect-ws-dra.hispace.dbankcloud.cn","SG_back":"connect-ws-dra.hispace.dbankcloud.com","CN":"connect-ws-drcn.hispace.dbankcloud.cn","CN_back":"connect-ws-drcn.hispace.dbankcloud.com","RU":"connect-ws-drru.hispace.dbankcloud.ru","RU_back":"connect-ws-drru.hispace.dbankcloud.cn","DE":"connect-ws-dre.hispace.dbankcloud.cn","DE_back":"connect-ws-dre.hispace.dbankcloud.com"},"client":{"cp_id":"30086000618737115","product_id":"101653523864112478","client_id":"1949443370896080512","client_secret":"CA50EF8C69075FAEFECF2D9F0F3ED101DC3264E0BFE1CC5E5B0272E7FF8215A1","project_id":"101653523864112478","app_id":"117704941","api_key":"DgEDAIkxmMyXBT6vYYW8ZWWXBMJweDbdmmKLYo5MvgH2lV8jPSIZt5WVpjd0/nZ0dk9gAwHf+dWIySILr3QQd7W80H8NNvuCUAQPig==","package_name":"cn.qczlit.withyou"},"oauth_client":{"client_id":"117704941","client_type":1},"app_info":{"app_id":"117704941","package_name":"cn.qczlit.withyou"},"service":{"analytics":{"collector_url":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn","collector_url_cn":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn","collector_url_de":"datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn","collector_url_ru":"datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com","collector_url_sg":"datacollector-dra.dt.hicloud.com,datacollector-dra.dt.dbankcloud.cn","resource_id":"p1","channel_id":""},"ml":{"mlservice_url":"ml-api-drcn.ai.dbankcloud.com,ml-api-drcn.ai.dbankcloud.cn"},"cloudstorage":{"storage_url":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg":"https://ops-dra.agcstorage.link","storage_url_de":"https://ops-dre.agcstorage.link","storage_url_cn":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru_back":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg_back":"https://agc-storage-dra.cloud.huawei.asia","storage_url_de_back":"https://agc-storage-dre.cloud.huawei.eu","storage_url_cn_back":"https://agc-storage-drcn.cloud.huawei.com.cn"},"search":{"url":"https://search-drcn.cloud.huawei.com"},"edukit":{"edu_url":"edukit.cloud.huawei.com.cn","dh_url":"edukit.cloud.huawei.com.cn"}},"region":"CN","configuration_version":"3.0","appInfos":[{"package_name":"cn.qczlit.withyou","client":{"app_id":"117704941"},"oauth_client":{"client_id":"117704941","client_type":1},"app_info":{"app_id":"117704941","package_name":"cn.qczlit.withyou"}}]}
|
||||
40
plugins/withCmakeJobLimit.js
Normal file
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;
|
||||
233
plugins/withHonorPush.js
Normal file
233
plugins/withHonorPush.js
Normal file
@@ -0,0 +1,233 @@
|
||||
const { withDangerousMod } = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 荣耀厂商推送通道的 Expo config plugin.
|
||||
//
|
||||
// 客户端职责(依据极光官方文档 https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide):
|
||||
// 1) 添加 cn.jiguang.sdk.plugin:honor 依赖
|
||||
// 2) 注入 manifestPlaceholders: HONOR_APPID
|
||||
// 3) 添加荣耀 Maven 仓库 https://developer.hihonor.com/repo 到 settings.gradle 和根 build.gradle
|
||||
// 关键: 必须同时加到 settings.gradle 的 dependencyResolutionManagement 块(CI build 走这条路径),
|
||||
// 和根 build.gradle 的 buildscript+allprojects(本地 build 兜底)。
|
||||
// 4) 添加 Proguard 规则 (com.hihonor.push.** 保留 + 5 条 -keepattributes)
|
||||
//
|
||||
// 实现说明:所有文件改动统一用 withDangerousMod 直接读写磁盘。
|
||||
// 原因:Expo SDK 56+ 的 withSettingsGradle / withProjectBuildGradle / withAppBuildGradle hook 链中,
|
||||
// 第二个以后的 plugin 写回 modResults.contents 会被静默忽略。withDangerousMod 走文件系统串行执行,
|
||||
// 多 vendor plugin 共存可靠。
|
||||
const withHonorPush = (config, options = {}) => {
|
||||
const {
|
||||
jpushVersion = '6.1.0',
|
||||
appId = '104562917',
|
||||
} = options;
|
||||
|
||||
const honorRepoUrl = 'https://developer.hihonor.com/repo';
|
||||
|
||||
config = withDangerousMod(config, [
|
||||
'android',
|
||||
async (config) => {
|
||||
const settingsGradlePath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
'settings.gradle'
|
||||
);
|
||||
const rootBuildGradlePath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
'build.gradle'
|
||||
);
|
||||
const appBuildGradlePath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
'app',
|
||||
'build.gradle'
|
||||
);
|
||||
const proguardFilePath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
'app',
|
||||
'proguard-rules.pro'
|
||||
);
|
||||
|
||||
// --- 1. settings.gradle: pluginManagement + dependencyResolutionManagement ---
|
||||
if (fs.existsSync(settingsGradlePath)) {
|
||||
let settings = fs.readFileSync(settingsGradlePath, 'utf-8');
|
||||
let settingsChanged = false;
|
||||
const repoLine = ` maven { url '${honorRepoUrl}' }`;
|
||||
|
||||
// pluginManagement.repositories
|
||||
if (!settings.match(/pluginManagement\s*\{[\s\S]*?developer\.hihonor\.com\/repo/)) {
|
||||
const hasRepositoriesInPM = /pluginManagement\s*\{[\s\S]*?repositories\s*\{/.test(settings);
|
||||
if (hasRepositoriesInPM) {
|
||||
settings = settings.replace(
|
||||
/(pluginManagement\s*\{[\s\S]*?repositories\s*\{)/,
|
||||
`$1\n ${repoLine}`
|
||||
);
|
||||
settingsChanged = true;
|
||||
} else if (settings.includes('pluginManagement')) {
|
||||
settings = settings.replace(
|
||||
/(pluginManagement\s*\{)/,
|
||||
`$1\n repositories {\n gradlePluginPortal()\n google()\n mavenCentral()\n ${repoLine}\n }`
|
||||
);
|
||||
settingsChanged = true;
|
||||
} else {
|
||||
settings =
|
||||
`pluginManagement {\n repositories {\n ${repoLine}\n }\n}\n\n` + settings;
|
||||
settingsChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// dependencyResolutionManagement (Gradle 7+, takes precedence over allprojects in CI)
|
||||
if (!settings.match(/dependencyResolutionManagement[\s\S]*developer\.hihonor\.com\/repo/)) {
|
||||
const depMgmtPattern = /dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{/;
|
||||
if (depMgmtPattern.test(settings)) {
|
||||
settings = settings.replace(
|
||||
depMgmtPattern,
|
||||
`$&\n maven { url '${honorRepoUrl}' }`
|
||||
);
|
||||
settingsChanged = true;
|
||||
} else {
|
||||
// Block doesn't exist — fallback: create with Huawei + Honor + standard repos.
|
||||
const block = `\ndependencyResolutionManagement {\n repositories {\n maven { url 'https://developer.huawei.com/repo/' }\n maven { url '${honorRepoUrl}' }\n google()\n mavenCentral()\n maven { url 'https://www.jitpack.io' }\n }\n}\n`;
|
||||
const pluginsBlockEnd = /(\nplugins\s*\{[\s\S]*?\n\})/;
|
||||
if (pluginsBlockEnd.test(settings)) {
|
||||
settings = settings.replace(pluginsBlockEnd, `$1\n${block}`);
|
||||
} else {
|
||||
settings = settings + block;
|
||||
}
|
||||
settingsChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (settingsChanged) {
|
||||
fs.writeFileSync(settingsGradlePath, settings);
|
||||
console.log('[withHonorPush] updated settings.gradle (pluginManagement + dependencyResolutionManagement)');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. 根 build.gradle: buildscript + allprojects repositories ---
|
||||
if (fs.existsSync(rootBuildGradlePath)) {
|
||||
let gradle = fs.readFileSync(rootBuildGradlePath, 'utf-8');
|
||||
let changed = false;
|
||||
const repoLine = ` maven { url '${honorRepoUrl}' }`;
|
||||
|
||||
if (!gradle.match(/buildscript\s*\{[\s\S]*?developer\.hihonor\.com\/repo/)) {
|
||||
const bsRepoPattern = /(buildscript\s*\{[\s\S]*?repositories\s*\{)/;
|
||||
if (bsRepoPattern.test(gradle)) {
|
||||
gradle = gradle.replace(bsRepoPattern, `$1\n${repoLine}`);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!gradle.match(/allprojects\s*\{[\s\S]*?developer\.hihonor\.com\/repo/)) {
|
||||
const allProjectsPattern = /(allprojects\s*\{[\s\S]*?repositories\s*\{)/;
|
||||
if (allProjectsPattern.test(gradle)) {
|
||||
gradle = gradle.replace(allProjectsPattern, `$1\n${repoLine}`);
|
||||
changed = true;
|
||||
} else {
|
||||
const allProjectsBlock = `\nallprojects {\n repositories {${repoLine}\n }\n}\n`;
|
||||
gradle = gradle + allProjectsBlock;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(rootBuildGradlePath, gradle);
|
||||
console.log('[withHonorPush] updated root build.gradle (buildscript + allprojects repos)');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. app/build.gradle: AAR 依赖 + manifestPlaceholders ---
|
||||
if (fs.existsSync(appBuildGradlePath)) {
|
||||
let gradle = fs.readFileSync(appBuildGradlePath, 'utf-8');
|
||||
let changed = false;
|
||||
|
||||
if (!gradle.includes('cn.jiguang.sdk.plugin:honor')) {
|
||||
const depsPattern = /dependencies\s*\{/;
|
||||
if (depsPattern.test(gradle)) {
|
||||
gradle = gradle.replace(
|
||||
depsPattern,
|
||||
`dependencies {\n implementation 'cn.jiguang.sdk.plugin:honor:${jpushVersion}'`
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const manifestPlaceholderRegex = /manifestPlaceholders\s*=\s*\[([\s\S]*?)\]/;
|
||||
const match = gradle.match(manifestPlaceholderRegex);
|
||||
|
||||
const requiredPlaceholders = {
|
||||
HONOR_APPID: appId,
|
||||
};
|
||||
|
||||
if (match) {
|
||||
const existingBlock = match[1];
|
||||
const existingEntries = {};
|
||||
const entryRegex = /(\w+)\s*:\s*"([^"]*)"/g;
|
||||
let entryMatch;
|
||||
while ((entryMatch = entryRegex.exec(existingBlock)) !== null) {
|
||||
existingEntries[entryMatch[1]] = entryMatch[2];
|
||||
}
|
||||
|
||||
let placeholdersChanged = false;
|
||||
for (const [key, value] of Object.entries(requiredPlaceholders)) {
|
||||
if (existingEntries[key] !== value) {
|
||||
existingEntries[key] = value;
|
||||
placeholdersChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (placeholdersChanged) {
|
||||
const entriesStr = Object.entries(existingEntries)
|
||||
.map(([k, v]) => `${k}: "${v}"`)
|
||||
.join(',\n ');
|
||||
const newBlock = `\n manifestPlaceholders = [\n ${entriesStr}\n ]`;
|
||||
gradle = gradle.replace(manifestPlaceholderRegex, newBlock.trim());
|
||||
changed = true;
|
||||
}
|
||||
} else if (gradle.includes('REACT_NATIVE_RELEASE_LEVEL')) {
|
||||
const entriesStr = Object.entries(requiredPlaceholders)
|
||||
.map(([k, v]) => `${k}: "${v}"`)
|
||||
.join(',\n ');
|
||||
const block = `\n manifestPlaceholders = [\n ${entriesStr}\n ]\n`;
|
||||
const lines = gradle.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('REACT_NATIVE_RELEASE_LEVEL')) {
|
||||
lines.splice(i + 1, 0, block);
|
||||
break;
|
||||
}
|
||||
}
|
||||
gradle = lines.join('\n');
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(appBuildGradlePath, gradle);
|
||||
console.log('[withHonorPush] updated app/build.gradle (AAR + manifestPlaceholders)');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. proguard-rules.pro ---
|
||||
if (fs.existsSync(proguardFilePath)) {
|
||||
let proguard = fs.readFileSync(proguardFilePath, 'utf-8');
|
||||
if (!proguard.includes('-keep class com.hihonor.push.**')) {
|
||||
const honorRules = `
|
||||
# JPush Honor vendor channel — keep rules (https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide)
|
||||
-ignorewarnings
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes Exceptions
|
||||
-keepattributes InnerClasses
|
||||
-keepattributes Signature
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-keep class com.hihonor.push.**{*;}
|
||||
`;
|
||||
fs.writeFileSync(proguardFilePath, proguard + honorRules);
|
||||
console.log('[withHonorPush] appended Honor Proguard rules to proguard-rules.pro');
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
module.exports = withHonorPush;
|
||||
295
plugins/withHuaweiPush.js
Normal file
295
plugins/withHuaweiPush.js
Normal file
@@ -0,0 +1,295 @@
|
||||
const {
|
||||
withAppBuildGradle,
|
||||
withProjectBuildGradle,
|
||||
withSettingsGradle,
|
||||
withDangerousMod,
|
||||
withAndroidManifest,
|
||||
} = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const withHuaweiPush = (config, options = {}) => {
|
||||
const { jpushVersion = '6.1.0' } = options;
|
||||
|
||||
config = withSettingsGradle(config, (config) => {
|
||||
const contents = config.modResults.contents;
|
||||
|
||||
// Add Huawei Maven repo to pluginManagement.repositories for buildscript resolution.
|
||||
// NOTE: We intentionally do NOT add `plugins { id 'com.huawei.agconnect' ... }` here.
|
||||
// The Huawei agcp plugin transitively depends on agconnect-apms-plugin:1.6.2.300,
|
||||
// which depends on AGP 4.0.1. That old AGP references BuildCompletionListener (removed
|
||||
// from Gradle 8.x+), causing NoClassDefFoundError at build time. Instead, the plugin
|
||||
// is applied via buildscript classpath in build.gradle with the apms-plugin excluded.
|
||||
if (!contents.includes('developer.huawei.com/repo')) {
|
||||
const repoLine = ` maven { url 'https://developer.huawei.com/repo/' }`;
|
||||
const hasPluginManagement = contents.includes('pluginManagement');
|
||||
const hasRepositoriesInPM = /pluginManagement\s*\{[\s\S]*?repositories\s*\{/.test(contents);
|
||||
|
||||
if (hasRepositoriesInPM) {
|
||||
config.modResults.contents = contents.replace(
|
||||
/(pluginManagement\s*\{[\s\S]*?repositories\s*\{)/,
|
||||
`$1\n ${repoLine}`
|
||||
);
|
||||
} else if (hasPluginManagement) {
|
||||
config.modResults.contents = contents.replace(
|
||||
/(pluginManagement\s*\{)/,
|
||||
`$1\n repositories {\n gradlePluginPortal()\n google()\n mavenCentral()\n ${repoLine}\n }`
|
||||
);
|
||||
} else {
|
||||
config.modResults.contents =
|
||||
`pluginManagement {\n repositories {\n ${repoLine}\n }\n}\n\n` +
|
||||
config.modResults.contents;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure Huawei Maven repo in dependencyResolutionManagement (Gradle 7+).
|
||||
// CI build paths require this block (Gradle dependencyResolutionManagement
|
||||
// takes precedence over allprojects.repositories when configured).
|
||||
// Create the block if it doesn't exist, otherwise inject Huawei into it.
|
||||
if (!config.modResults.contents.match(/dependencyResolutionManagement[\s\S]*developer\.huawei\.com\/repo/)) {
|
||||
const depMgmtPattern = /dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{/;
|
||||
if (depMgmtPattern.test(config.modResults.contents)) {
|
||||
// Block exists — append Huawei to its repositories
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
depMgmtPattern,
|
||||
`$&\n maven { url 'https://developer.huawei.com/repo/' }`
|
||||
);
|
||||
} else {
|
||||
// Block doesn't exist — create it after the `plugins { ... }` block
|
||||
// (or after pluginManagement if no plugins block) so it sits where
|
||||
// Gradle expects it.
|
||||
const block = `\ndependencyResolutionManagement {\n repositories {\n maven { url 'https://developer.huawei.com/repo/' }\n google()\n mavenCentral()\n maven { url 'https://www.jitpack.io' }\n }\n}\n`;
|
||||
const pluginsBlockEnd = /(\nplugins\s*\{[\s\S]*?\n\})/;
|
||||
if (pluginsBlockEnd.test(config.modResults.contents)) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
pluginsBlockEnd,
|
||||
`$1\n${block}`
|
||||
);
|
||||
} else {
|
||||
// Fallback: append to end
|
||||
config.modResults.contents = config.modResults.contents + block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
config = withProjectBuildGradle(config, (config) => {
|
||||
const contents = config.modResults.contents;
|
||||
|
||||
if (!contents.includes('developer.huawei.com/repo')) {
|
||||
config.modResults.contents = contents.replace(
|
||||
/(repositories\s*\{)/g,
|
||||
(match) => {
|
||||
return match + `\n maven { url 'https://developer.huawei.com/repo/' }`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
config = withProjectBuildGradle(config, (config) => {
|
||||
const contents = config.modResults.contents;
|
||||
|
||||
// Add Huawei AGConnect classpath with apms-plugin excluded.
|
||||
// The apms-plugin transitively depends on AGP 4.0.1 which is incompatible
|
||||
// with Gradle 8.14+ (references removed BuildCompletionListener class).
|
||||
if (!contents.includes('com.huawei.agconnect:agcp')) {
|
||||
if (contents.includes('buildscript')) {
|
||||
const depsPattern = /(buildscript\s*\{[\s\S]*?dependencies\s*\{)/;
|
||||
if (depsPattern.test(contents)) {
|
||||
config.modResults.contents = contents.replace(
|
||||
depsPattern,
|
||||
`$1\n classpath('com.huawei.agconnect:agcp:1.9.1.301') {\n exclude group: 'com.huawei.agconnect', module: 'agconnect-apms-plugin'\n }`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set explicit AGP version (needed by Huawei plugin validation)
|
||||
if (!contents.includes('com.android.tools.build:gradle:8.12.0')) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
/classpath\(['"]com\.android\.tools\.build:gradle['"]\)/,
|
||||
"classpath('com.android.tools.build:gradle:8.12.0')"
|
||||
);
|
||||
}
|
||||
|
||||
// Google Services plugin classpath — required for Firebase init via google-services.json.
|
||||
if (!contents.includes('com.google.gms:google-services')) {
|
||||
const depsPattern = /(buildscript\s*\{[\s\S]*?dependencies\s*\{[\s\S]*?classpath\(['"]org\.jetbrains\.kotlin:kotlin-gradle-plugin['"]\))/;
|
||||
if (depsPattern.test(config.modResults.contents)) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
depsPattern,
|
||||
`$1\n classpath('com.google.gms:google-services:4.4.4')`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.modResults.contents.includes('developer.huawei.com/repo')) {
|
||||
const bsRepoPattern = /(buildscript\s*\{[\s\S]*?repositories\s*\{)/;
|
||||
if (bsRepoPattern.test(config.modResults.contents)) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
bsRepoPattern,
|
||||
`$1\n maven { url 'https://developer.huawei.com/repo/' }`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
config = withAppBuildGradle(config, (config) => {
|
||||
const contents = config.modResults.contents;
|
||||
|
||||
// Apply Google Services plugin (Firebase) before the Huawei plugin.
|
||||
// The googleServicesFile in app.json only sets up the file copy + plugin
|
||||
// when run through `expo prebuild`, but it does not coexist with this
|
||||
// Huawei plugin reliably, so we apply it explicitly here.
|
||||
if (!contents.includes('com.google.gms.google-services')) {
|
||||
const reactApply = /apply plugin:\s*['"]com\.facebook\.react['"]\s*\n/;
|
||||
if (reactApply.test(contents)) {
|
||||
config.modResults.contents = contents.replace(
|
||||
reactApply,
|
||||
`apply plugin: "com.facebook.react"\napply plugin: 'com.google.gms.google-services'\n`
|
||||
);
|
||||
} else {
|
||||
config.modResults.contents = `apply plugin: 'com.google.gms.google-services'\n` + contents;
|
||||
}
|
||||
}
|
||||
|
||||
if (!contents.includes('com.huawei.agconnect')) {
|
||||
const applyPluginPattern = /apply plugin:\s*['"]com\.google\.gms\.google-services['"]/;
|
||||
if (applyPluginPattern.test(contents)) {
|
||||
config.modResults.contents = contents.replace(
|
||||
applyPluginPattern,
|
||||
`apply plugin: 'com.google.gms.google-services'\napply plugin: 'com.huawei.agconnect'`
|
||||
);
|
||||
} else {
|
||||
const lastApplyPlugin = /apply plugin:[^\n]*(?:\n)/g;
|
||||
const matches = contents.match(lastApplyPlugin);
|
||||
if (matches && matches.length > 0) {
|
||||
const lastMatch = matches[matches.length - 1];
|
||||
const lastIndex = contents.lastIndexOf(lastMatch);
|
||||
config.modResults.contents =
|
||||
contents.slice(0, lastIndex + lastMatch.length) +
|
||||
"apply plugin: 'com.huawei.agconnect'\n" +
|
||||
contents.slice(lastIndex + lastMatch.length);
|
||||
} else {
|
||||
config.modResults.contents = contents + "\napply plugin: 'com.huawei.agconnect'\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!contents.includes('cn.jiguang.sdk.plugin:huawei')) {
|
||||
const depsPattern = /dependencies\s*\{/;
|
||||
if (depsPattern.test(config.modResults.contents)) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
depsPattern,
|
||||
`dependencies {\n implementation 'cn.jiguang.sdk.plugin:huawei:${jpushVersion}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
config = withDangerousMod(config, [
|
||||
'android',
|
||||
async (config) => {
|
||||
// Ensure Huawei Maven repo is in allprojects.repositories (not just buildscript).
|
||||
// Runtime dependencies like com.huawei.hms:push must be resolved via allprojects,
|
||||
// and the earlier withProjectBuildGradle hook may be silently dropped in Expo SDK 56+.
|
||||
const rootBuildGradlePath = path.join(config.modRequest.platformProjectRoot, 'build.gradle');
|
||||
if (fs.existsSync(rootBuildGradlePath)) {
|
||||
let rootGradle = fs.readFileSync(rootBuildGradlePath, 'utf-8');
|
||||
const huaweiRepoLine = ` maven { url 'https://developer.huawei.com/repo/' }`;
|
||||
let rootChanged = false;
|
||||
|
||||
// buildscript.repositories
|
||||
if (!rootGradle.match(/buildscript\s*\{[\s\S]*?developer\.huawei\.com\/repo/)) {
|
||||
const bsPattern = /(buildscript\s*\{[\s\S]*?repositories\s*\{)/;
|
||||
if (bsPattern.test(rootGradle)) {
|
||||
rootGradle = rootGradle.replace(bsPattern, `$1\n${huaweiRepoLine}`);
|
||||
rootChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// allprojects.repositories (runtime deps resolution)
|
||||
if (!rootGradle.match(/allprojects\s*\{[\s\S]*?developer\.huawei\.com\/repo/)) {
|
||||
const allProjectsPattern = /(allprojects\s*\{[\s\S]*?repositories\s*\{)/;
|
||||
if (allProjectsPattern.test(rootGradle)) {
|
||||
rootGradle = rootGradle.replace(allProjectsPattern, `$1\n${huaweiRepoLine}`);
|
||||
rootChanged = true;
|
||||
} else {
|
||||
const allProjectsBlock = `\nallprojects {\n repositories {${huaweiRepoLine}\n }\n}\n`;
|
||||
rootGradle = rootGradle + allProjectsBlock;
|
||||
rootChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (rootChanged) {
|
||||
fs.writeFileSync(rootBuildGradlePath, rootGradle);
|
||||
console.log('[withHuaweiPush] ensured Huawei Maven repo in buildscript + allprojects');
|
||||
}
|
||||
}
|
||||
|
||||
const appDir = path.join(config.modRequest.platformProjectRoot, 'app');
|
||||
const srcFile = path.join(config.modRequest.projectRoot, 'plugins', 'agconnect-services.json');
|
||||
const destFile = path.join(appDir, 'agconnect-services.json');
|
||||
|
||||
if (fs.existsSync(srcFile)) {
|
||||
fs.copyFileSync(srcFile, destFile);
|
||||
console.log('[withHuaweiPush] copied agconnect-services.json to android/app/');
|
||||
} else {
|
||||
console.warn('[withHuaweiPush] agconnect-services.json not found at project root');
|
||||
}
|
||||
|
||||
// Copy google-services.json to android/app/ so the Google Services
|
||||
// Gradle plugin can find it. Without this, FCM / Firebase init fails
|
||||
// at runtime with "default FirebaseApp is not initialized".
|
||||
const gmsSrc = path.join(config.modRequest.projectRoot, 'google-services.json');
|
||||
const gmsDest = path.join(appDir, 'google-services.json');
|
||||
if (fs.existsSync(gmsSrc)) {
|
||||
fs.copyFileSync(gmsSrc, gmsDest);
|
||||
console.log('[withHuaweiPush] copied google-services.json to android/app/');
|
||||
} else {
|
||||
console.warn('[withHuaweiPush] google-services.json not found at project root');
|
||||
}
|
||||
|
||||
// RN 0.85.3 ships Gradle 9.3.1 in its template, but Kotlin 2.1.20 is incompatible
|
||||
// with Gradle 9.x (CompilerEnvironment ClassNotFoundException). Downgrade to 8.14.5.
|
||||
const wrapperFile = path.join(config.modRequest.platformProjectRoot, 'gradle', 'wrapper', 'gradle-wrapper.properties');
|
||||
if (fs.existsSync(wrapperFile)) {
|
||||
let wrapper = fs.readFileSync(wrapperFile, 'utf8');
|
||||
if (wrapper.includes('gradle-9.')) {
|
||||
wrapper = wrapper.replace(/gradle-[\d.]+-bin\.zip/, 'gradle-8.14.5-bin.zip');
|
||||
fs.writeFileSync(wrapperFile, wrapper);
|
||||
console.log('[withHuaweiPush] downgraded Gradle to 8.14.5 for Kotlin 2.1.20 compatibility');
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure local.properties with SDK path exists (gets cleared on prebuild --clean)
|
||||
const localPropsFile = path.join(config.modRequest.platformProjectRoot, 'local.properties');
|
||||
if (!fs.existsSync(localPropsFile)) {
|
||||
const homeDir = process.env.USERPROFILE || process.env.HOME || '';
|
||||
const sdkPath = path.join(homeDir, 'AppData', 'Local', 'Android', 'Sdk');
|
||||
if (fs.existsSync(sdkPath)) {
|
||||
fs.writeFileSync(localPropsFile, `sdk.dir=${sdkPath.replace(/\\/g, '\\\\')}\n`);
|
||||
console.log('[withHuaweiPush] created local.properties with SDK path');
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
|
||||
config = withAndroidManifest(config, (config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
module.exports = withHuaweiPush;
|
||||
@@ -17,19 +17,40 @@ const withJPush = (config, options = {}) => {
|
||||
|
||||
// 1. Android: Inject manifestPlaceholders into app/build.gradle
|
||||
config = withAppBuildGradle(config, (config) => {
|
||||
const block = '\n manifestPlaceholders = [\n' +
|
||||
' JPUSH_APPKEY: "' + appKey + '",\n' +
|
||||
' JPUSH_CHANNEL: "' + channel + '"\n' +
|
||||
' ]';
|
||||
|
||||
const contents = config.modResults.contents;
|
||||
|
||||
if (contents.includes('manifestPlaceholders')) {
|
||||
config.modResults.contents = contents.replace(
|
||||
/manifestPlaceholders\s*=\s*\[[\s\S]*?\]/,
|
||||
block.trim()
|
||||
);
|
||||
const requiredPlaceholders = {
|
||||
JPUSH_APPKEY: appKey,
|
||||
JPUSH_CHANNEL: channel,
|
||||
};
|
||||
|
||||
const manifestPlaceholderRegex = /manifestPlaceholders\s*=\s*\[([\s\S]*?)\]/;
|
||||
const match = contents.match(manifestPlaceholderRegex);
|
||||
|
||||
if (match) {
|
||||
const existingBlock = match[1];
|
||||
const existingEntries = {};
|
||||
const entryRegex = /(\w+)\s*:\s*"([^"]*)"/g;
|
||||
let entryMatch;
|
||||
while ((entryMatch = entryRegex.exec(existingBlock)) !== null) {
|
||||
existingEntries[entryMatch[1]] = entryMatch[2];
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(requiredPlaceholders)) {
|
||||
existingEntries[key] = value;
|
||||
}
|
||||
|
||||
const entriesStr = Object.entries(existingEntries)
|
||||
.map(([k, v]) => `${k}: "${v}"`)
|
||||
.join(',\n ');
|
||||
const newBlock = `\n manifestPlaceholders = [\n ${entriesStr}\n ]`;
|
||||
|
||||
config.modResults.contents = contents.replace(manifestPlaceholderRegex, newBlock.trim());
|
||||
} else if (contents.includes('REACT_NATIVE_RELEASE_LEVEL')) {
|
||||
const entriesStr = Object.entries(requiredPlaceholders)
|
||||
.map(([k, v]) => `${k}: "${v}"`)
|
||||
.join(',\n ');
|
||||
const block = '\n manifestPlaceholders = [\n ' + entriesStr + '\n ]';
|
||||
const lines = contents.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('REACT_NATIVE_RELEASE_LEVEL')) {
|
||||
@@ -74,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;
|
||||
}
|
||||
|
||||
@@ -83,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)/,
|
||||
@@ -97,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)
|
||||
@@ -174,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,7 +35,95 @@ 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;
|
||||
};
|
||||
|
||||
module.exports = withSigning;
|
||||
module.exports = withSigning;
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -40,8 +40,6 @@ function createCommentItemStyles(colors: AppColors) {
|
||||
paddingBottom: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
@@ -84,7 +82,7 @@ function createCommentItemStyles(colors: AppColors) {
|
||||
paddingVertical: 0,
|
||||
},
|
||||
authorBadge: {
|
||||
backgroundColor: colors.primary.main,
|
||||
backgroundColor: `${colors.primary.main}18`,
|
||||
},
|
||||
adminBadge: {
|
||||
backgroundColor: colors.error.main,
|
||||
@@ -94,11 +92,21 @@ function createCommentItemStyles(colors: AppColors) {
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
authorBadgeText: {
|
||||
color: colors.primary.main,
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '700',
|
||||
},
|
||||
smallBadgeText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: 9,
|
||||
fontWeight: '600',
|
||||
},
|
||||
smallAuthorBadgeText: {
|
||||
color: colors.primary.main,
|
||||
fontSize: 9,
|
||||
fontWeight: '700',
|
||||
},
|
||||
timeText: {
|
||||
fontSize: fontSizes.xs,
|
||||
flexShrink: 0,
|
||||
@@ -304,7 +312,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
if (isAuthor) {
|
||||
badges.push(
|
||||
<View key="author" style={[styles.badge, styles.authorBadge]}>
|
||||
<Text variant="caption" style={styles.badgeText}>楼主</Text>
|
||||
<Text variant="caption" style={styles.authorBadgeText}>楼主</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -460,7 +468,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
</Text>
|
||||
{replyAuthorId === commentAuthorId && (
|
||||
<View style={[styles.badge, styles.authorBadge, styles.smallBadge]}>
|
||||
<Text variant="caption" style={styles.smallBadgeText}>楼主</Text>
|
||||
<Text variant="caption" style={styles.smallAuthorBadgeText}>楼主</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* 显示回复引用:aaa 回复 bbb */}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
115
src/components/business/SearchHeader.tsx
Normal file
115
src/components/business/SearchHeader.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* SearchHeader 搜索顶栏组件
|
||||
* 统一的「搜索框 + 取消按钮」顶栏,用于内嵌式搜索页面。
|
||||
*
|
||||
* - 内置 Android 硬件返回键拦截:存在 onCancel 时,按下返回键调用 onCancel
|
||||
* 而非退出 App(解决 Tab 根页面内嵌搜索时返回键直接退出的问题)。
|
||||
* - 样式与 HomeScreen 搜索页保持一致。
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, BackHandler, StyleProp, ViewStyle } from 'react-native';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import { Text } from '../common';
|
||||
import SearchBar from './SearchBar';
|
||||
|
||||
interface SearchHeaderProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSubmit: () => void;
|
||||
/** 取消回调:同时用于取消按钮与硬件返回键。必传以启用内嵌模式返回拦截。 */
|
||||
onCancel: () => void;
|
||||
placeholder?: string;
|
||||
/** 透传给 SearchBar 的 compact 模式 */
|
||||
compact?: boolean;
|
||||
/** 搜索框最大宽度(宽屏限宽) */
|
||||
searchBarMaxWidth?: number;
|
||||
/** 水平外边距,默认 spacing.md */
|
||||
horizontalPadding?: number;
|
||||
/** 顶部内边距,默认 spacing.sm */
|
||||
paddingTop?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
function createSearchHeaderStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
searchShell: {
|
||||
flex: 1,
|
||||
},
|
||||
cancelButton: {
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
cancelText: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const SearchHeader: React.FC<SearchHeaderProps> = ({
|
||||
value,
|
||||
onChangeText,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
placeholder,
|
||||
compact = false,
|
||||
searchBarMaxWidth,
|
||||
horizontalPadding = spacing.md,
|
||||
paddingTop = spacing.sm,
|
||||
style,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createSearchHeaderStyles(colors), [colors]);
|
||||
|
||||
// 内嵌模式:拦截 Android 物理返回键,调用 onCancel 而非退出 App
|
||||
useEffect(() => {
|
||||
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
onCancel();
|
||||
return true; // 阻止默认行为(退出 App)
|
||||
});
|
||||
return () => backHandler.remove();
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
paddingTop,
|
||||
paddingHorizontal: horizontalPadding,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<View style={[styles.searchShell, searchBarMaxWidth != null && { maxWidth: searchBarMaxWidth }]}>
|
||||
<SearchBar
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onSubmit={onSubmit}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
compact={compact}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.cancelButton, { marginLeft: spacing.sm }]}
|
||||
onPress={onCancel}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text variant="body" color={colors.primary.main} style={styles.cancelText}>
|
||||
取消
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchHeader;
|
||||
@@ -10,7 +10,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import Text from '../common/Text';
|
||||
|
||||
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern';
|
||||
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern' | 'home';
|
||||
|
||||
interface TabBarProps {
|
||||
tabs: string[];
|
||||
@@ -20,6 +20,8 @@ interface TabBarProps {
|
||||
rightContent?: ReactNode;
|
||||
variant?: TabBarVariant;
|
||||
icons?: string[];
|
||||
/** home 变体的字号,默认 18(HomeScreen 可传 20) */
|
||||
fontSize?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
@@ -176,6 +178,33 @@ function createTabBarStyles(colors: AppColors) {
|
||||
borderTopLeftRadius: borderRadius.sm,
|
||||
borderTopRightRadius: borderRadius.sm,
|
||||
},
|
||||
// home 变体:与 HomeScreen/SearchScreen 一致的文字下划线风格
|
||||
homeContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
homeTab: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
marginRight: 20,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: 'transparent',
|
||||
},
|
||||
homeTabActive: {
|
||||
borderBottomColor: colors.text.primary,
|
||||
},
|
||||
homeTabText: {
|
||||
fontSize: fontSizes.xl,
|
||||
fontWeight: '400',
|
||||
color: colors.text.hint,
|
||||
},
|
||||
homeTabTextActive: {
|
||||
fontSize: fontSizes.xl,
|
||||
fontWeight: '700',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -187,6 +216,7 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
rightContent,
|
||||
variant = 'default',
|
||||
icons,
|
||||
fontSize,
|
||||
style,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
@@ -278,6 +308,29 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'home') {
|
||||
const homeTextStyle = [
|
||||
isActive ? styles.homeTabTextActive : styles.homeTabText,
|
||||
fontSize != null ? { fontSize } : undefined,
|
||||
];
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[styles.homeTab, isActive && styles.homeTabActive]}
|
||||
onPress={() => onTabChange(index)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={isActive ? colors.text.primary : colors.text.hint}
|
||||
style={homeTextStyle}
|
||||
>
|
||||
{tab}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
@@ -306,6 +359,8 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
return styles.segmentedContainer;
|
||||
case 'modern':
|
||||
return styles.modernContainer;
|
||||
case 'home':
|
||||
return styles.homeContainer;
|
||||
default:
|
||||
return styles.container;
|
||||
}
|
||||
@@ -330,4 +385,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',
|
||||
|
||||
@@ -18,12 +18,14 @@ export { default as CommentItem } from './CommentItem';
|
||||
export { default as UserProfileHeader } from './UserProfileHeader';
|
||||
export { default as SystemMessageItem } from './SystemMessageItem';
|
||||
export { default as SearchBar } from './SearchBar';
|
||||
export { default as SearchHeader } from './SearchHeader';
|
||||
export { default as TabBar } from './TabBar';
|
||||
export { default as VoteCard } from './VoteCard';
|
||||
export { default as VoteEditor } from './VoteEditor';
|
||||
export { default as VotePreview } from './VotePreview';
|
||||
export { default as PostContentRenderer } from './PostContentRenderer';
|
||||
export { default as PostMentionInput } from './PostMentionInput';
|
||||
export type { PostMentionInputHandle } from './PostMentionInput';
|
||||
export { default as BlockEditor } from './BlockEditor';
|
||||
export type { BlockEditorHandle } from './BlockEditor';
|
||||
export { default as ReportDialog } from './ReportDialog';
|
||||
|
||||
@@ -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,10 +390,12 @@ 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
|
||||
key={image.id || index}
|
||||
@@ -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}
|
||||
@@ -427,10 +431,12 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
{displayImages.map((image, index) => {
|
||||
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}
|
||||
@@ -489,10 +497,12 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
? image.width / image.height
|
||||
: 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}
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface Message {
|
||||
seq: number;
|
||||
segments: MessageSegment[];
|
||||
createdAt: string;
|
||||
status: 'normal' | 'recalled' | 'deleted';
|
||||
status: 'normal' | 'pending' | 'failed' | 'recalled' | 'deleted';
|
||||
category?: string;
|
||||
sender?: {
|
||||
id: string;
|
||||
|
||||
@@ -5,9 +5,7 @@ export type AppEvent =
|
||||
| { type: 'NAVIGATE_BACK' }
|
||||
| { type: 'SHOW_VERIFICATION_MODAL' }
|
||||
| { type: 'VIBRATE'; payload: { type: 'message' | 'group_message' | 'call' } }
|
||||
| { type: 'SHOW_TOAST'; payload: { message: string; type: 'success' | 'error' | 'info' } }
|
||||
| { type: 'AUTH_LOGOUT' }
|
||||
| { type: 'AUTH_LOGIN'; payload: { userId: string } };
|
||||
| { type: 'AUTH_LOGOUT' };
|
||||
|
||||
class EventBusClass {
|
||||
private subscribers: Set<Subscriber<AppEvent>> = new Set();
|
||||
|
||||
@@ -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,184 +0,0 @@
|
||||
/**
|
||||
* WSClient - WebSocket连接管理
|
||||
* 只负责WebSocket连接管理,提供事件驱动架构
|
||||
*/
|
||||
|
||||
import {
|
||||
wsService,
|
||||
WSChatMessage,
|
||||
WSGroupChatMessage,
|
||||
WSReadMessage,
|
||||
WSGroupReadMessage,
|
||||
WSRecallMessage,
|
||||
WSGroupRecallMessage,
|
||||
WSGroupTypingMessage,
|
||||
WSGroupNoticeMessage,
|
||||
WSMessageType,
|
||||
} from '@/services/core';
|
||||
|
||||
// 事件处理器类型
|
||||
type EventHandler<T> = (data: T) => void;
|
||||
|
||||
// WS事件类型
|
||||
export interface WSEvents {
|
||||
'chat': WSChatMessage;
|
||||
'group_message': WSGroupChatMessage;
|
||||
'read': WSReadMessage;
|
||||
'group_read': WSGroupReadMessage;
|
||||
'recall': WSRecallMessage;
|
||||
'group_recall': WSGroupRecallMessage;
|
||||
'group_typing': WSGroupTypingMessage;
|
||||
'group_notice': WSGroupNoticeMessage;
|
||||
'connected': void;
|
||||
'disconnected': void;
|
||||
'error': Error;
|
||||
}
|
||||
|
||||
export type WSEventType = keyof WSEvents;
|
||||
|
||||
class WSClient {
|
||||
private handlers: Map<WSEventType, Set<EventHandler<any>>> = new Map();
|
||||
private unsubscribeFns: Array<() => void> = [];
|
||||
private isInitialized = false;
|
||||
|
||||
/**
|
||||
* 初始化WebSocket监听
|
||||
*/
|
||||
initialize(): void {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
// 监听私聊消息
|
||||
const unsubChat = wsService.on('chat', (message) => {
|
||||
this.emit('chat', message);
|
||||
});
|
||||
|
||||
// 监听群聊消息
|
||||
const unsubGroupMessage = wsService.on('group_message', (message) => {
|
||||
this.emit('group_message', message);
|
||||
});
|
||||
|
||||
// 监听私聊已读回执
|
||||
const unsubRead = wsService.on('read', (message) => {
|
||||
this.emit('read', message);
|
||||
});
|
||||
|
||||
// 监听群聊已读回执
|
||||
const unsubGroupRead = wsService.on('group_read', (message) => {
|
||||
this.emit('group_read', message);
|
||||
});
|
||||
|
||||
// 监听私聊消息撤回
|
||||
const unsubRecall = wsService.on('recall', (message) => {
|
||||
this.emit('recall', message);
|
||||
});
|
||||
|
||||
// 监听群聊消息撤回
|
||||
const unsubGroupRecall = wsService.on('group_recall', (message) => {
|
||||
this.emit('group_recall', message);
|
||||
});
|
||||
|
||||
// 监听群聊输入状态
|
||||
const unsubGroupTyping = wsService.on('group_typing', (message) => {
|
||||
this.emit('group_typing', message);
|
||||
});
|
||||
|
||||
// 监听群通知
|
||||
const unsubGroupNotice = wsService.on('group_notice', (message) => {
|
||||
this.emit('group_notice', message);
|
||||
});
|
||||
|
||||
// 监听连接状态
|
||||
const unsubConnect = wsService.onConnect(() => {
|
||||
this.emit('connected', undefined);
|
||||
});
|
||||
|
||||
const unsubDisconnect = wsService.onDisconnect(() => {
|
||||
this.emit('disconnected', undefined);
|
||||
});
|
||||
|
||||
this.unsubscribeFns = [
|
||||
unsubChat,
|
||||
unsubGroupMessage,
|
||||
unsubRead,
|
||||
unsubGroupRead,
|
||||
unsubRecall,
|
||||
unsubGroupRecall,
|
||||
unsubGroupTyping,
|
||||
unsubGroupNotice,
|
||||
unsubConnect,
|
||||
unsubDisconnect,
|
||||
];
|
||||
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁WebSocket监听
|
||||
*/
|
||||
destroy(): void {
|
||||
this.unsubscribeFns.forEach((fn) => fn());
|
||||
this.unsubscribeFns = [];
|
||||
this.handlers.clear();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅事件
|
||||
*/
|
||||
on<T extends WSEventType>(
|
||||
event: T,
|
||||
handler: EventHandler<WSEvents[T]>
|
||||
): () => void {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, new Set());
|
||||
}
|
||||
this.handlers.get(event)!.add(handler);
|
||||
|
||||
return () => {
|
||||
this.handlers.get(event)?.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件
|
||||
*/
|
||||
private emit<T extends WSEventType>(
|
||||
event: T,
|
||||
data: WSEvents[T]
|
||||
): void {
|
||||
const handlers = this.handlers.get(event);
|
||||
if (handlers) {
|
||||
handlers.forEach((handler) => {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (error) {
|
||||
console.error(`[WSClient] 事件处理器执行失败: ${event}`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查连接状态
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return wsService.isConnected();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动WebSocket连接
|
||||
*/
|
||||
async connect(): Promise<boolean> {
|
||||
return wsService.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开WebSocket连接
|
||||
*/
|
||||
disconnect(): void {
|
||||
wsService.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export const wsClient = new WSClient();
|
||||
export default wsClient;
|
||||
@@ -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>;
|
||||
@@ -8,6 +9,16 @@ export interface IMessageRepository {
|
||||
getByConversation(conversationId: string, limit?: number): Promise<CachedMessage[]>;
|
||||
getMaxSeq(conversationId: string): Promise<number>;
|
||||
getMinSeq(conversationId: string): Promise<number>;
|
||||
/**
|
||||
* 返回本地某会话从最大 seq 往下的「连续区间」。
|
||||
* - maxSeq: 该会话本地最大的 seq(0 表示无消息)
|
||||
* - minSeq: 从 maxSeq 往下连续递减遇到第一个缺口时的下沿(即连续段的最早 seq)
|
||||
*
|
||||
* 用于 hydration 管线判断「最新端往前是否存在历史缺口」:
|
||||
* 若 minSeq > 1,说明 [minSeq-1, ...] 更早的消息本地缺失,需要回填。
|
||||
* 应用层遍历而非 SQL 窗口函数,保证跨 SQLite 版本兼容、易测试。
|
||||
*/
|
||||
getContiguousRange(conversationId: string): Promise<{ maxSeq: number; minSeq: number }>;
|
||||
getBeforeSeq(conversationId: string, beforeSeq: number, limit?: number): Promise<CachedMessage[]>;
|
||||
getCount(conversationId: string): Promise<number>;
|
||||
getCountBeforeSeq(conversationId: string, beforeSeq: number): Promise<number>;
|
||||
@@ -16,7 +27,14 @@ export interface IMessageRepository {
|
||||
delete(messageId: string): Promise<void>;
|
||||
updateStatus(messageId: string, status: string, clearContent?: boolean): Promise<void>;
|
||||
clearConversation(conversationId: string): Promise<void>;
|
||||
/**
|
||||
* 查询所有待发送(pending)或发送失败(failed)的消息(Outbox 恢复用)。
|
||||
* 按 createdAt 升序,保证恢复重试时按发送顺序处理。
|
||||
* 用于 App 启动时扫描本地未完成发送的消息,读入 store 并触发重试。
|
||||
*/
|
||||
getPendingOrFailed(): Promise<CachedMessage[]>;
|
||||
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>;
|
||||
}
|
||||
@@ -77,6 +95,27 @@ export class MessageRepository implements IMessageRepository {
|
||||
return r?.minSeq || 0;
|
||||
}
|
||||
|
||||
async getContiguousRange(conversationId: string): Promise<{ maxSeq: number; minSeq: number }> {
|
||||
// 单次查询拉出该会话所有有效 seq,按 DESC 排序;应用层从最大值向下找第一个缺口。
|
||||
// 会话消息量通常可控,且仅对未 hydrated 的会话执行一次,开销可接受。
|
||||
const rows = await this.dataSource.query<{ seq: number }>(
|
||||
`SELECT seq FROM messages WHERE conversationId = ? AND seq > 0 ORDER BY seq DESC`,
|
||||
[conversationId]
|
||||
);
|
||||
if (rows.length === 0) return { maxSeq: 0, minSeq: 0 };
|
||||
|
||||
let maxSeq = rows[0].seq;
|
||||
let minSeq = maxSeq;
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
if (rows[i].seq === minSeq - 1) {
|
||||
minSeq = rows[i].seq; // 连续,向下扩展
|
||||
} else {
|
||||
break; // 遇到缺口,停止
|
||||
}
|
||||
}
|
||||
return { maxSeq, minSeq };
|
||||
}
|
||||
|
||||
async getBeforeSeq(conversationId: string, beforeSeq: number, limit: number = 20): Promise<CachedMessage[]> {
|
||||
const rows = await this.dataSource.query<any>(
|
||||
`SELECT * FROM messages WHERE conversationId = ? AND seq < ? ORDER BY seq DESC LIMIT ?`,
|
||||
@@ -85,6 +124,19 @@ export class MessageRepository implements IMessageRepository {
|
||||
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined })).reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按消息 ID 获取单条缓存消息(用于引用预览的离线命中优先查询)。
|
||||
* 不限定 conversationId——引用消息可能来自任意会话,按主键 id 直接查即可。
|
||||
*/
|
||||
async getById(messageId: string): Promise<CachedMessage | null> {
|
||||
const r = await this.dataSource.getFirst<any>(
|
||||
`SELECT * FROM messages WHERE id = ?`,
|
||||
[messageId]
|
||||
);
|
||||
if (!r) return null;
|
||||
return { ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined };
|
||||
}
|
||||
|
||||
async getCount(conversationId: string): Promise<number> {
|
||||
const r = await this.dataSource.getFirst<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ?`, [conversationId]
|
||||
@@ -136,6 +188,18 @@ export class MessageRepository implements IMessageRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async getPendingOrFailed(): Promise<CachedMessage[]> {
|
||||
const rows = await this.dataSource.query<any>(
|
||||
`SELECT * FROM messages WHERE status IN ('pending', 'failed') ORDER BY createdAt ASC`,
|
||||
[]
|
||||
);
|
||||
return rows.map(r => ({
|
||||
...r,
|
||||
isRead: r.isRead === 1,
|
||||
segments: r.segments ? JSON.parse(r.segments) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async search(keyword: string): Promise<CachedMessage[]> {
|
||||
const rows = await this.dataSource.query<any>(
|
||||
`SELECT * FROM messages WHERE content LIKE ? ORDER BY createdAt DESC LIMIT 50`, [`%${keyword}%`]
|
||||
@@ -143,6 +207,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`);
|
||||
|
||||
@@ -23,6 +23,42 @@ import {
|
||||
CREATE_POSTS_CACHE_TABLE,
|
||||
} from './PostSchema';
|
||||
|
||||
/**
|
||||
* 迁移版本号管理(对标 expo-sqlite 官方推荐用 PRAGMA user_version)。
|
||||
*
|
||||
* 设计:
|
||||
* - version 0:基线(建表 + 建索引),所有老库都已执行过(靠 CREATE IF NOT EXISTS 幂等)
|
||||
* - version 1:列级迁移(补 seq/status/segments/lastSeq 列),即原 MIGRATE_* 函数
|
||||
*
|
||||
* 向后兼容:老库 user_version 默认为 0,会重新跑 version 1 的列迁移,
|
||||
* 但 MIGRATE_* 靠 PRAGMA table_info 列检查幂等,重复执行无副作用。
|
||||
*
|
||||
* 扩展方式(未来破坏性迁移):在 MIGRATIONS 注册表新增 version 2/3/...,
|
||||
* 每个 step 靠 transaction 包裹保证原子性(失败回滚,不污染 user_version)。
|
||||
*/
|
||||
export const CURRENT_SCHEMA_VERSION = 1;
|
||||
|
||||
/** 读取当前 user_version */
|
||||
async function getUserVersion(db: SQLiteDatabase): Promise<number> {
|
||||
const result = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
|
||||
return result?.user_version ?? 0;
|
||||
}
|
||||
|
||||
/** 设置 user_version */
|
||||
async function setUserVersion(db: SQLiteDatabase, version: number): Promise<void> {
|
||||
await execWithRetry(db, `PRAGMA user_version = ${version};`);
|
||||
}
|
||||
|
||||
/** 版本化迁移注册表:key = 目标版本,value = 该版本要执行的迁移逻辑 */
|
||||
const MIGRATIONS: Map<number, (db: SQLiteDatabase) => Promise<void>> = new Map([
|
||||
// version 1:列级迁移(补 seq/status/segments/lastSeq 列)
|
||||
// 历史背景:最初表结构不含这些列,靠 MIGRATE_* 增量 ALTER。现在纳入版本管理。
|
||||
[1, async (db: SQLiteDatabase) => {
|
||||
await MIGRATE_MESSAGES_TABLE(db);
|
||||
await MIGRATE_CONVERSATIONS_TABLE(db);
|
||||
}],
|
||||
]);
|
||||
|
||||
async function execWithRetry(db: SQLiteDatabase, sql: string, maxRetries = 5): Promise<void> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
@@ -41,9 +77,19 @@ async function execWithRetry(db: SQLiteDatabase, sql: string, maxRetries = 5): P
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行数据库迁移。
|
||||
*
|
||||
* 流程:
|
||||
* 1. 设置 busy_timeout
|
||||
* 2. 基线建表 + 建索引(version 0,CREATE IF NOT EXISTS 幂等,每次都执行以保证新表存在)
|
||||
* 3. 按 user_version 顺序执行版本化迁移,每步成功后递增 user_version
|
||||
*/
|
||||
export async function runMigrations(db: SQLiteDatabase): Promise<void> {
|
||||
await execWithRetry(db, 'PRAGMA busy_timeout = 10000;');
|
||||
|
||||
|
||||
// ---- 基线:version 0(建表 + 建索引)----
|
||||
// 保留每次执行,确保新库/损坏恢复时表结构就位;对老库靠 IF NOT EXISTS 幂等。
|
||||
await execWithRetry(db, CREATE_MESSAGES_TABLE);
|
||||
await execWithRetry(db, CREATE_CONVERSATIONS_TABLE);
|
||||
await execWithRetry(db, CREATE_CONVERSATION_CACHE_TABLE);
|
||||
@@ -61,6 +107,32 @@ export async function runMigrations(db: SQLiteDatabase): Promise<void> {
|
||||
await execWithRetry(db, indexSql);
|
||||
}
|
||||
|
||||
await MIGRATE_MESSAGES_TABLE(db);
|
||||
await MIGRATE_CONVERSATIONS_TABLE(db);
|
||||
}
|
||||
// ---- 版本化迁移:version 1 ~ CURRENT_SCHEMA_VERSION ----
|
||||
const currentVersion = await getUserVersion(db);
|
||||
|
||||
// 当前版本已是最新(含未来更高版本),跳过
|
||||
if (currentVersion >= CURRENT_SCHEMA_VERSION) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 从 currentVersion+1 开始,逐版本执行迁移
|
||||
for (let target = currentVersion + 1; target <= CURRENT_SCHEMA_VERSION; target++) {
|
||||
const migration = MIGRATIONS.get(target);
|
||||
if (!migration) {
|
||||
console.warn(`[MigrationManager] 未找到 version ${target} 的迁移逻辑,跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await migration(db);
|
||||
// 每个版本迁移成功后立即持久化 user_version,
|
||||
// 即使后续版本失败,已完成的版本不会重复执行
|
||||
await setUserVersion(db, target);
|
||||
} catch (error) {
|
||||
console.error(`[MigrationManager] 迁移到 version ${target} 失败:`, error);
|
||||
// 不抛出致命错误以避免阻塞 App 启动;降级运行(表可能缺列,MIGRATE_* 内部会兜底)
|
||||
// 但记录版本未推进,下次启动会重试该版本
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
for (const [key, value] of records) {
|
||||
const records = await AsyncStorage.getMany(recordKeys);
|
||||
|
||||
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)}`;
|
||||
}
|
||||
@@ -73,10 +76,18 @@ export function hrefProfileBookmarks(): string {
|
||||
return '/profile/bookmarks';
|
||||
}
|
||||
|
||||
export function hrefCreatePost(mode?: 'create' | 'edit', postId?: string): string {
|
||||
export function hrefCreatePost(
|
||||
mode?: 'create' | 'edit',
|
||||
postId?: string,
|
||||
postMode?: 'moment' | 'long',
|
||||
): string {
|
||||
if (mode === 'edit' && postId) {
|
||||
return `/posts/create?mode=edit&postId=${encodeURIComponent(postId)}`;
|
||||
}
|
||||
// 新建时可指定入口模式:moment=瞬间(普通),long=长文(含投票)。
|
||||
if (postMode) {
|
||||
return `/posts/create?postMode=${postMode}`;
|
||||
}
|
||||
return '/posts/create';
|
||||
}
|
||||
|
||||
@@ -87,14 +98,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 +135,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;
|
||||
@@ -186,6 +210,10 @@ export function hrefProfileAbout(): string {
|
||||
return '/profile/about';
|
||||
}
|
||||
|
||||
export function hrefProfileHelp(): string {
|
||||
return '/profile/help';
|
||||
}
|
||||
|
||||
export function hrefProfileTerms(): string {
|
||||
return '/terms';
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
140
src/screens/create/composers/ChannelPicker.tsx
Normal file
140
src/screens/create/composers/ChannelPicker.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 频道选择(瞬间 / 长文共用)
|
||||
* 包含「发表至」入口行 + 展开后的频道单选面板。
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Text } from '../../../components/common';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../../theme';
|
||||
|
||||
export type ChannelOption = { id: string; name: string };
|
||||
|
||||
interface ChannelPickerProps {
|
||||
channels: ChannelOption[];
|
||||
selectedId: string | null;
|
||||
onSelect: (channelId: string) => void;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function ChannelPicker({
|
||||
channels,
|
||||
selectedId,
|
||||
onSelect,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: ChannelPickerProps) {
|
||||
const colors = useAppColors();
|
||||
const styles = React.useMemo(() => createChannelPickerStyles(colors), [colors]);
|
||||
|
||||
const selectedName = React.useMemo(() => {
|
||||
if (!selectedId) return '';
|
||||
return channels.find(c => c.id === selectedId)?.name || '';
|
||||
}, [selectedId, channels]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 入口行 */}
|
||||
<TouchableOpacity style={styles.channelEntryRow} onPress={onToggle} activeOpacity={0.8}>
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.channelEntryLabel}>
|
||||
发表至:
|
||||
</Text>
|
||||
<View style={styles.channelEntryValueWrap}>
|
||||
<Text
|
||||
variant="body"
|
||||
color={selectedName ? colors.text.primary : colors.text.hint}
|
||||
style={styles.channelEntryValue}
|
||||
>
|
||||
{selectedName || '选择频道'}
|
||||
</Text>
|
||||
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.text.hint} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 展开面板 */}
|
||||
{expanded && (
|
||||
<View style={styles.tagsSection}>
|
||||
<View style={styles.tagsContainer}>
|
||||
{channels.map((channel) => {
|
||||
const isSelected = selectedId === channel.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={channel.id}
|
||||
style={[styles.tag, isSelected && styles.channelSelected]}
|
||||
onPress={() => onSelect(channel.id)}
|
||||
>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={isSelected ? colors.primary.contrast : colors.text.secondary}
|
||||
style={styles.tagText}
|
||||
>
|
||||
{channel.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{channels.length === 0 && (
|
||||
<Text variant="caption" color={colors.text.hint}>暂无可用频道</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function createChannelPickerStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
channelEntryRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.divider,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
channelEntryLabel: {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
channelEntryValueWrap: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
channelEntryValue: {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
tagsSection: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.divider,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
tag: {
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
channelSelected: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
tagText: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
}
|
||||
121
src/screens/create/composers/EmojiPanel.tsx
Normal file
121
src/screens/create/composers/EmojiPanel.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 表情面板(瞬间 / 长文共用)
|
||||
* 从 CreatePostScreen 抽出,数据 + FlatList 渲染整体迁入。
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, FlatList, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { Text } from '../../../components/common';
|
||||
import { spacing, useAppColors, type AppColors } from '../../../theme';
|
||||
|
||||
// 常用表情列表
|
||||
const EMOJIS = [
|
||||
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂',
|
||||
'🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩',
|
||||
'😘', '😗', '😚', '😙', '🥲', '😋', '😛', '😜',
|
||||
'🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐',
|
||||
'🤨', '😐', '😑', '😶', '😏', '😒', '🙄',
|
||||
'😬', '🤥', '😌', '😔', '😪', '🤤', '😴', '😷',
|
||||
'🤒', '🤕', '🤢', '🤮', '🤧', '🥵', '🥶', '🥴',
|
||||
'😵', '🤯', '🤠', '🥳', '🥸', '😎', '🤓',
|
||||
'🧐', '😕', '😟', '🙁', '☹️', '😮', '😯', '😲',
|
||||
'😳', '🥺', '😦', '😧', '😨', '😰', '😥', '😢',
|
||||
'😭', '😱', '😖', '😣', '😞', '😓', '😩', '😫',
|
||||
'🥱', '😤', '😡', '😠', '🤬', '😈', '👿', '💀',
|
||||
'👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤌', '🤏',
|
||||
'✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆',
|
||||
'👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌',
|
||||
'👐', '🤲', '🤝', '🙏', '✍️', '💪', '🦾', '🦵',
|
||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍',
|
||||
'🤎', '💔', '🩹', '💕', '💞', '💓', '💗', '💖',
|
||||
'💘', '💝', '🎉', '🎊', '🎁', '🎈', '✨', '🔥',
|
||||
'💯', '💢', '💥', '💫', '💦', '💨', '🕳️',
|
||||
];
|
||||
|
||||
const EMOJIS_PER_ROW = 8;
|
||||
const EMOJI_ROW_HEIGHT = 52;
|
||||
|
||||
const EMOJI_ROWS = (() => {
|
||||
const rows: { id: string; emojis: string[] }[] = [];
|
||||
for (let i = 0; i < EMOJIS.length; i += EMOJIS_PER_ROW) {
|
||||
rows.push({ id: `emoji-row-${i}`, emojis: EMOJIS.slice(i, i + EMOJIS_PER_ROW) });
|
||||
}
|
||||
return rows;
|
||||
})();
|
||||
|
||||
interface EmojiPanelProps {
|
||||
visible: boolean;
|
||||
onPick: (emoji: string) => void;
|
||||
isWideScreen?: boolean;
|
||||
}
|
||||
|
||||
export function EmojiPanel({ visible, onPick, isWideScreen }: EmojiPanelProps) {
|
||||
const colors = useAppColors();
|
||||
const styles = React.useMemo(() => createEmojiPanelStyles(colors), [colors]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<View style={[styles.emojiPanel, isWideScreen && styles.emojiPanelWide]}>
|
||||
<FlatList
|
||||
data={EMOJI_ROWS}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.emojiRow}>
|
||||
{item.emojis.map((emoji, index) => (
|
||||
<TouchableOpacity
|
||||
key={`${item.id}-${index}`}
|
||||
style={styles.emojiItem}
|
||||
onPress={() => onPick(emoji)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.emojiText}>{emoji}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
initialNumToRender={8}
|
||||
maxToRenderPerBatch={8}
|
||||
windowSize={5}
|
||||
getItemLayout={(_data, index) => ({
|
||||
length: EMOJI_ROW_HEIGHT,
|
||||
offset: EMOJI_ROW_HEIGHT * index,
|
||||
index,
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function createEmojiPanelStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
emojiPanel: {
|
||||
height: 280,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.divider,
|
||||
},
|
||||
emojiPanelWide: {
|
||||
height: 320,
|
||||
},
|
||||
emojiRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
height: EMOJI_ROW_HEIGHT,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
emojiItem: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emojiText: {
|
||||
fontSize: 26,
|
||||
lineHeight: 32,
|
||||
},
|
||||
});
|
||||
}
|
||||
135
src/screens/create/composers/LongPostComposer.tsx
Normal file
135
src/screens/create/composers/LongPostComposer.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 短链 Composer(原长文模式,含投票)
|
||||
*
|
||||
* 富文本块编辑器 + 条件投票编辑器。
|
||||
* 通过 forwardRef 暴露 LongPostComposerHandle(含 getVote),供外壳发布逻辑分流。
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import BlockEditor, { BlockEditorHandle } from '../../../components/business/BlockEditor';
|
||||
import VoteEditor from '../../../components/business/VoteEditor';
|
||||
import { Text } from '../../../components/common';
|
||||
import { spacing, useAppColors, type AppColors } from '../../../theme';
|
||||
import { useResponsive } from '../../../hooks';
|
||||
import { MAX_CONTENT_LENGTH, type LongPostComposerHandle, type LongPostComposerProps } from './types';
|
||||
|
||||
export const LongPostComposer = React.forwardRef<LongPostComposerHandle, LongPostComposerProps>(function LongPostComposer(
|
||||
props,
|
||||
ref,
|
||||
) {
|
||||
const colors = useAppColors();
|
||||
const styles = React.useMemo(() => createLongStyles(colors), [colors]);
|
||||
const { isWideScreen } = useResponsive();
|
||||
const { onContentChange, voteToggleSignal } = props;
|
||||
|
||||
const blockEditorRef = React.useRef<BlockEditorHandle>(null);
|
||||
const [isVotePost, setIsVotePost] = React.useState(false);
|
||||
const [voteOptions, setVoteOptions] = React.useState<string[]>(['', '']);
|
||||
|
||||
// 投票选项增删改
|
||||
const handleAddVoteOption = () => {
|
||||
if (voteOptions.length < 10) setVoteOptions([...voteOptions, '']);
|
||||
};
|
||||
const handleRemoveVoteOption = (index: number) => {
|
||||
if (voteOptions.length > 2) setVoteOptions(voteOptions.filter((_, i) => i !== index));
|
||||
};
|
||||
const handleUpdateVoteOption = (index: number, value: string) => {
|
||||
setVoteOptions(prev => {
|
||||
const next = [...prev];
|
||||
next[index] = value;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 工具栏投票按钮点击信号:每次自增切换投票开关(跳过初始挂载)
|
||||
const firstSignal = React.useRef(true);
|
||||
React.useEffect(() => {
|
||||
if (firstSignal.current) {
|
||||
firstSignal.current = false;
|
||||
return;
|
||||
}
|
||||
setIsVotePost(prev => !prev);
|
||||
}, [voteToggleSignal]);
|
||||
|
||||
// 暴露给外壳的统一能力 + 额外的投票数据桥
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
getContent: () => blockEditorRef.current?.getContent() || '',
|
||||
getSegments: () => blockEditorRef.current?.getSegments() || [],
|
||||
getUploadedImages: () => [], // 长文图片以 segment 形式上传后回填到块内,不单独返回
|
||||
uploadImages: async () => {
|
||||
const ok = await blockEditorRef.current?.uploadPendingImages();
|
||||
return ok !== false;
|
||||
},
|
||||
insertEmoji: (text: string) => blockEditorRef.current?.insertTextAtCursor(text),
|
||||
insertImage: async () => { await blockEditorRef.current?.insertImage(); },
|
||||
insertCameraPhoto: async () => { await blockEditorRef.current?.insertCameraPhoto(); },
|
||||
getVote: () => ({ isVotePost, voteOptions }),
|
||||
}), [isVotePost, voteOptions]);
|
||||
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<BlockEditor
|
||||
ref={blockEditorRef}
|
||||
placeholder="添加正文"
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
textStyle={[styles.contentInput, isWideScreen && styles.contentInputWide]}
|
||||
onContentChange={onContentChange}
|
||||
initialBlocks={props.initialBlocks}
|
||||
/>
|
||||
|
||||
{/* 投票编辑器 */}
|
||||
{isVotePost && (
|
||||
<View style={isWideScreen && styles.voteEditorWide}>
|
||||
<View style={styles.voteEditorHeaderRow}>
|
||||
<Text variant="caption" color={colors.text.secondary}>已插入投票</Text>
|
||||
<TouchableOpacity onPress={() => setIsVotePost(false)} activeOpacity={0.7}>
|
||||
<Text variant="caption" color={colors.primary.main}>移除投票</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<VoteEditor
|
||||
options={voteOptions}
|
||||
onAddOption={handleAddVoteOption}
|
||||
onRemoveOption={handleRemoveVoteOption}
|
||||
onUpdateOption={handleUpdateVoteOption}
|
||||
maxOptions={10}
|
||||
minOptions={2}
|
||||
maxLength={50}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function createLongStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
section: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
contentInput: {
|
||||
flexGrow: 1,
|
||||
fontSize: 16,
|
||||
color: colors.text.primary,
|
||||
lineHeight: 24,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
contentInputWide: {
|
||||
fontSize: 18,
|
||||
lineHeight: 28,
|
||||
},
|
||||
voteEditorWide: {
|
||||
maxWidth: 600,
|
||||
alignSelf: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
voteEditorHeaderRow: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.md,
|
||||
marginBottom: -spacing.sm,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
});
|
||||
}
|
||||
223
src/screens/create/composers/MomentComposer.tsx
Normal file
223
src/screens/create/composers/MomentComposer.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 瞬间 Composer(原普通模式)
|
||||
*
|
||||
* 单输入框(支持 @提及)+ 独立图片网格。
|
||||
* 通过 forwardRef 暴露 ComposerHandle,供外壳无差别驱动。
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import PostMentionInput from '../../../components/business/PostMentionInput';
|
||||
import {
|
||||
spacing,
|
||||
borderRadius,
|
||||
useAppColors,
|
||||
type AppColors,
|
||||
} from '../../../theme';
|
||||
import { useResponsive, useResponsiveValue } from '../../../hooks';
|
||||
import { MessageSegment } from '../../../types';
|
||||
import {
|
||||
type PendingOrRemoteImage,
|
||||
makePendingImageFromAsset,
|
||||
getImageDisplayUri,
|
||||
uploadAllPendingImages,
|
||||
} from '../../../utils/pendingImages';
|
||||
import { MAX_CONTENT_LENGTH, type ComposerHandle, type ComposerProps } from './types';
|
||||
|
||||
export const MomentComposer = React.forwardRef<ComposerHandle, ComposerProps>(function MomentComposer(
|
||||
props,
|
||||
ref,
|
||||
) {
|
||||
const colors = useAppColors();
|
||||
const styles = React.useMemo(() => createMomentStyles(colors), [colors]);
|
||||
const { isWideScreen, width } = useResponsive();
|
||||
const { onContentChange } = props;
|
||||
|
||||
const [content, setContent] = React.useState(props.initialContent ?? '');
|
||||
const [segments, setSegments] = React.useState<MessageSegment[]>(props.initialSegments ?? []);
|
||||
const [images, setImages] = React.useState<PendingOrRemoteImage[]>(props.initialImages ?? []);
|
||||
// 表情插入位置(与原实现一致:默认末尾追加,插入表情后更新)
|
||||
const [selection, setSelection] = React.useState({ start: content.length, end: content.length });
|
||||
const [uploadedUrls, setUploadedUrls] = React.useState<string[]>([]);
|
||||
|
||||
// 响应式图片网格
|
||||
const imagesPerRow = useResponsiveValue({ xs: 3, sm: 3, md: 4, lg: 5, xl: 6 });
|
||||
const imageGap = 4;
|
||||
const availableWidth = isWideScreen ? Math.min(width, 800) - spacing.lg * 2 : width - spacing.lg * 2;
|
||||
const imageSize = Math.floor((availableWidth - imageGap * (imagesPerRow - 1)) / imagesPerRow);
|
||||
|
||||
const emitContent = React.useCallback((text: string) => {
|
||||
setContent(text);
|
||||
onContentChange?.(text);
|
||||
}, [onContentChange]);
|
||||
|
||||
// —— 选图 / 拍照(工具栏按钮与网格「+」按钮共用)——
|
||||
const pickImage = React.useCallback(async () => {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert('权限不足', '需要访问相册权限来选择图片');
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: 'images',
|
||||
allowsMultipleSelection: true,
|
||||
selectionLimit: 0,
|
||||
quality: 1,
|
||||
});
|
||||
if (!result.canceled && result.assets) {
|
||||
setImages(prev => [...prev, ...result.assets.map(a => makePendingImageFromAsset(a))]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const takePhoto = React.useCallback(async () => {
|
||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert('权限不足', '需要访问相机权限来拍照');
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchCameraAsync({ allowsEditing: true, quality: 0.8 });
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
setImages(prev => [...prev, makePendingImageFromAsset(result.assets[0])]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 暴露给外壳的统一能力
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
getContent: () => content,
|
||||
getSegments: () => segments,
|
||||
getUploadedImages: () => uploadedUrls,
|
||||
uploadImages: async () => {
|
||||
if (images.length === 0) {
|
||||
setUploadedUrls([]);
|
||||
return true;
|
||||
}
|
||||
const result = await uploadAllPendingImages(images, (id, url) => {
|
||||
setImages(prev => prev.map(i => i.id === id ? { id, kind: 'remote' as const, url } : i));
|
||||
});
|
||||
if (!result.success) {
|
||||
return false;
|
||||
}
|
||||
setUploadedUrls(result.urls);
|
||||
return true;
|
||||
},
|
||||
insertEmoji: (text: string) => {
|
||||
const newContent = content.slice(0, selection.start) + text + content.slice(selection.end);
|
||||
emitContent(newContent);
|
||||
const newPosition = selection.start + text.length;
|
||||
setSelection({ start: newPosition, end: newPosition });
|
||||
},
|
||||
insertImage: pickImage,
|
||||
insertCameraPhoto: takePhoto,
|
||||
}), [content, segments, images, selection, uploadedUrls, emitContent, pickImage, takePhoto]);
|
||||
|
||||
const handleRemoveImage = (index: number) => {
|
||||
setImages(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<PostMentionInput
|
||||
value={content}
|
||||
onChangeText={emitContent}
|
||||
onSegmentsChange={setSegments}
|
||||
placeholder="添加正文"
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
minHeight={160}
|
||||
autoExpand
|
||||
style={styles.contentInput}
|
||||
/>
|
||||
|
||||
{/* 图片网格 */}
|
||||
{images.length > 0 && (
|
||||
<View style={styles.imageGrid}>
|
||||
{images.map((img, index) => (
|
||||
<View key={img.id} style={[styles.imageGridItem, { width: imageSize, height: imageSize }]}>
|
||||
<Image
|
||||
source={{ uri: getImageDisplayUri(img) }}
|
||||
style={styles.gridImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.removeImageButton}
|
||||
onPress={() => handleRemoveImage(index)}
|
||||
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
||||
>
|
||||
<View style={styles.removeImageButtonInner}>
|
||||
<MaterialCommunityIcons name="close" size={12} color={colors.text.inverse} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
{/* 添加图片按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[styles.addImageGridButton, { width: imageSize, height: imageSize }]}
|
||||
onPress={pickImage}
|
||||
>
|
||||
<MaterialCommunityIcons name="plus" size={32} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function createMomentStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
section: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
contentInput: {
|
||||
flexGrow: 1,
|
||||
fontSize: 16,
|
||||
color: colors.text.primary,
|
||||
lineHeight: 24,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
imageGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
paddingTop: spacing.md,
|
||||
gap: 4,
|
||||
},
|
||||
imageGridItem: {
|
||||
borderRadius: borderRadius.md,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
gridImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
removeImageButton: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
zIndex: 10,
|
||||
},
|
||||
removeImageButtonInner: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
addImageGridButton: {
|
||||
borderRadius: borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
borderStyle: 'dashed',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
});
|
||||
}
|
||||
57
src/screens/create/composers/types.ts
Normal file
57
src/screens/create/composers/types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { MessageSegment } from '../../../types';
|
||||
import type { EditorBlock } from '../../../components/business/BlockEditor/blockEditorTypes';
|
||||
import type { RemoteImage } from '../../../utils/pendingImages';
|
||||
|
||||
/**
|
||||
* 两种 Composer(瞬间 / 长文)共同实现的对外能力。
|
||||
* 发帖外壳 CreatePostScreen 通过 ref 无差别地驱动两种模式,
|
||||
* 从而消除主文件里的 isLongPostMode 分支。
|
||||
*/
|
||||
export interface ComposerHandle {
|
||||
/** 纯文本内容(标题/正文校验、字数统计用) */
|
||||
getContent(): string;
|
||||
/** 提交正文段(长文含 image/at 段;瞬间可能含 at 段) */
|
||||
getSegments(): MessageSegment[];
|
||||
/** 发布前上传所有 pending 图片,成功返回 true */
|
||||
uploadImages(): Promise<boolean>;
|
||||
/** 上传后得到的图片 URL(普通模式独立 images 字段;长文返回空数组) */
|
||||
getUploadedImages(): string[];
|
||||
/** 表情面板点击插入 */
|
||||
insertEmoji(text: string): void;
|
||||
/** 工具栏「相册」 */
|
||||
insertImage(): Promise<void>;
|
||||
/** 工具栏「相机」 */
|
||||
insertCameraPhoto(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 长文 Composer 额外暴露的投票数据,供外壳发布逻辑决定走 voteService。
|
||||
*/
|
||||
export interface LongPostComposerHandle extends ComposerHandle {
|
||||
/** 当前投票态与选项 */
|
||||
getVote(): { isVotePost: boolean; voteOptions: string[] };
|
||||
}
|
||||
|
||||
export interface ComposerProps {
|
||||
isEditMode: boolean;
|
||||
/** 编辑态回填:纯文本 */
|
||||
initialContent?: string;
|
||||
/** 编辑态回填:普通模式已有图片 */
|
||||
initialImages?: RemoteImage[];
|
||||
/** 编辑态回填:长文含 image 段的 segments */
|
||||
initialSegments?: MessageSegment[];
|
||||
/** 编辑态回填:长文块(由 segments 转换) */
|
||||
initialBlocks?: EditorBlock[];
|
||||
/** 正文变化回调——同步外壳顶栏字数统计 */
|
||||
onContentChange?: (text: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 长文 Composer 额外接受的 props。
|
||||
* voteToggleSignal:外壳工具栏投票按钮每次点击自增;长文 Composer 监听其变化以切换投票开关。
|
||||
*/
|
||||
export interface LongPostComposerProps extends ComposerProps {
|
||||
voteToggleSignal?: number;
|
||||
}
|
||||
|
||||
export const MAX_CONTENT_LENGTH = 2000;
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
RefreshControl,
|
||||
StatusBar,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
NativeSyntheticEvent,
|
||||
NativeScrollEvent,
|
||||
Alert,
|
||||
@@ -24,11 +25,12 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter, useFocusEffect } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme';
|
||||
import { useAppColors, useResolvedColorScheme, spacing, borderRadius, type AppColors } from '../../theme';
|
||||
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';
|
||||
@@ -185,10 +187,11 @@ function createHomeStyles(colors: AppColors, responsivePadding: number) {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: colors.primary.main,
|
||||
backgroundColor: `${colors.primary.main}10`,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.primary.main}35`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...shadows.lg,
|
||||
},
|
||||
floatingButtonDesktop: {
|
||||
right: 40,
|
||||
@@ -204,6 +207,54 @@ function createHomeStyles(colors: AppColors, responsivePadding: number) {
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
},
|
||||
// 展开后两个小按钮所在的列容器,底部对齐主 FAB 顶部上方
|
||||
fabSubGroup: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
gap: 10,
|
||||
},
|
||||
fabSubGroupDesktop: {
|
||||
right: 40,
|
||||
},
|
||||
fabSubGroupWide: {
|
||||
right: 60,
|
||||
},
|
||||
// 两个小按钮:胶囊按钮 + 图标 + 文字
|
||||
fabSubButton: {
|
||||
minWidth: 92,
|
||||
height: 44,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: `${colors.primary.main}24`,
|
||||
},
|
||||
fabSubButtonWide: {
|
||||
minWidth: 104,
|
||||
height: 48,
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
fabSubLabel: {
|
||||
fontSize: 13,
|
||||
lineHeight: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.primary.main,
|
||||
},
|
||||
// 全屏透明遮罩:用于点击空白收起
|
||||
fabOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
loadingMoreFooter: {
|
||||
paddingVertical: 20,
|
||||
alignItems: 'center',
|
||||
@@ -215,7 +266,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,19 +293,33 @@ 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('');
|
||||
|
||||
|
||||
// 图片查看器状态
|
||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||
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);
|
||||
|
||||
// 发帖弹窗状态
|
||||
const [showCreatePost, setShowCreatePost] = useState(false);
|
||||
// 发帖模式:moment=瞬间(普通),long=长文(含投票)。由 FAB 展开选择后决定。
|
||||
const [postMode, setPostMode] = useState<'moment' | 'long'>('moment');
|
||||
// 加号是否展开为「瞬间/长文」两个小按钮
|
||||
const [fabExpanded, setFabExpanded] = useState(false);
|
||||
|
||||
// 广场/市集 Tab
|
||||
const [homeTab, setHomeTab] = useState<'square' | 'market'>('square');
|
||||
@@ -353,7 +418,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 +491,6 @@ export const HomeScreen: React.FC = () => {
|
||||
refreshing: isRefreshing,
|
||||
hasMore,
|
||||
error,
|
||||
reset,
|
||||
} = useDifferentialPosts<Post>(
|
||||
[],
|
||||
{
|
||||
@@ -486,8 +550,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 +652,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);
|
||||
@@ -714,16 +767,23 @@ export const HomeScreen: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 跳转到发帖页面(使用 Modal 方式)
|
||||
// 点击加号:校验登录/实名后展开「瞬间/长文」两个小按钮
|
||||
const handleCreatePost = () => {
|
||||
if (!isAuthenticated) {
|
||||
router.push('/login');
|
||||
router.push(hrefs.hrefAuthLogin());
|
||||
return;
|
||||
}
|
||||
if (!isVerified) {
|
||||
router.push(hrefs.hrefVerificationGuide());
|
||||
return;
|
||||
}
|
||||
setFabExpanded(prev => !prev);
|
||||
};
|
||||
|
||||
// 选择发帖模式并打开发帖弹窗
|
||||
const openCreatePostWithMode = (mode: 'moment' | 'long') => {
|
||||
setPostMode(mode);
|
||||
setFabExpanded(false);
|
||||
setShowCreatePost(true);
|
||||
};
|
||||
|
||||
@@ -796,7 +856,7 @@ export const HomeScreen: React.FC = () => {
|
||||
const authorId = item.author?.id || '';
|
||||
const isPostAuthor = currentUser?.id === authorId;
|
||||
return (
|
||||
<View style={{ marginBottom: 2, paddingHorizontal: 1 }}>
|
||||
<View style={{ marginBottom: 2, paddingHorizontal: responsiveGap / 2 }}>
|
||||
<PostCard
|
||||
post={item}
|
||||
variant="grid"
|
||||
@@ -805,22 +865,23 @@ export const HomeScreen: React.FC = () => {
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}, [currentUser?.id, stableOnPostAction]);
|
||||
}, [currentUser?.id, stableOnPostAction, responsiveGap]);
|
||||
|
||||
// 渲染响应式网格布局(所有端统一使用 FlashList masonry 模式)
|
||||
const renderResponsiveGrid = () => {
|
||||
return (
|
||||
<FlashList
|
||||
key={`${listKey}_grid`}
|
||||
key="home-grid"
|
||||
ref={scrollViewRef as any}
|
||||
data={displayPosts}
|
||||
extraData={listKey}
|
||||
renderItem={renderGridItem}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={gridColumns}
|
||||
masonry
|
||||
optimizeItemArrangement
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: responsivePadding,
|
||||
paddingHorizontal: responsiveGap / 2,
|
||||
paddingBottom: 80 + responsivePadding,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -865,9 +926,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 +963,7 @@ export const HomeScreen: React.FC = () => {
|
||||
<StatusBar barStyle={statusBarStyle} backgroundColor={colors.background.paper} />
|
||||
<SearchScreen
|
||||
onBack={() => setShowSearch(false)}
|
||||
homeTab={homeTab}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
@@ -1041,6 +1104,51 @@ export const HomeScreen: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 惰性全屏遮罩:点击空白处收起 FAB */}
|
||||
{fabExpanded && (
|
||||
<TouchableWithoutFeedback onPress={() => setFabExpanded(false)}>
|
||||
<View style={styles.fabOverlay} />
|
||||
</TouchableWithoutFeedback>
|
||||
)}
|
||||
|
||||
{/* 发帖入口:加号 / 展开后的「瞬间/长文」两个小按钮 */}
|
||||
{fabExpanded && homeTab !== 'market' && (() => {
|
||||
// 子按钮组整体置于主 FAB 上方:bottom = 主FAB底部 + 主FAB高度 + 间距
|
||||
const fabBottom = floatingButtonBottom !== undefined
|
||||
? floatingButtonBottom
|
||||
: (isWideScreen ? 60 : isDesktop ? 40 : 20);
|
||||
const fabHeight = isWideScreen ? 72 : isDesktop ? 64 : 56;
|
||||
const subGroupBottom = fabBottom + fabHeight + 14;
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.fabSubGroup,
|
||||
isDesktop && styles.fabSubGroupDesktop,
|
||||
isWideScreen && styles.fabSubGroupWide,
|
||||
{ bottom: subGroupBottom },
|
||||
]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[styles.fabSubButton, isWideScreen && styles.fabSubButtonWide]}
|
||||
onPress={() => openCreatePostWithMode('long')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialCommunityIcons name="text-box" size={18} color={colors.primary.main} />
|
||||
<Text variant="caption" style={styles.fabSubLabel}>长文</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.fabSubButton, isWideScreen && styles.fabSubButtonWide]}
|
||||
onPress={() => openCreatePostWithMode('moment')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialCommunityIcons name="lightning-bolt" size={18} color={colors.primary.main} />
|
||||
<Text variant="caption" style={styles.fabSubLabel}>瞬间</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 漂浮发帖/发布按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
@@ -1050,24 +1158,25 @@ 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}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialCommunityIcons name="plus" size={28} color={colors.text.inverse} />
|
||||
<MaterialCommunityIcons
|
||||
name={(fabExpanded && homeTab !== 'market') ? 'close' : 'plus'}
|
||||
size={28}
|
||||
color={colors.primary.main}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 图片查看器 */}
|
||||
<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
|
||||
/>
|
||||
|
||||
@@ -1079,6 +1188,8 @@ export const HomeScreen: React.FC = () => {
|
||||
onRequestClose={() => setShowCreatePost(false)}
|
||||
>
|
||||
<CreatePostScreen
|
||||
key={postMode}
|
||||
postMode={postMode}
|
||||
onClose={() => setShowCreatePost(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -37,13 +37,21 @@ import {
|
||||
} from '../../theme';
|
||||
import { Post, Comment, VoteResultDTO, VoteOptionDTO, MessageSegment } from '../../types';
|
||||
import { useUserStore } from '../../stores';
|
||||
import { userManager } from '../../stores/user';
|
||||
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,10 +259,11 @@ 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);
|
||||
const [authorFollowersCount, setAuthorFollowersCount] = useState<number | null>(null);
|
||||
const [isFollowLoading, setIsFollowLoading] = useState(false);
|
||||
const flatListRef = useRef<FlashListRef<Comment> | null>(null);
|
||||
const hasRecordedView = useRef(false); // 是否已记录浏览量
|
||||
@@ -270,6 +279,35 @@ export const PostDetailScreen: React.FC = () => {
|
||||
const [voteResult, setVoteResult] = useState<VoteResultDTO | null>(null);
|
||||
const [isVoteLoading, setIsVoteLoading] = useState(false);
|
||||
|
||||
// 同步作者主页资料,帖子详情接口可能只返回作者基础信息,粉丝数需要以用户主页数据为准。
|
||||
useEffect(() => {
|
||||
const author = post?.author;
|
||||
if (!author?.id) {
|
||||
setAuthorFollowersCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const authorId = author.id;
|
||||
let cancelled = false;
|
||||
setAuthorFollowersCount(author.followers_count ?? null);
|
||||
|
||||
userManager.getUserById(authorId, true)
|
||||
.then((user) => {
|
||||
if (!cancelled && user) {
|
||||
setAuthorFollowersCount(user.followers_count ?? 0);
|
||||
setIsFollowing(user.is_following || false);
|
||||
setIsFollowingMe(user.is_following_me || false);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('加载作者主页资料失败:', error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [post?.author?.id]);
|
||||
|
||||
// 桌面端侧边栏宽度
|
||||
const sidebarWidth = useResponsiveValue({
|
||||
xs: 0,
|
||||
@@ -378,35 +416,45 @@ export const PostDetailScreen: React.FC = () => {
|
||||
router.replace(hrefs.hrefHome());
|
||||
}, [router]);
|
||||
|
||||
const renderCustomHeader = () => (
|
||||
<View style={styles.customHeader}>
|
||||
<AppBackButton onPress={handleBackPress} style={styles.headerBackButton} />
|
||||
{post?.author ? (
|
||||
<View style={styles.headerContainer}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleUserPress(post.author!.id)}
|
||||
style={styles.headerAvatarWrapper}
|
||||
>
|
||||
<Avatar source={post.author.avatar} size={32} name={post.author.nickname} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.headerUserInfo}>
|
||||
<TouchableOpacity onPress={() => handleUserPress(post.author!.id)}>
|
||||
<Text style={styles.headerNickname} numberOfLines={1}>
|
||||
{post.author.nickname && post.author.nickname.length > 10
|
||||
? `${post.author.nickname.slice(0, 10)}...`
|
||||
: post.author.nickname}
|
||||
</Text>
|
||||
const renderCustomHeader = () => {
|
||||
const followersCount = authorFollowersCount ?? post?.author?.followers_count ?? 0;
|
||||
|
||||
return (
|
||||
<View style={styles.customHeader}>
|
||||
<AppBackButton onPress={handleBackPress} style={styles.headerBackButton} />
|
||||
{post?.author ? (
|
||||
<View style={styles.headerContainer}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleUserPress(post.author!.id)}
|
||||
style={styles.headerAvatarWrapper}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<Avatar source={post.author.avatar} size={42} name={post.author.nickname} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.headerUserInfo}>
|
||||
<TouchableOpacity onPress={() => handleUserPress(post.author!.id)} activeOpacity={0.75}>
|
||||
<View style={styles.headerTitleRow}>
|
||||
<Text style={styles.headerNickname} numberOfLines={1}>
|
||||
{post.author.nickname && post.author.nickname.length > 10
|
||||
? `${post.author.nickname.slice(0, 10)}...`
|
||||
: post.author.nickname}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerMetaText} numberOfLines={1}>
|
||||
{formatNumber(followersCount)} 粉丝
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.headerPlaceholder} />
|
||||
)}
|
||||
<View style={styles.headerRightContainer}>
|
||||
{renderFollowButton()}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.headerPlaceholder} />
|
||||
)}
|
||||
<View style={styles.headerRightContainer}>
|
||||
{renderFollowButton()}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
// 监听键盘事件
|
||||
useEffect(() => {
|
||||
@@ -688,31 +736,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 +752,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 +772,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,
|
||||
@@ -1021,12 +1049,14 @@ export const PostDetailScreen: React.FC = () => {
|
||||
const success = await authService.unfollowUser(post.author.id);
|
||||
if (success) {
|
||||
setIsFollowing(false);
|
||||
setAuthorFollowersCount(prev => prev == null ? prev : Math.max(0, prev - 1));
|
||||
}
|
||||
} else {
|
||||
// 关注
|
||||
const success = await authService.followUser(post.author.id);
|
||||
if (success) {
|
||||
setIsFollowing(true);
|
||||
setAuthorFollowersCount(prev => prev == null ? prev : prev + 1);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1084,6 +1114,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 +1315,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 = () => {
|
||||
@@ -1353,31 +1401,42 @@ export const PostDetailScreen: React.FC = () => {
|
||||
);
|
||||
}, [currentUser?.id, handleDeleteComment, handleImagePress, handleLikeComment, handleLoadMoreReplies, post?.user_id, handleReportComment]);
|
||||
|
||||
// 渲染空评论 - 类似图片中的样式
|
||||
const renderEmptyComments = useCallback(() => (
|
||||
<View style={styles.emptyCommentsContainer}>
|
||||
{/* 四个方块图标 */}
|
||||
<View style={styles.emptyCommentsGrid}>
|
||||
<View style={styles.emptyCommentsGridItem} />
|
||||
<View style={styles.emptyCommentsGridItem} />
|
||||
<View style={styles.emptyCommentsGridItem} />
|
||||
<View style={[styles.emptyCommentsGridItem, styles.emptyCommentsGridItemActive]} />
|
||||
</View>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.emptyCommentsSubtitle}>
|
||||
这里空空如也,邀请好友来互动吧!
|
||||
</Text>
|
||||
</View>
|
||||
), []);
|
||||
|
||||
const openComposer = () => {
|
||||
const openComposer = useCallback(() => {
|
||||
setIsComposerVisible(true);
|
||||
};
|
||||
focusCommentInput();
|
||||
}, [focusCommentInput]);
|
||||
|
||||
const closeComposer = () => {
|
||||
const closeComposer = useCallback(() => {
|
||||
setIsComposerVisible(false);
|
||||
setShowEmojiPanel(false);
|
||||
setReplyingTo(null);
|
||||
Keyboard.dismiss();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 渲染空评论 - 简洁留白状态
|
||||
const renderEmptyComments = useCallback(() => (
|
||||
<View style={styles.emptyCommentsContainer}>
|
||||
<View style={styles.emptyCommentsIllustration}>
|
||||
<View style={styles.emptyCommentsIconCircle}>
|
||||
<MaterialCommunityIcons name="forum-outline" size={40} color={colors.primary.main} />
|
||||
</View>
|
||||
<View style={styles.emptyCommentsBubbleSmall} />
|
||||
<View style={styles.emptyCommentsBubbleTiny} />
|
||||
</View>
|
||||
<Text style={styles.emptyCommentsTitle}>还没有评论</Text>
|
||||
<Text variant="caption" style={styles.emptyCommentsSubtitle}>
|
||||
评论区空空如也,来发表第一条评论吧~
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.emptyCommentsAction}
|
||||
onPress={openComposer}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<Text style={styles.emptyCommentsActionText}>抢沙发</Text>
|
||||
<MaterialCommunityIcons name="chevron-right" size={16} color={colors.primary.main} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
), [colors, openComposer, styles]);
|
||||
|
||||
const handleAtMention = () => {
|
||||
setCommentText(prev => prev + '@');
|
||||
@@ -1479,6 +1538,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
|
||||
{/* Text Input —— 小红书风格:无边框、自动扩展 */}
|
||||
<PostMentionInput
|
||||
ref={commentInputRef}
|
||||
value={commentText}
|
||||
onChangeText={setCommentText}
|
||||
onSegmentsChange={setCommentSegments}
|
||||
@@ -1495,13 +1555,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)}
|
||||
@@ -1516,6 +1575,16 @@ export const PostDetailScreen: React.FC = () => {
|
||||
{/* Toolbar */}
|
||||
<View style={styles.expandedToolbar}>
|
||||
<View style={styles.expandedToolbarLeft}>
|
||||
<TouchableOpacity
|
||||
style={styles.expandedToolbarItem}
|
||||
onPress={closeComposer}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-down"
|
||||
size={22}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.expandedToolbarItem}
|
||||
onPress={handlePickCommentImage}
|
||||
@@ -1740,10 +1809,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 +1881,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
|
||||
@@ -1882,9 +1945,17 @@ function createPostDetailStyles(colors: AppColors) {
|
||||
customHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
height: 52,
|
||||
backgroundColor: colors.background.paper,
|
||||
minHeight: 66,
|
||||
paddingRight: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.divider,
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
zIndex: 10,
|
||||
},
|
||||
headerPlaceholder: {
|
||||
flex: 1,
|
||||
@@ -1894,24 +1965,55 @@ function createPostDetailStyles(colors: AppColors) {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
marginLeft: spacing.sm,
|
||||
marginLeft: spacing.xs,
|
||||
minWidth: 0,
|
||||
},
|
||||
headerAvatarWrapper: {
|
||||
marginRight: spacing.sm,
|
||||
padding: 2,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.primary.main}10`,
|
||||
},
|
||||
headerUserInfo: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
minWidth: 0,
|
||||
},
|
||||
headerTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
},
|
||||
headerNickname: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
fontWeight: '700',
|
||||
color: colors.text.primary,
|
||||
maxWidth: 150,
|
||||
},
|
||||
headerAuthorPill: {
|
||||
marginLeft: spacing.xs,
|
||||
paddingHorizontal: 5,
|
||||
paddingVertical: 1,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: `${colors.primary.main}18`,
|
||||
},
|
||||
headerAuthorPillText: {
|
||||
fontSize: 9,
|
||||
lineHeight: 12,
|
||||
color: colors.primary.main,
|
||||
fontWeight: '700',
|
||||
},
|
||||
headerMetaText: {
|
||||
marginTop: 3,
|
||||
fontSize: fontSizes.sm,
|
||||
lineHeight: fontSizes.sm + 4,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
headerRightContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
marginRight: spacing.xs,
|
||||
marginLeft: spacing.xs,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
headerBackButton: {
|
||||
@@ -1922,38 +2024,42 @@ function createPostDetailStyles(colors: AppColors) {
|
||||
// 帖子容器
|
||||
postContainer: {
|
||||
backgroundColor: colors.background.paper,
|
||||
paddingBottom: spacing.md,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
// 关注按钮样式
|
||||
followButton: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 4,
|
||||
borderRadius: borderRadius.lg,
|
||||
minWidth: 64,
|
||||
paddingVertical: 6,
|
||||
borderRadius: borderRadius.full,
|
||||
minWidth: 72,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
followButtonPrimary: {
|
||||
backgroundColor: colors.primary.main,
|
||||
backgroundColor: `${colors.primary.main}10`,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.primary.main}35`,
|
||||
},
|
||||
followButtonOutline: {
|
||||
backgroundColor: 'transparent',
|
||||
backgroundColor: `${colors.text.primary}04`,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
followButtonMutual: {
|
||||
backgroundColor: 'transparent',
|
||||
backgroundColor: `${colors.primary.main}08`,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
borderColor: `${colors.primary.main}24`,
|
||||
},
|
||||
followButtonLoading: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
followButtonPlaceholder: {
|
||||
minWidth: 64,
|
||||
minWidth: 72,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 4,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
followButtonText: {
|
||||
@@ -1961,7 +2067,7 @@ function createPostDetailStyles(colors: AppColors) {
|
||||
fontWeight: '600',
|
||||
},
|
||||
followButtonTextPrimary: {
|
||||
color: colors.text.inverse,
|
||||
color: colors.primary.main,
|
||||
},
|
||||
followButtonTextOutline: {
|
||||
color: colors.text.secondary,
|
||||
@@ -2185,17 +2291,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,
|
||||
@@ -2305,33 +2400,73 @@ function createPostDetailStyles(colors: AppColors) {
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
// 空评论状态样式(类似图片中的四个方块样式)
|
||||
// 空评论状态样式
|
||||
emptyCommentsContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.xl * 2.5,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingTop: spacing['3xl'],
|
||||
paddingBottom: spacing['4xl'],
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
emptyCommentsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
width: 48,
|
||||
height: 48,
|
||||
marginBottom: spacing.md,
|
||||
emptyCommentsIllustration: {
|
||||
position: 'relative',
|
||||
width: 112,
|
||||
height: 96,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
emptyCommentsGridItem: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
backgroundColor: colors.background.disabled,
|
||||
margin: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
emptyCommentsIconCircle: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: `${colors.primary.main}10`,
|
||||
},
|
||||
emptyCommentsGridItemActive: {
|
||||
backgroundColor: colors.text.hint,
|
||||
emptyCommentsBubbleSmall: {
|
||||
position: 'absolute',
|
||||
right: 14,
|
||||
top: 10,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
backgroundColor: `${colors.primary.light}28`,
|
||||
},
|
||||
emptyCommentsBubbleTiny: {
|
||||
position: 'absolute',
|
||||
left: 18,
|
||||
bottom: 18,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: `${colors.primary.main}18`,
|
||||
},
|
||||
emptyCommentsTitle: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '800',
|
||||
color: colors.text.primary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyCommentsSubtitle: {
|
||||
marginTop: spacing.sm,
|
||||
fontSize: fontSizes.sm,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center',
|
||||
color: colors.text.hint,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
emptyCommentsAction: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
emptyCommentsActionText: {
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '700',
|
||||
color: colors.primary.main,
|
||||
},
|
||||
// 侧边栏样式
|
||||
sidebar: {
|
||||
|
||||
@@ -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, SearchHeader, TabBar } 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);
|
||||
@@ -142,14 +175,21 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
|
||||
try {
|
||||
const searchType = getSearchType();
|
||||
|
||||
|
||||
if (searchType === 'users') {
|
||||
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,18 +472,54 @@ 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();
|
||||
|
||||
|
||||
if (searchType === 'posts') {
|
||||
return renderPostResults();
|
||||
}
|
||||
|
||||
|
||||
if (searchType === 'users') {
|
||||
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,48 +584,36 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
{/* 搜索输入框 */}
|
||||
<View
|
||||
style={[
|
||||
styles.searchHeader,
|
||||
{
|
||||
paddingTop: spacing.sm,
|
||||
paddingHorizontal: responsivePadding,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.searchShell, { maxWidth: searchBarMaxWidth }]}>
|
||||
<SearchBar
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
onSubmit={handleSearch}
|
||||
placeholder="搜索帖子、用户"
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.cancelButton, { marginLeft: responsiveGap }]}
|
||||
onPress={() => (onBack ? onBack() : router.back())}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.primary.main}
|
||||
style={styles.cancelText}
|
||||
>
|
||||
取消
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{/* 搜索顶栏(统一组件,内置硬件返回键拦截) */}
|
||||
<SearchHeader
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
onSubmit={handleSearch}
|
||||
onCancel={onBack ? onBack : () => router.back()}
|
||||
placeholder={isMarket ? "搜索商品、用户" : "搜索帖子、用户"}
|
||||
compact
|
||||
searchBarMaxWidth={searchBarMaxWidth}
|
||||
horizontalPadding={responsivePadding}
|
||||
/>
|
||||
|
||||
{/* Tab切换 */}
|
||||
{/* Tab切换 - 与主页风格一致的下划线样式 */}
|
||||
<View style={styles.tabWrapper}>
|
||||
<TabBar
|
||||
tabs={TABS}
|
||||
@@ -546,8 +624,8 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
performSearch(currentKeyword);
|
||||
}
|
||||
}}
|
||||
variant="modern"
|
||||
icons={['file-document-outline', 'account-outline']}
|
||||
variant="home"
|
||||
style={{ paddingHorizontal: responsivePadding }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -563,34 +641,19 @@ function createSearchScreenStyles(colors: AppColors) {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
searchHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: `${colors.divider}70`,
|
||||
},
|
||||
searchShell: {
|
||||
flex: 1,
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: `${colors.primary.main}12`,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
cancelText: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
tabWrapper: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: `${colors.divider}50`,
|
||||
backgroundColor: colors.background.default,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
suggestionsContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
emptySuggestions: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: -40,
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
@@ -611,13 +674,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,12 @@ import {
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
BackHandler,
|
||||
StatusBar,
|
||||
Alert,
|
||||
} 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 +112,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 +160,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
longPressMenuVisible,
|
||||
selectedMessage,
|
||||
selectedMessageId,
|
||||
setSelectedMessageId,
|
||||
menuPosition,
|
||||
isGroupChat,
|
||||
groupInfo,
|
||||
@@ -169,6 +174,10 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
effectiveGroupName,
|
||||
otherUserLastReadSeq,
|
||||
messageMap,
|
||||
// 引用消息回填(会话级缓存:内存 → 本地 SQLite → 服务端)
|
||||
getCachedReply,
|
||||
ensureReplyMessage,
|
||||
jumpToMessageSeq,
|
||||
loadingMore,
|
||||
hasMoreHistory,
|
||||
conversationId,
|
||||
@@ -196,13 +205,13 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
handleDeleteMessage,
|
||||
handleReplyMessage,
|
||||
handleCancelReply,
|
||||
handleRetrySend,
|
||||
handleAvatarPress,
|
||||
handleAvatarLongPress,
|
||||
handleSelectMention,
|
||||
handleMentionAll,
|
||||
getSenderInfo,
|
||||
getTypingHint,
|
||||
getInputBottom,
|
||||
handleDismiss,
|
||||
navigateToInfo,
|
||||
navigateToChatSettings,
|
||||
@@ -211,7 +220,30 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
handleReachLatestEdge,
|
||||
jumpToLatestMessages,
|
||||
setBrowsingHistory,
|
||||
clearHistoryLock,
|
||||
} = 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(() => {
|
||||
@@ -254,20 +286,43 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleReplyPreviewPress = useCallback((messageId: string) => {
|
||||
const handleReplyPreviewPress = useCallback(async (messageId: string) => {
|
||||
const targetId = String(messageId);
|
||||
|
||||
// 1. 内存命中:直接滚动定位(与原逻辑一致,最快路径)
|
||||
const targetIndex = displayMessages.findIndex(msg => String(msg.id) === targetId);
|
||||
if (targetIndex < 0) return;
|
||||
if (targetIndex >= 0) {
|
||||
replyTargetMessageIdRef.current = targetId;
|
||||
setBrowsingHistory(true);
|
||||
try {
|
||||
flatListRef.current?.scrollToIndex({
|
||||
index: targetIndex,
|
||||
animated: true,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
} catch {
|
||||
// FlashList 远距离跳转可能失败,降级到 offset 滚动
|
||||
flatListRef.current?.scrollToOffset?.({ offset: 0, animated: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 内存未命中:回填被引用消息(内存缓存 → 本地 SQLite → 服务端),拿到 seq
|
||||
if (!ensureReplyMessage) return;
|
||||
const filled = await ensureReplyMessage(targetId);
|
||||
if (!filled || !filled.seq || filled.seq <= 0) {
|
||||
// 已被删除/不可见,或无有效 seq
|
||||
Alert.alert('该引用消息已被删除或不可见');
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 拿到 seq 后复用 scrollToSeq 范式:内存命中定位,未命中循环 loadMoreHistory
|
||||
replyTargetMessageIdRef.current = targetId;
|
||||
setBrowsingHistory(true);
|
||||
|
||||
flatListRef.current?.scrollToIndex({
|
||||
index: targetIndex,
|
||||
animated: true,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, [displayMessages, flatListRef, setBrowsingHistory]);
|
||||
const accepted = jumpToMessageSeq ? jumpToMessageSeq(filled.seq) : false;
|
||||
if (!accepted) {
|
||||
Alert.alert('无法定位该引用消息');
|
||||
}
|
||||
}, [displayMessages, flatListRef, setBrowsingHistory, ensureReplyMessage, jumpToMessageSeq]);
|
||||
|
||||
// 监听返回事件:面板/键盘打开时先关闭它们,否则刷新会话列表后返回
|
||||
useEffect(() => {
|
||||
@@ -332,6 +387,8 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
otherUserLastReadSeq={otherUserLastReadSeq}
|
||||
selectedMessageId={selectedMessageId}
|
||||
messageMap={messageMap}
|
||||
getCachedReply={getCachedReply}
|
||||
ensureReplyMessage={ensureReplyMessage}
|
||||
onLongPress={handleLongPressMessage}
|
||||
onAvatarPress={handleAvatarPress}
|
||||
onAvatarLongPress={handleAvatarLongPress}
|
||||
@@ -340,6 +397,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
onImagePress={handleImagePress}
|
||||
onReply={handleReplyMessage}
|
||||
onReplyPress={handleReplyPreviewPress}
|
||||
onRetrySend={handleRetrySend}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
@@ -352,6 +410,8 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
otherUserLastReadSeq,
|
||||
selectedMessageId,
|
||||
messageMap,
|
||||
getCachedReply,
|
||||
ensureReplyMessage,
|
||||
handleLongPressMessage,
|
||||
handleAvatarPress,
|
||||
handleAvatarLongPress,
|
||||
@@ -360,6 +420,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
handleImagePress,
|
||||
handleReplyMessage,
|
||||
handleReplyPreviewPress,
|
||||
handleRetrySend,
|
||||
]);
|
||||
|
||||
const keyExtractor = useCallback((item: GroupMessage) => String(item.id), []);
|
||||
@@ -387,15 +448,12 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
setBrowsingHistory(true);
|
||||
}
|
||||
|
||||
// 仅用户手势主动回到底部时才解除历史阅读锁(避免加载阶段误解锁)
|
||||
const isScrollingTowardLatest = contentOffset.y < lastScrollYRef.current;
|
||||
if (
|
||||
isUserDraggingRef.current &&
|
||||
!loadingMore &&
|
||||
isScrollingTowardLatest &&
|
||||
contentOffset.y <= 40
|
||||
) {
|
||||
handleReachLatestEdge();
|
||||
// 接近最新消息端时退出历史阅读模式
|
||||
// 修复:不再要求 isUserDraggingRef — 动量滚动结束后、layout settle 后、
|
||||
// 或用户轻触回到底部时都能正确解锁,防止浏览历史锁永久为 true
|
||||
// 导致新消息不自动跟随(仅自己发的才显示的问题)
|
||||
if (!loadingMore && contentOffset.y <= 100) {
|
||||
clearHistoryLock();
|
||||
}
|
||||
|
||||
// inverted 下历史端在“列表尾部”,对应 offset 增大且距离尾部变小
|
||||
@@ -435,14 +493,18 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
loading,
|
||||
messages.length,
|
||||
loadMoreHistory,
|
||||
handleReachLatestEdge,
|
||||
showEdgeLoadingIndicator,
|
||||
setBrowsingHistory,
|
||||
]);
|
||||
|
||||
const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
|
||||
handleMessageListContentSizeChange(contentWidth, contentHeight);
|
||||
}, [handleMessageListContentSizeChange]);
|
||||
// 安全检查:loadMoreHistory 完成后,如果用户仍在底部附近,清除浏览历史锁
|
||||
// 防止 layout settle 后没有触发滚动事件导致锁无法释放
|
||||
if (!loadingMore && scrollPositionRef.current.scrollY <= 100) {
|
||||
clearHistoryLock();
|
||||
}
|
||||
}, [handleMessageListContentSizeChange, loadingMore, clearHistoryLock]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={containerStyle} edges={props.isEmbedded ? [] : ['top']}>
|
||||
@@ -451,7 +513,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 +522,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 +532,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 +553,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 +621,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 +635,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
onInsertEmoji={handleInsertEmoji}
|
||||
onInsertSticker={handleSendSticker}
|
||||
onClose={closePanel}
|
||||
onFocusInput={() => textInputRef.current?.focus()}
|
||||
onFocusInput={stableFocusTextInput}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -657,10 +705,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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* GroupInfoScreen 群组信息/设置界面
|
||||
* 显示群组详细信息,提供群组管理功能
|
||||
* 支持响应式布局
|
||||
* QQ 风格:移除装饰性图标,使用简洁的文字列表
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
@@ -21,15 +22,13 @@ 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';
|
||||
import {
|
||||
spacing,
|
||||
fontSizes,
|
||||
borderRadius,
|
||||
shadows,
|
||||
useAppColors,
|
||||
type AppColors,
|
||||
} from '../../theme';
|
||||
@@ -38,7 +37,7 @@ import { groupService } from '@/services/message';
|
||||
import { uploadService } from '@/services/upload';
|
||||
import { messageService } from '@/services/message';
|
||||
import { Avatar, Text, Button, Loading, Divider } from '../../components/common';
|
||||
import { useResponsive, useBreakpointGTE } from '../../hooks';
|
||||
import { useResponsive } from '../../hooks';
|
||||
import {
|
||||
GroupResponse,
|
||||
GroupMemberResponse,
|
||||
@@ -58,10 +57,10 @@ import { AppBackButton } from '../../components/common';
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
|
||||
// 加群方式选项
|
||||
const JOIN_TYPE_OPTIONS: { value: JoinType; label: string; icon: string; desc: string }[] = [
|
||||
{ value: 0, label: '允许任何人加入', icon: 'earth', desc: '任何人都可以直接加入群聊' },
|
||||
{ value: 1, label: '需要管理员审批', icon: 'shield-check', desc: '新成员需要管理员审核' },
|
||||
{ value: 2, label: '不允许任何人加入', icon: 'lock', desc: '只能通过邀请加入群聊' },
|
||||
const JOIN_TYPE_OPTIONS: { value: JoinType; label: string; desc: string }[] = [
|
||||
{ value: 0, label: '允许任何人加入', desc: '任何人都可以直接加入群聊' },
|
||||
{ value: 1, label: '需要管理员审批', desc: '新成员需要管理员审核' },
|
||||
{ value: 2, label: '不允许任何人加入', desc: '只能通过邀请加入群聊' },
|
||||
];
|
||||
|
||||
const GroupInfoScreen: React.FC = () => {
|
||||
@@ -77,16 +76,7 @@ const GroupInfoScreen: React.FC = () => {
|
||||
const { currentUser } = useAuthStore();
|
||||
|
||||
// 响应式布局
|
||||
const { isDesktop, isTablet, width } = useResponsive();
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
|
||||
// 计算成员网格列数
|
||||
const memberColumns = useMemo(() => {
|
||||
if (width >= 1200) return 6;
|
||||
if (width >= 900) return 5;
|
||||
if (width >= 768) return 4;
|
||||
return 5; // 移动端默认
|
||||
}, [width]);
|
||||
const { width } = useResponsive();
|
||||
|
||||
// 群组信息状态
|
||||
const [group, setGroup] = useState<GroupResponse | null>(null);
|
||||
@@ -512,12 +502,6 @@ const GroupInfoScreen: React.FC = () => {
|
||||
return option?.label || '未知';
|
||||
};
|
||||
|
||||
// 获取加群方式图标
|
||||
const getJoinTypeIcon = (joinType: JoinType): string => {
|
||||
const option = JOIN_TYPE_OPTIONS.find(o => o.value === joinType);
|
||||
return option?.icon || 'help-circle';
|
||||
};
|
||||
|
||||
const handleCopyGroupNo = () => {
|
||||
if (!group) return;
|
||||
Clipboard.setString(String(group.id));
|
||||
@@ -530,47 +514,64 @@ const GroupInfoScreen: React.FC = () => {
|
||||
return `${raw.slice(0, 6)}...${raw.slice(-4)}`;
|
||||
};
|
||||
|
||||
// 渲染设置项
|
||||
// 渲染设置项:QQ 风格,左侧标题,右侧值/箭头
|
||||
const renderSettingItem = (
|
||||
icon: string,
|
||||
title: string,
|
||||
value?: string,
|
||||
onPress?: () => void,
|
||||
danger: boolean = false
|
||||
danger: boolean = false,
|
||||
subtitle?: string
|
||||
) => (
|
||||
<TouchableOpacity
|
||||
style={styles.settingItem}
|
||||
onPress={onPress}
|
||||
disabled={!onPress}
|
||||
activeOpacity={0.7}
|
||||
activeOpacity={onPress ? 0.6 : 1}
|
||||
>
|
||||
<View style={[styles.settingIconContainer, danger && styles.settingIconDanger]}>
|
||||
<MaterialCommunityIcons
|
||||
name={icon as any}
|
||||
size={20}
|
||||
color={danger ? colors.error.main : colors.primary.main}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text variant="body" style={danger ? styles.dangerText : undefined}>
|
||||
<View style={styles.settingItemLeft}>
|
||||
<Text variant="body" style={danger ? styles.dangerText : styles.settingItemTitle}>
|
||||
{title}
|
||||
</Text>
|
||||
{value && (
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
|
||||
{value}
|
||||
{subtitle && (
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={2} style={styles.settingItemSubtitle}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.settingValueRow}>
|
||||
{value && (
|
||||
<Text variant="body" color={colors.text.secondary} numberOfLines={1} style={styles.settingValueText}>
|
||||
{value}
|
||||
</Text>
|
||||
)}
|
||||
{onPress && (
|
||||
<Text variant="body" color={colors.text.hint} style={styles.settingChevron}>
|
||||
›
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{onPress && (
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-right"
|
||||
size={22}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
// 渲染带开关的设置项
|
||||
const renderSwitchItem = (
|
||||
title: string,
|
||||
value: boolean,
|
||||
onValueChange: () => void,
|
||||
disabled?: boolean
|
||||
) => (
|
||||
<View style={styles.settingItem}>
|
||||
<Text variant="body" style={styles.settingItemTitle}>{title}</Text>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
disabled={disabled}
|
||||
trackColor={{ false: colors.divider, true: colors.primary.light }}
|
||||
thumbColor={value ? colors.primary.main : colors.background.paper}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
@@ -618,7 +619,7 @@ const GroupInfoScreen: React.FC = () => {
|
||||
<View style={styles.groupHeader}>
|
||||
<Avatar
|
||||
source={group.avatar}
|
||||
size={80}
|
||||
size={72}
|
||||
name={group.name}
|
||||
/>
|
||||
<View style={styles.groupInfo}>
|
||||
@@ -626,12 +627,10 @@ const GroupInfoScreen: React.FC = () => {
|
||||
{group.name}
|
||||
</Text>
|
||||
<View style={styles.groupMeta}>
|
||||
<MaterialCommunityIcons name="account-group" size={14} color={colors.text.secondary} />
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
{group.member_count} 人
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.hint}> · </Text>
|
||||
<MaterialCommunityIcons name={getJoinTypeIcon(group.join_type) as any} size={14} color={colors.text.secondary} />
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={1} style={{ flex: 1 }}>
|
||||
{getJoinTypeText(group.join_type)}
|
||||
</Text>
|
||||
@@ -641,7 +640,6 @@ const GroupInfoScreen: React.FC = () => {
|
||||
群号:{formatGroupNo(group.id)}
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.copyGroupNoBtn} onPress={handleCopyGroupNo} activeOpacity={0.7}>
|
||||
<MaterialCommunityIcons name="content-copy" size={14} color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.primary.main} style={styles.copyGroupNoBtnText}>
|
||||
复制
|
||||
</Text>
|
||||
@@ -651,14 +649,12 @@ const GroupInfoScreen: React.FC = () => {
|
||||
</View>
|
||||
{group.description ? (
|
||||
<View style={styles.descriptionContainer}>
|
||||
<MaterialCommunityIcons name="information-outline" size={16} color={colors.text.secondary} />
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.groupDesc}>
|
||||
{group.description}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.descriptionContainer}>
|
||||
<MaterialCommunityIcons name="information-outline" size={16} color={colors.text.hint} />
|
||||
<Text variant="body" color={colors.text.hint} style={styles.groupDesc}>
|
||||
暂无群描述
|
||||
</Text>
|
||||
@@ -669,37 +665,32 @@ const GroupInfoScreen: React.FC = () => {
|
||||
{/* 群公告 */}
|
||||
{announcements.length > 0 && (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.cardIconContainer}>
|
||||
<MaterialCommunityIcons name="bullhorn" size={18} color={colors.warning.main} />
|
||||
<TouchableOpacity
|
||||
style={styles.announcementRow}
|
||||
onPress={() => setAnnouncementModalVisible(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.announcementHeader}>
|
||||
<Text variant="body" style={styles.settingItemTitle}>群公告</Text>
|
||||
<Text variant="body" color={colors.text.hint} style={styles.settingChevron}>›</Text>
|
||||
</View>
|
||||
<Text variant="label" style={styles.cardTitle}>群公告</Text>
|
||||
</View>
|
||||
<View style={styles.announcementContent}>
|
||||
<Text variant="body" style={styles.announcementText}>
|
||||
<Text variant="body" color={colors.text.secondary} numberOfLines={2} style={styles.announcementPreviewText}>
|
||||
{announcements[0].content}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.announcementTime}>
|
||||
{(() => {
|
||||
const d = new Date(announcements[0].created_at);
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString();
|
||||
})()}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 成员预览 */}
|
||||
<View style={styles.card}>
|
||||
<TouchableOpacity style={styles.cardHeader} onPress={goToMembers} activeOpacity={0.7}>
|
||||
<View style={styles.cardIconContainer}>
|
||||
<MaterialCommunityIcons name="account-multiple" size={18} color={colors.info.main} />
|
||||
<Text variant="body" style={styles.settingItemTitle}>群成员</Text>
|
||||
<View style={styles.cardValueRow}>
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.memberCount}>
|
||||
{group.member_count}人
|
||||
</Text>
|
||||
<Text variant="body" color={colors.text.hint} style={styles.settingChevron}>›</Text>
|
||||
</View>
|
||||
<Text variant="label" style={styles.cardTitle}>群成员</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.memberCount}>
|
||||
{group.member_count}人
|
||||
</Text>
|
||||
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.memberPreview}>
|
||||
@@ -740,103 +731,54 @@ const GroupInfoScreen: React.FC = () => {
|
||||
|
||||
{/* 邀请成员按钮 */}
|
||||
<TouchableOpacity style={styles.inviteButton} onPress={openInviteModal} activeOpacity={0.8}>
|
||||
<View style={styles.inviteIconContainer}>
|
||||
<MaterialCommunityIcons name="account-plus" size={20} color={colors.primary.main} />
|
||||
</View>
|
||||
<Text variant="body" color={colors.primary.main} style={styles.inviteButtonText}>
|
||||
邀请新成员
|
||||
+ 邀请新成员
|
||||
</Text>
|
||||
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.primary.main} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 群名称 / 群描述 */}
|
||||
<View style={styles.card}>
|
||||
<View style={styles.settingsList}>
|
||||
{isAdmin && renderSettingItem('群名称', group.name, openEditModal)}
|
||||
{isAdmin && <Divider style={styles.settingDivider} />}
|
||||
{isOwner && renderSettingItem('加群方式', getJoinTypeText(group.join_type), () => setJoinTypeModalVisible(true))}
|
||||
{isOwner && <Divider style={styles.settingDivider} />}
|
||||
{isAdmin && renderSettingItem('群描述', group.description || '未设置', openEditModal)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 管理员设置 */}
|
||||
{isAdmin && (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={[styles.cardIconContainer, { backgroundColor: colors.primary.light + '20' }]}>
|
||||
<MaterialCommunityIcons name="shield-crown" size={18} color={colors.primary.main} />
|
||||
</View>
|
||||
<Text variant="label" style={styles.cardTitle}>群管理</Text>
|
||||
</View>
|
||||
<View style={styles.settingsList}>
|
||||
{renderSettingItem('pencil-outline', '修改群信息', undefined, openEditModal)}
|
||||
<Divider style={styles.settingDivider} />
|
||||
{isOwner && renderSettingItem('earth', '加群方式', getJoinTypeText(group.join_type), () => setJoinTypeModalVisible(true))}
|
||||
{renderSettingItem('发布群公告', undefined, () => setAnnouncementModalVisible(true))}
|
||||
{isOwner && <Divider style={styles.settingDivider} />}
|
||||
{isOwner && (
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingIconContainer}>
|
||||
<MaterialCommunityIcons name="microphone-off" size={20} color={colors.primary.main} />
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text variant="body">全员禁言</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
{group.mute_all ? '已开启' : '已关闭'}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={group.mute_all}
|
||||
onValueChange={handleToggleMuteAll}
|
||||
trackColor={{ false: colors.divider, true: colors.primary.light }}
|
||||
thumbColor={group.mute_all ? colors.primary.main : colors.background.paper}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<Divider style={styles.settingDivider} />
|
||||
{renderSettingItem('bullhorn-outline', '发布群公告', undefined, () => setAnnouncementModalVisible(true))}
|
||||
{isOwner && renderSwitchItem('全员禁言', group.mute_all, handleToggleMuteAll)}
|
||||
{isOwner && <Divider style={styles.settingDivider} />}
|
||||
{isOwner && renderSettingItem('account-switch', '转让群主', undefined, () => setTransferModalVisible(true))}
|
||||
{isOwner && renderSettingItem('转让群主', undefined, () => setTransferModalVisible(true))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 聊天设置 */}
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={[styles.cardIconContainer, { backgroundColor: colors.warning.light + '20' }]}>
|
||||
<MaterialCommunityIcons name="chat-outline" size={18} color={colors.warning.main} />
|
||||
</View>
|
||||
<Text variant="label" style={styles.cardTitle}>聊天设置</Text>
|
||||
</View>
|
||||
<View style={styles.settingsList}>
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingIconContainer}>
|
||||
<MaterialCommunityIcons name="pin-outline" size={20} color={colors.primary.main} />
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text variant="body">置顶群聊</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
{conversationId ? (isPinned ? '已置顶' : '未置顶') : '请从聊天页面进入后设置'}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isPinned}
|
||||
onValueChange={handleTogglePinned}
|
||||
disabled={!conversationId || pinLoading}
|
||||
trackColor={{ false: colors.divider, true: colors.primary.light }}
|
||||
thumbColor={isPinned ? colors.primary.main : colors.background.paper}
|
||||
/>
|
||||
</View>
|
||||
{renderSettingItem(
|
||||
'查找聊天记录',
|
||||
'图片、视频、文件等',
|
||||
conversationId
|
||||
? () => router.push(hrefs.hrefMessageSearch({
|
||||
conversationId,
|
||||
conversationName: group?.name || '群聊',
|
||||
isGroupChat: true,
|
||||
}))
|
||||
: undefined,
|
||||
)}
|
||||
<Divider style={styles.settingDivider} />
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingIconContainer}>
|
||||
<MaterialCommunityIcons name="bell-off-outline" size={20} color={colors.primary.main} />
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text variant="body">消息免打扰</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
{conversationId ? (isNotificationMuted ? '已开启' : '未开启') : '请从聊天页面进入后设置'}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isNotificationMuted}
|
||||
onValueChange={handleToggleNotificationMuted}
|
||||
disabled={!conversationId}
|
||||
trackColor={{ false: colors.divider, true: colors.primary.light }}
|
||||
thumbColor={isNotificationMuted ? colors.primary.main : colors.background.paper}
|
||||
/>
|
||||
</View>
|
||||
{renderSwitchItem('设为置顶', isPinned, handleTogglePinned, !conversationId || pinLoading)}
|
||||
<Divider style={styles.settingDivider} />
|
||||
{renderSwitchItem('消息免打扰', isNotificationMuted, handleToggleNotificationMuted, !conversationId)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -848,7 +790,6 @@ const GroupInfoScreen: React.FC = () => {
|
||||
onPress={handleDissolveGroup}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="delete-forever" size={22} color={colors.error.main} />
|
||||
<Text variant="body" color={colors.error.main} style={styles.dangerButtonText}>
|
||||
解散群组
|
||||
</Text>
|
||||
@@ -859,7 +800,6 @@ const GroupInfoScreen: React.FC = () => {
|
||||
onPress={handleLeaveGroup}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="logout" size={22} color={colors.error.main} />
|
||||
<Text variant="body" color={colors.error.main} style={styles.dangerButtonText}>
|
||||
退出群聊
|
||||
</Text>
|
||||
@@ -906,7 +846,7 @@ const GroupInfoScreen: React.FC = () => {
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.editAvatarButton} onPress={handleSelectGroupAvatar} disabled={uploadingAvatar}>
|
||||
<MaterialCommunityIcons name="camera" size={16} color={colors.background.paper} />
|
||||
<Text variant="caption" color={colors.background.paper}>更换</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -1019,13 +959,6 @@ const GroupInfoScreen: React.FC = () => {
|
||||
onPress={() => handleSetJoinType(option.value)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.joinTypeIcon, group.join_type === option.value && styles.joinTypeIconSelected]}>
|
||||
<MaterialCommunityIcons
|
||||
name={option.icon as any}
|
||||
size={24}
|
||||
color={group.join_type === option.value ? colors.primary.main : colors.text.secondary}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.joinTypeInfo}>
|
||||
<Text
|
||||
variant="body"
|
||||
@@ -1038,7 +971,7 @@ const GroupInfoScreen: React.FC = () => {
|
||||
</Text>
|
||||
</View>
|
||||
{group.join_type === option.value && (
|
||||
<MaterialCommunityIcons name="check-circle" size={24} color={colors.primary.main} />
|
||||
<Text variant="body" color={colors.primary.main} style={styles.joinTypeCheck}>✓</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
@@ -1096,7 +1029,7 @@ const GroupInfoScreen: React.FC = () => {
|
||||
</View>
|
||||
<View style={[styles.transferRadio, transferUserId === member.user_id && styles.transferRadioSelected]}>
|
||||
{transferUserId === member.user_id && (
|
||||
<MaterialCommunityIcons name="check" size={14} color={colors.background.paper} />
|
||||
<Text variant="caption" color={colors.background.paper}>✓</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
@@ -1132,16 +1065,17 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.paper,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
pageHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 0,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: colors.divider + '60',
|
||||
},
|
||||
pageTitle: {
|
||||
fontWeight: '600',
|
||||
@@ -1155,7 +1089,6 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.xl * 2,
|
||||
},
|
||||
errorContainer: {
|
||||
@@ -1164,16 +1097,17 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
// 头部区域 - 扁平化无卡片
|
||||
// 头部区域 - QQ 风格简洁白底
|
||||
headerCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
groupHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
groupInfo: {
|
||||
marginLeft: spacing.lg,
|
||||
@@ -1181,7 +1115,7 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
},
|
||||
groupName: {
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes['2xl'],
|
||||
fontSize: fontSizes.xl,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
groupMeta: {
|
||||
@@ -1196,86 +1130,68 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
copyGroupNoBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.light + '15',
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
copyGroupNoBtnText: {
|
||||
marginLeft: 4,
|
||||
fontWeight: '500',
|
||||
},
|
||||
descriptionContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.md,
|
||||
},
|
||||
groupDesc: {
|
||||
marginLeft: spacing.sm,
|
||||
flex: 1,
|
||||
lineHeight: 22,
|
||||
fontWeight: '400',
|
||||
},
|
||||
|
||||
// 通用卡片样式 - 扁平化:无阴影无圆角,使用分割线
|
||||
// 通用卡片样式 - 白底分隔(QQ 风格)
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
marginBottom: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
cardIconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.info.light + '15',
|
||||
justifyContent: 'center',
|
||||
cardValueRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
cardTitle: {
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.md,
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// 公告样式 - 扁平化
|
||||
announcementContent: {
|
||||
backgroundColor: colors.warning.light + '08',
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.warning.main,
|
||||
// 公告样式 - QQ 风格:标题行 + 内容预览
|
||||
announcementRow: {
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
announcementText: {
|
||||
lineHeight: 22,
|
||||
announcementHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.sm,
|
||||
fontWeight: '400',
|
||||
},
|
||||
announcementTime: {
|
||||
textAlign: 'right',
|
||||
fontWeight: '500',
|
||||
announcementPreviewText: {
|
||||
lineHeight: 22,
|
||||
fontWeight: '400',
|
||||
},
|
||||
|
||||
// 成员预览样式
|
||||
memberCount: {
|
||||
marginRight: spacing.xs,
|
||||
fontWeight: '600',
|
||||
color: colors.text.secondary,
|
||||
fontWeight: '500',
|
||||
},
|
||||
memberPreview: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'nowrap',
|
||||
marginBottom: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
paddingLeft: spacing.xs,
|
||||
},
|
||||
memberAvatar: {
|
||||
@@ -1307,8 +1223,6 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
backgroundColor: colors.background.default,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider + '60',
|
||||
marginLeft: -8,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
@@ -1318,88 +1232,81 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
|
||||
// 邀请按钮样式 - 扁平化
|
||||
// 邀请按钮样式
|
||||
inviteButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.primary.light + '10',
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.primary.light + '60',
|
||||
borderStyle: 'dashed',
|
||||
},
|
||||
inviteIconContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.background.default,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
inviteButtonText: {
|
||||
fontWeight: '700',
|
||||
marginRight: spacing.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// 设置项样式 - 扁平化:无圆角背景,简洁分割线
|
||||
// 设置项样式 - QQ 风格:无图标,标题在左,值/箭头在右
|
||||
settingsList: {
|
||||
marginTop: 0,
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: 0,
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
},
|
||||
settingIconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.background.default,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
settingIconDanger: {
|
||||
backgroundColor: colors.error.light + '15',
|
||||
},
|
||||
settingContent: {
|
||||
settingItemLeft: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
settingItemTitle: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '500',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
settingItemSubtitle: {
|
||||
marginTop: spacing.xs,
|
||||
fontWeight: '400',
|
||||
lineHeight: 20,
|
||||
},
|
||||
settingValueRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
settingValueText: {
|
||||
maxWidth: 180,
|
||||
},
|
||||
settingChevron: {
|
||||
fontSize: fontSizes.xl,
|
||||
marginLeft: spacing.xs,
|
||||
fontWeight: '300',
|
||||
},
|
||||
dangerText: {
|
||||
color: colors.error.main,
|
||||
fontWeight: '600',
|
||||
},
|
||||
settingDivider: {
|
||||
marginLeft: 52,
|
||||
width: 'auto',
|
||||
marginVertical: 0,
|
||||
opacity: 0.5,
|
||||
},
|
||||
|
||||
// 危险操作区域 - 扁平化
|
||||
// 危险操作区域
|
||||
dangerCard: {
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
marginTop: spacing.sm,
|
||||
marginTop: 0,
|
||||
},
|
||||
dangerButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
dangerButtonText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// 模态框样式 - 扁平化
|
||||
// 模态框样式
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.45)',
|
||||
@@ -1423,10 +1330,6 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: colors.divider + '60',
|
||||
},
|
||||
modalHeaderButton: {
|
||||
paddingVertical: spacing.sm,
|
||||
minWidth: 60,
|
||||
},
|
||||
modalTitle: {
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.xl,
|
||||
@@ -1434,12 +1337,8 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
saveButton: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
confirmButton: {
|
||||
fontWeight: '600',
|
||||
textAlign: 'right',
|
||||
},
|
||||
|
||||
// 编辑表单样式 - 扁平化
|
||||
// 编辑表单样式
|
||||
editForm: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
@@ -1468,11 +1367,9 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
backgroundColor: colors.primary.main,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: borderRadius.md,
|
||||
borderWidth: 3,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
@@ -1529,36 +1426,25 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// 加群方式样式 - 更现代的选中状态
|
||||
// 加群方式样式
|
||||
joinTypeList: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
joinTypeItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider + '80',
|
||||
backgroundColor: colors.background.default,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
joinTypeItemSelected: {
|
||||
borderColor: colors.primary.main,
|
||||
backgroundColor: colors.primary.light + '12',
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
joinTypeIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.background.paper,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
joinTypeIconSelected: {
|
||||
backgroundColor: colors.primary.light + '25',
|
||||
},
|
||||
joinTypeInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
@@ -1566,6 +1452,10 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
fontWeight: '700',
|
||||
color: colors.primary.main,
|
||||
},
|
||||
joinTypeCheck: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '700',
|
||||
},
|
||||
|
||||
// 转让群主样式
|
||||
transferDesc: {
|
||||
@@ -1619,79 +1509,6 @@ function createGroupInfoStyles(colors: AppColors) {
|
||||
transferButton: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
|
||||
// 邀请成员模态框样式
|
||||
tabContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.xs,
|
||||
marginBottom: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider + '60',
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm,
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider + '60',
|
||||
},
|
||||
tabTextActive: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
selectedBadge: {
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.sm,
|
||||
alignSelf: 'flex-start',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
friendListContent: {
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
friendItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginBottom: spacing.sm,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
friendItemSelected: {
|
||||
backgroundColor: colors.primary.light + '12',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary.light + '60',
|
||||
},
|
||||
friendInfo: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.md,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
nickname: {
|
||||
fontWeight: '600',
|
||||
marginBottom: 2,
|
||||
},
|
||||
checkbox: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.divider,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
checkboxSelected: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
* 显示群成员列表,支持管理员管理成员
|
||||
* 支持响应式网格布局
|
||||
* 使用游标分页
|
||||
* QQ 风格:移除装饰性图标,使用简洁的文字列表
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { hrefUserProfile } from '../../navigation/hrefs';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
@@ -33,8 +33,8 @@ import {
|
||||
ICursorGroupMemberListPagedSource,
|
||||
} from '../../stores/group';
|
||||
import { enrichGroupMembersWithUserProfiles } from '../../stores/group';
|
||||
import { Avatar, Text, Button, Loading, EmptyState, Divider, ResponsiveContainer, AppBackButton } from '../../components/common';
|
||||
import { useResponsive, useBreakpointGTE } from '../../hooks';
|
||||
import { Avatar, Text, Button, Loading, EmptyState, Divider, AppBackButton } from '../../components/common';
|
||||
import { useResponsive } from '../../hooks';
|
||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||
import {
|
||||
GroupMemberResponse,
|
||||
@@ -44,13 +44,6 @@ import { firstRouteParam } from '../../navigation/paramUtils';
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
const GROUP_MEMBER_REMOTE_LIST_KIND: GroupMemberListSourceKind = 'cursor';
|
||||
|
||||
// 网格布局配置
|
||||
const GRID_CONFIG = {
|
||||
mobile: { columns: 1, itemWidth: '100%' },
|
||||
tablet: { columns: 2, itemWidth: '48%' },
|
||||
desktop: { columns: 3, itemWidth: '31%' },
|
||||
};
|
||||
|
||||
// 成员分组
|
||||
interface MemberGroup {
|
||||
title: string;
|
||||
@@ -66,15 +59,7 @@ const GroupMembersScreen: React.FC = () => {
|
||||
const { currentUser } = useAuthStore();
|
||||
|
||||
// 响应式布局
|
||||
const { isDesktop, isTablet, width } = useResponsive();
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
|
||||
// 计算网格列数
|
||||
const gridConfig = useMemo(() => {
|
||||
if (width >= 1024) return GRID_CONFIG.desktop;
|
||||
if (width >= 768) return GRID_CONFIG.tablet;
|
||||
return GRID_CONFIG.mobile;
|
||||
}, [width]);
|
||||
const { width } = useResponsive();
|
||||
|
||||
const memberListSource = useMemo<ICursorGroupMemberListPagedSource>(() => {
|
||||
return createCursorGroupMemberListSource(GROUP_MEMBER_REMOTE_LIST_KIND, groupId, 50);
|
||||
@@ -431,17 +416,17 @@ const GroupMembersScreen: React.FC = () => {
|
||||
</Text>
|
||||
{item.muted && (
|
||||
<View style={styles.mutedBadge}>
|
||||
<MaterialCommunityIcons name="microphone-off" size={12} color={colors.error.main} />
|
||||
<Text variant="label" color={colors.error.main}> 禁言中</Text>
|
||||
<Text variant="label" color={colors.error.main}>禁言中</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{canManage && (
|
||||
<TouchableOpacity
|
||||
style={styles.moreButton}
|
||||
onPress={() => openActionModal(item)}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<MaterialCommunityIcons name="dots-vertical" size={20} color={colors.text.hint} />
|
||||
<Text variant="body" color={colors.text.hint} style={styles.moreButtonText}>···</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
@@ -468,7 +453,6 @@ const GroupMembersScreen: React.FC = () => {
|
||||
<EmptyState
|
||||
title="暂无成员"
|
||||
description="群组还没有成员"
|
||||
icon="account-group-outline"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -509,11 +493,6 @@ const GroupMembersScreen: React.FC = () => {
|
||||
onPress={handleToggleAdmin}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={selectedMember.role === 'admin' ? 'account-remove' : 'account-plus'}
|
||||
size={22}
|
||||
color={colors.text.primary}
|
||||
/>
|
||||
<Text variant="body" style={styles.actionText}>
|
||||
{selectedMember.role === 'admin' ? '取消管理员' : '设为管理员'}
|
||||
</Text>
|
||||
@@ -526,11 +505,6 @@ const GroupMembersScreen: React.FC = () => {
|
||||
onPress={handleToggleMute}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={selectedMember.muted ? 'microphone' : 'microphone-off'}
|
||||
size={22}
|
||||
color={selectedMember.muted ? colors.success.main : colors.error.main}
|
||||
/>
|
||||
<Text
|
||||
variant="body"
|
||||
color={selectedMember.muted ? colors.success.main : colors.error.main}
|
||||
@@ -546,7 +520,6 @@ const GroupMembersScreen: React.FC = () => {
|
||||
onPress={handleRemoveMember}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<MaterialCommunityIcons name="account-remove" size={22} color={colors.error.main} />
|
||||
<Text variant="body" color={colors.error.main} style={styles.actionText}>
|
||||
移除成员
|
||||
</Text>
|
||||
@@ -690,21 +663,20 @@ function createGroupMembersStyles(colors: AppColors) {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
// Header 样式 - 扁平化
|
||||
// Header 样式
|
||||
pageHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: colors.divider + '60',
|
||||
},
|
||||
pageTitle: {
|
||||
fontWeight: '800',
|
||||
fontSize: fontSizes.xl + 1,
|
||||
letterSpacing: 0.5,
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.xl,
|
||||
},
|
||||
pageHeaderPlaceholder: {
|
||||
width: 40,
|
||||
@@ -712,6 +684,7 @@ function createGroupMembersStyles(colors: AppColors) {
|
||||
},
|
||||
section: {
|
||||
marginBottom: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
@@ -720,8 +693,6 @@ function createGroupMembersStyles(colors: AppColors) {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: colors.background.default,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: colors.divider + '40',
|
||||
},
|
||||
memberItem: {
|
||||
flexDirection: 'row',
|
||||
@@ -742,25 +713,31 @@ function createGroupMembersStyles(colors: AppColors) {
|
||||
marginBottom: 2,
|
||||
},
|
||||
memberName: {
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.md + 1,
|
||||
letterSpacing: 0.3,
|
||||
fontWeight: '500',
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
roleBadge: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
marginLeft: spacing.sm,
|
||||
fontWeight: '800',
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.background.paper,
|
||||
},
|
||||
mutedBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 2,
|
||||
},
|
||||
// 模态框样式 - 更现代
|
||||
moreButton: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
minWidth: 36,
|
||||
alignItems: 'center',
|
||||
},
|
||||
moreButtonText: {
|
||||
fontSize: fontSizes['2xl'],
|
||||
fontWeight: '700',
|
||||
lineHeight: fontSizes['2xl'],
|
||||
color: colors.text.hint,
|
||||
},
|
||||
// 模态框样式
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.45)',
|
||||
@@ -781,32 +758,25 @@ function createGroupMembersStyles(colors: AppColors) {
|
||||
borderBottomColor: colors.divider + '60',
|
||||
},
|
||||
modalTitle: {
|
||||
fontWeight: '800',
|
||||
fontSize: fontSizes.xl + 1,
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.xl,
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
actionItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
marginLeft: -spacing.sm,
|
||||
marginRight: -spacing.sm,
|
||||
borderRadius: borderRadius.md,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: colors.divider + '40',
|
||||
},
|
||||
actionText: {
|
||||
marginLeft: spacing.md,
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
inputLabel: {
|
||||
marginBottom: spacing.xs,
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.sm + 1,
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.background.default,
|
||||
@@ -815,7 +785,7 @@ function createGroupMembersStyles(colors: AppColors) {
|
||||
paddingVertical: spacing.md,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
borderWidth: 1.5,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider + '80',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
@@ -61,7 +61,7 @@ import { ConversationListRow } from './components/ConversationListRow';
|
||||
// 导入 NotificationsScreen 用于在内部显示
|
||||
import { NotificationsScreen } from './NotificationsScreen';
|
||||
// 导入扫码组件
|
||||
import { SearchBar, TabBar } from '../../components/business';
|
||||
import { SearchBar, SearchHeader, TabBar } from '../../components/business';
|
||||
import { QRCodeScanner } from '../../components/business/QRCodeScanner';
|
||||
|
||||
// 系统通知会话特殊ID
|
||||
@@ -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角标显示)
|
||||
@@ -666,33 +668,27 @@ export const MessageListScreen: React.FC = () => {
|
||||
// 渲染搜索模式UI(扁平化现代化设计)
|
||||
const renderSearchMode = () => (
|
||||
<View style={styles.searchModeContainer}>
|
||||
{/* 搜索头部 */}
|
||||
<View style={[styles.searchHeader, { paddingTop: spacing.sm }]}>
|
||||
<View style={styles.searchShell}>
|
||||
<SearchBar
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
onSubmit={() => performSearch(searchText)}
|
||||
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={handleCloseSearch}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.cancelText}>取消</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{/* 搜索顶栏(统一组件,内置硬件返回键拦截) */}
|
||||
<SearchHeader
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
onSubmit={() => performSearch(searchText)}
|
||||
onCancel={handleCloseSearch}
|
||||
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
|
||||
compact
|
||||
/>
|
||||
|
||||
{/* Tab切换 - 现代化风格 */}
|
||||
{/* Tab切换 - 与主页一致的下划线风格 */}
|
||||
<View style={styles.searchTabContainer}>
|
||||
<TabBar
|
||||
tabs={['聊天记录', '用户']}
|
||||
activeIndex={activeSearchTab === 'chat' ? 0 : 1}
|
||||
onTabChange={(index) => setActiveSearchTab(index === 0 ? 'chat' : 'user')}
|
||||
variant="modern"
|
||||
variant="home"
|
||||
style={{
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -840,25 +836,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 +867,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 +878,7 @@ export const MessageListScreen: React.FC = () => {
|
||||
renderConversationList()
|
||||
)}
|
||||
{renderActionMenu()}
|
||||
<QRCodeScanner visible={scannerVisible} onClose={() => setScannerVisible(false)} />
|
||||
<QRCodeScanner visible={scannerVisible} onClose={stableCloseScanner} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
@@ -1046,32 +1042,10 @@ function createMessageListStyles(colors: AppColors) {
|
||||
},
|
||||
searchModeContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
searchHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: `${colors.divider}70`,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
searchShell: {
|
||||
flex: 1,
|
||||
},
|
||||
cancelButton: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
cancelText: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
color: colors.primary.main,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
searchTabContainer: {
|
||||
backgroundColor: colors.background.paper,
|
||||
backgroundColor: colors.background.default,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: `${colors.divider}50`,
|
||||
},
|
||||
|
||||
315
src/screens/message/MessageSearchScreen.tsx
Normal file
315
src/screens/message/MessageSearchScreen.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* 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,
|
||||
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 } from '../../components/common';
|
||||
import { SearchHeader } 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={['top', 'bottom']}>
|
||||
{/* 搜索顶栏(与 HomeScreen 搜索页一致的统一组件) */}
|
||||
<SearchHeader
|
||||
value={keyword}
|
||||
onChangeText={handleTextChanged}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => router.back()}
|
||||
placeholder={`搜索 ${conversationName} 的聊天记录`}
|
||||
compact
|
||||
/>
|
||||
|
||||
<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,
|
||||
},
|
||||
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));
|
||||
}
|
||||
// 其他类型暂不处理跳转
|
||||
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { spacing, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { authService } from '@/services/auth';
|
||||
import { messageService } from '@/services/message';
|
||||
@@ -133,6 +132,9 @@ const PrivateChatInfoScreen: React.FC = () => {
|
||||
onPress: async () => {
|
||||
try {
|
||||
await messageRepository.clearConversation(conversationId);
|
||||
// 同步清空内存消息 + hydrated 标志,保证返回聊天页时状态与 DB 一致。
|
||||
// 否则 hydrated 残留 + 内存旧消息会显示已清空的记录。
|
||||
useMessageStore.getState().clearMessages(conversationId);
|
||||
Alert.alert('成功', '聊天记录已清空');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '清空聊天记录失败');
|
||||
@@ -145,8 +147,11 @@ const PrivateChatInfoScreen: React.FC = () => {
|
||||
|
||||
// 查找聊天记录
|
||||
const handleSearchMessages = () => {
|
||||
// TODO: 实现聊天记录搜索功能
|
||||
Alert.alert('提示', '聊天记录搜索功能开发中');
|
||||
router.push(hrefs.hrefMessageSearch({
|
||||
conversationId,
|
||||
conversationName: userName || user?.nickname || '私聊',
|
||||
isGroupChat: false,
|
||||
}));
|
||||
};
|
||||
|
||||
// 查看用户资料
|
||||
@@ -256,34 +261,28 @@ const PrivateChatInfoScreen: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染设置项(带开关)
|
||||
// 渲染开关设置项 - QQ 风格:左侧标题,右侧开关
|
||||
const renderSwitchItem = (
|
||||
icon: string,
|
||||
title: string,
|
||||
value: boolean,
|
||||
onValueChange: () => void
|
||||
) => (
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingIconContainer}>
|
||||
<MaterialCommunityIcons name={icon as any} size={20} color={colors.primary.main} />
|
||||
</View>
|
||||
<Text variant="body" style={styles.settingTitle}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text variant="body" style={styles.settingTitle}>{title}</Text>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ false: '#E0E0E0', true: colors.primary.light }}
|
||||
thumbColor={value ? colors.primary.main : colors.background.default}
|
||||
trackColor={{ false: colors.divider, true: colors.primary.light }}
|
||||
thumbColor={value ? colors.primary.main : colors.background.paper}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染设置项(带箭头)
|
||||
// 渲染操作项 - QQ 风格:左侧标题,右侧值/箭头
|
||||
const renderActionItem = (
|
||||
icon: string,
|
||||
title: string,
|
||||
onPress: () => void,
|
||||
value?: string,
|
||||
danger: boolean = false
|
||||
) => (
|
||||
<TouchableOpacity
|
||||
@@ -291,17 +290,17 @@ const PrivateChatInfoScreen: React.FC = () => {
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.settingIconContainer, danger && styles.settingIconDanger]}>
|
||||
<MaterialCommunityIcons
|
||||
name={icon as any}
|
||||
size={20}
|
||||
color={danger ? colors.error.main : colors.primary.main}
|
||||
/>
|
||||
</View>
|
||||
<Text variant="body" style={danger ? [styles.settingTitle, styles.dangerText] : styles.settingTitle}>
|
||||
<Text variant="body" style={danger ? styles.dangerText : styles.settingTitle}>
|
||||
{title}
|
||||
</Text>
|
||||
<MaterialCommunityIcons name="chevron-right" size={22} color={colors.text.hint} />
|
||||
<View style={styles.settingValueRow}>
|
||||
{value && (
|
||||
<Text variant="body" color={colors.text.secondary} numberOfLines={1} style={styles.settingValueText}>
|
||||
{value}
|
||||
</Text>
|
||||
)}
|
||||
<Text variant="body" color={colors.text.hint} style={styles.settingChevron}>›</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -345,7 +344,7 @@ const PrivateChatInfoScreen: React.FC = () => {
|
||||
>
|
||||
<Avatar
|
||||
source={displayUser.avatar}
|
||||
size={80}
|
||||
size={72}
|
||||
name={displayUser.nickname || ''}
|
||||
/>
|
||||
<View style={styles.userInfo}>
|
||||
@@ -358,59 +357,53 @@ const PrivateChatInfoScreen: React.FC = () => {
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<MaterialCommunityIcons name="chevron-right" size={24} color={colors.text.hint} />
|
||||
<Text variant="body" color={colors.text.hint} style={styles.settingChevron}>›</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 聊天设置 */}
|
||||
<View style={styles.section}>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
聊天设置
|
||||
</Text>
|
||||
<View style={styles.card}>
|
||||
{renderSwitchItem('bell-off-outline', '消息免打扰', isMuted, toggleMute)}
|
||||
<View style={styles.card}>
|
||||
<View style={styles.settingsList}>
|
||||
{renderSwitchItem('消息免打扰', isMuted, toggleMute)}
|
||||
<Divider style={styles.divider} />
|
||||
{renderSwitchItem('pin-outline', '置顶聊天', isPinned, togglePin)}
|
||||
{renderSwitchItem('置顶聊天', isPinned, togglePin)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 聊天记录管理 */}
|
||||
<View style={styles.section}>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
聊天记录
|
||||
</Text>
|
||||
<View style={styles.card}>
|
||||
{renderActionItem('magnify', '查找聊天记录', handleSearchMessages)}
|
||||
<View style={styles.card}>
|
||||
<View style={styles.settingsList}>
|
||||
{renderActionItem('查找聊天记录', handleSearchMessages)}
|
||||
<Divider style={styles.divider} />
|
||||
{renderActionItem('delete-sweep-outline', '清空聊天记录', handleClearHistory, true)}
|
||||
{renderActionItem('清空聊天记录', handleClearHistory, undefined, true)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 其他操作 */}
|
||||
<View style={styles.section}>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
其他
|
||||
</Text>
|
||||
<View style={styles.card}>
|
||||
{renderActionItem('shield-alert-outline', '投诉', handleReport, true)}
|
||||
<View style={styles.card}>
|
||||
<View style={styles.settingsList}>
|
||||
{renderActionItem('投诉', handleReport, undefined, true)}
|
||||
<Divider style={styles.divider} />
|
||||
{renderActionItem(
|
||||
isBlocked ? 'account-check-outline' : 'account-cancel-outline',
|
||||
isBlocked ? '取消拉黑' : '拉黑用户',
|
||||
handleBlockUser,
|
||||
undefined,
|
||||
true
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 删除聊天按钮 */}
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="删除并退出聊天"
|
||||
<View style={[styles.card, styles.dangerCard]}>
|
||||
<TouchableOpacity
|
||||
style={styles.dangerButton}
|
||||
onPress={handleDeleteAndExit}
|
||||
variant="danger"
|
||||
style={styles.deleteButton}
|
||||
/>
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text variant="body" color={colors.error.main} style={styles.dangerButtonText}>
|
||||
删除并退出聊天
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
@@ -421,16 +414,17 @@ function createPrivateChatInfoStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.paper,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
pageHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 0,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: colors.divider + '60',
|
||||
},
|
||||
pageTitle: {
|
||||
fontWeight: '600',
|
||||
@@ -444,11 +438,11 @@ function createPrivateChatInfoStyles(colors: AppColors) {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.xl,
|
||||
paddingBottom: spacing.xl * 2,
|
||||
},
|
||||
headerCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
@@ -462,57 +456,62 @@ function createPrivateChatInfoStyles(colors: AppColors) {
|
||||
},
|
||||
userName: {
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes['2xl'],
|
||||
fontSize: fontSizes.xl,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: spacing.sm,
|
||||
marginLeft: spacing.sm,
|
||||
fontWeight: '500',
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
overflow: 'hidden',
|
||||
marginBottom: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
settingsList: {
|
||||
marginTop: 0,
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: 0,
|
||||
},
|
||||
settingIconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.background.default,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
settingIconDanger: {
|
||||
backgroundColor: colors.error.light + '15',
|
||||
},
|
||||
settingTitle: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '500',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
settingValueRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
settingValueText: {
|
||||
maxWidth: 180,
|
||||
},
|
||||
settingChevron: {
|
||||
fontSize: fontSizes.xl,
|
||||
marginLeft: spacing.xs,
|
||||
fontWeight: '300',
|
||||
},
|
||||
dangerText: {
|
||||
color: colors.error.main,
|
||||
fontWeight: '600',
|
||||
},
|
||||
divider: {
|
||||
marginLeft: 52,
|
||||
width: 'auto',
|
||||
marginVertical: 0,
|
||||
opacity: 0.5,
|
||||
},
|
||||
deleteButton: {
|
||||
marginTop: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
height: 52,
|
||||
dangerCard: {
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
marginTop: 0,
|
||||
},
|
||||
dangerButton: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.lg,
|
||||
},
|
||||
dangerButtonText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -216,10 +216,9 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
<TouchableOpacity
|
||||
style={styles.sendButtonActive}
|
||||
onPress={onSend}
|
||||
activeOpacity={0.85}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<View style={styles.sendButtonShine} pointerEvents="none" />
|
||||
<MaterialCommunityIcons name="send" size={18} color="#FFF" />
|
||||
<Text style={styles.sendButtonText}>发送</Text>
|
||||
</TouchableOpacity>
|
||||
) : isComposerBusy && !isDisabled ? (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -4,21 +4,23 @@
|
||||
* 支持响应式布局(宽屏下优化显示)
|
||||
*/
|
||||
|
||||
import React, { useRef, useMemo, useCallback } from 'react';
|
||||
import React, { useRef, useMemo, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
GestureResponderEvent,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Avatar, Text, ImageGridItem } from '../../../../components/common';
|
||||
import { useChatScreenStyles } from './styles';
|
||||
import { useResponsive, useBreakpointGTE } from '../../../../hooks';
|
||||
import { MessageBubbleProps, SenderInfo, MenuPosition, GroupMessage } from './types';
|
||||
import { MessageBubbleProps, SenderInfo, MenuPosition } from './types';
|
||||
import { MessageSegmentsRenderer } from './SegmentRenderer';
|
||||
import { MessageSegment, ImageSegmentData, extractTextFromSegments } from '../../../../types/dto';
|
||||
import { MessageResponse } from '@/types/dto/message';
|
||||
import { SwipeableMessageBubble } from './SwipeableMessageBubble';
|
||||
// 获取屏幕宽度
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
@@ -41,6 +43,8 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
otherUserLastReadSeq,
|
||||
selectedMessageId,
|
||||
messageMap,
|
||||
getCachedReply,
|
||||
ensureReplyMessage,
|
||||
onLongPress,
|
||||
onAvatarPress,
|
||||
onAvatarLongPress,
|
||||
@@ -49,6 +53,7 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
onImagePress,
|
||||
onReply,
|
||||
onReplyPress,
|
||||
onRetrySend,
|
||||
}) => {
|
||||
const bubbleRef = useRef<View>(null);
|
||||
const pressPositionRef = useRef({ x: 0, y: 0 });
|
||||
@@ -93,6 +98,8 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
const isMe = message.sender_id === currentUserId;
|
||||
const showTime = shouldShowTime(index);
|
||||
const isRecalled = message.status === 'recalled';
|
||||
const isPending = message.status === 'pending';
|
||||
const isFailed = message.status === 'failed';
|
||||
const isRead = !isGroupChat && isMe && message.seq <= otherUserLastReadSeq;
|
||||
const isLastReadMessage = !isGroupChat && isMe && message.seq === otherUserLastReadSeq;
|
||||
|
||||
@@ -107,6 +114,24 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
const isPureImageMessage =
|
||||
segmentsWithoutReply.length > 0 &&
|
||||
segmentsWithoutReply.every(s => s.type === 'image');
|
||||
|
||||
// 引用消息回填:本条消息若含 reply segment 且被引用消息不在 messageMap 中,
|
||||
// 渲染时触发懒加载(内存缓存 → 本地 SQLite → 服务端),回填成功后由缓存版本变化驱动重渲染。
|
||||
const replySegmentId = useMemo(() => {
|
||||
const replySeg = segments.find(s => s.type === 'reply');
|
||||
if (!replySeg) return null;
|
||||
const replyId = String((replySeg.data as { id?: string } | undefined)?.id ?? '');
|
||||
return replyId || null;
|
||||
}, [segments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!replySegmentId || !ensureReplyMessage) return;
|
||||
// messageMap 命中则无需回填
|
||||
if (messageMap?.has(replySegmentId)) return;
|
||||
// 已回填过(含 null 不可见)则不重复请求
|
||||
if (getCachedReply?.(replySegmentId) !== undefined) return;
|
||||
ensureReplyMessage(replySegmentId);
|
||||
}, [replySegmentId, messageMap, ensureReplyMessage, getCachedReply]);
|
||||
|
||||
// 提取所有图片 segments
|
||||
const imageSegments = segments
|
||||
@@ -273,23 +298,27 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
}
|
||||
|
||||
// 使用消息链渲染(必须使用 segments 格式)
|
||||
// 从 segments 中获取被回复消息的 ID 和 seq,然后从 messageMap 中查找
|
||||
const getReplyMessage = (): GroupMessage | undefined => {
|
||||
// 从 segments 中获取被回复消息的 ID 和 seq,依次查 messageMap → 回填缓存
|
||||
const getReplyMessage = (): MessageResponse | undefined => {
|
||||
const replySegment = segments.find(s => s.type === 'reply');
|
||||
if (replySegment && messageMap) {
|
||||
const replyData = replySegment.data as { id: string; seq?: number };
|
||||
const replyId = String(replyData.id);
|
||||
|
||||
// 首先尝试通过 ID 查找
|
||||
if (!replySegment) return undefined;
|
||||
const replyData = replySegment.data as { id: string; seq?: number };
|
||||
const replyId = String(replyData.id);
|
||||
|
||||
// 1. messageMap 命中(已加载到内存的消息)
|
||||
if (messageMap) {
|
||||
let found = messageMap.get(replyId);
|
||||
|
||||
// 如果通过 ID 找不到,尝试通过 seq 查找
|
||||
// 通过 ID 找不到,尝试通过 seq 查找
|
||||
if (!found && replyData.seq !== undefined) {
|
||||
found = Array.from(messageMap.values()).find(msg => msg.seq === replyData.seq);
|
||||
}
|
||||
|
||||
return found;
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
// 2. 回填缓存命中(懒加载回填的被引用消息,可能来自本地 SQLite 或服务端)
|
||||
const cached = getCachedReply?.(replyId);
|
||||
if (cached) return cached;
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -311,13 +340,21 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
// 检查是否有回复,用于调整气泡样式
|
||||
const hasReply = segments.some(s => s.type === 'reply');
|
||||
|
||||
// 计算引用回填状态,供占位区分"加载中"与"不可见"
|
||||
const replyMsg = getReplyMessage();
|
||||
// replyMsg 命中(含 messageMap 与缓存)即为 ready,无需 loading/notFound 标记
|
||||
const replyLoading = !replyMsg && replySegmentId ? getCachedReply?.(replySegmentId) === undefined : false;
|
||||
const replyNotFound = !replyMsg && replySegmentId ? getCachedReply?.(replySegmentId) === null : false;
|
||||
|
||||
return renderBubbleShell(
|
||||
<MessageSegmentsRenderer
|
||||
segments={segments}
|
||||
isMe={isMe}
|
||||
currentUserId={currentUserId}
|
||||
memberMap={memberMap}
|
||||
replyMessage={getReplyMessage()}
|
||||
replyMessage={replyMsg}
|
||||
replyLoading={replyLoading}
|
||||
replyNotFound={replyNotFound}
|
||||
getSenderInfo={getSenderInfo}
|
||||
onAtPress={() => undefined}
|
||||
onReplyPress={onReplyPress}
|
||||
@@ -431,18 +468,46 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
)}
|
||||
|
||||
{renderMessageContent()}
|
||||
|
||||
{/* 私聊模式:显示已读标记 */}
|
||||
{isLastReadMessage && (
|
||||
<View style={styles.readStatusContainer}>
|
||||
<MaterialCommunityIcons
|
||||
name="check-all"
|
||||
size={14}
|
||||
color="#34C759"
|
||||
/>
|
||||
<Text style={[styles.readStatusText, styles.readStatusTextRead]}>
|
||||
已读
|
||||
</Text>
|
||||
|
||||
{/* 发送状态指示器 */}
|
||||
{isMe && (
|
||||
<View style={styles.sendStatusContainer}>
|
||||
{isPending && (
|
||||
<View style={styles.sendStatusRow}>
|
||||
<ActivityIndicator size="small" color="#999" style={styles.sendStatusIndicator} />
|
||||
<Text style={styles.pendingStatusText}>发送中</Text>
|
||||
</View>
|
||||
)}
|
||||
{isFailed && (
|
||||
// 点击「发送失败」重试:仅对自己发送的失败消息提供重试入口。
|
||||
// 用 Pressable 而非 TouchableOpacity,避免与外层长按手势冲突;
|
||||
// 失败状态本身是终态,重试期间会被替换为 pending。
|
||||
<TouchableOpacity
|
||||
style={styles.sendStatusRow}
|
||||
onPress={() => onRetrySend?.(message)}
|
||||
disabled={!onRetrySend}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="alert-circle"
|
||||
size={14}
|
||||
color="#FF3B30"
|
||||
/>
|
||||
<Text style={styles.failedStatusText}>发送失败,点击重试</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{!isPending && !isFailed && isLastReadMessage && (
|
||||
<View style={styles.sendStatusRow}>
|
||||
<MaterialCommunityIcons
|
||||
name="check-all"
|
||||
size={14}
|
||||
color="#34C759"
|
||||
/>
|
||||
<Text style={[styles.readStatusText, styles.readStatusTextRead]}>
|
||||
已读
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -509,6 +574,9 @@ export const MessageBubble = React.memo(MessageBubbleInner, (prev, next) => {
|
||||
if (prev.currentUser !== next.currentUser) return false;
|
||||
if (prev.otherUser !== next.otherUser) return false;
|
||||
if (prev.messageMap !== next.messageMap) return false;
|
||||
// getCachedReply 引用在缓存回填后变化,驱动引用预览从"加载中"→"显示内容"重渲染
|
||||
if (prev.getCachedReply !== next.getCachedReply) return false;
|
||||
if (prev.ensureReplyMessage !== next.ensureReplyMessage) return false;
|
||||
if (prev.onAvatarLongPress !== next.onAvatarLongPress) return false;
|
||||
if (prev.onAvatarPress !== next.onAvatarPress) return false;
|
||||
return true;
|
||||
|
||||
@@ -66,6 +66,9 @@ export interface SegmentRendererProps {
|
||||
export interface ReplyPreviewSegmentProps {
|
||||
replyData: ReplySegmentData;
|
||||
replyMessage?: MessageResponse;
|
||||
// 回填状态:区分"加载中"与"已被删除/不可见",避免占位文案误导
|
||||
replyLoading?: boolean;
|
||||
replyNotFound?: boolean;
|
||||
isMe: boolean;
|
||||
onPress?: () => void;
|
||||
getSenderInfo?: (senderId: string) => SenderInfo;
|
||||
@@ -476,23 +479,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 +584,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>
|
||||
);
|
||||
};
|
||||
@@ -582,6 +671,8 @@ const renderLinkSegment = (
|
||||
export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
|
||||
replyData,
|
||||
replyMessage,
|
||||
replyLoading,
|
||||
replyNotFound,
|
||||
isMe,
|
||||
onPress,
|
||||
getSenderInfo,
|
||||
@@ -591,7 +682,12 @@ export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
|
||||
const styles = useMemo(() => createSegmentStyles(themeColors, fontSize), [themeColors, fontSize]);
|
||||
|
||||
if (!replyMessage) {
|
||||
// 如果没有引用消息详情,只显示引用ID
|
||||
// 回填进行中:显示加载占位;已删除/不可见:显示提示文案
|
||||
const placeholder = replyNotFound
|
||||
? '引用消息已被删除或不可见'
|
||||
: replyLoading
|
||||
? '加载中…'
|
||||
: '引用消息';
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.replyPreview, isMe ? styles.replyMe : styles.replyOther]}
|
||||
@@ -601,7 +697,7 @@ export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
|
||||
<View style={styles.replyLine} />
|
||||
<View style={styles.replyContent}>
|
||||
<Text style={styles.replyText} numberOfLines={2}>
|
||||
引用消息
|
||||
{placeholder}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
@@ -680,6 +776,8 @@ const MessageSegmentsRendererInner: React.FC<{
|
||||
currentUserId?: string;
|
||||
memberMap?: Map<string, { nickname: string; user_id: string }>;
|
||||
replyMessage?: MessageResponse;
|
||||
replyLoading?: boolean;
|
||||
replyNotFound?: boolean;
|
||||
onAtPress?: (userId: string) => void;
|
||||
onReplyPress?: (messageId: string) => void;
|
||||
onImagePress?: (url: string) => void;
|
||||
@@ -692,6 +790,8 @@ const MessageSegmentsRendererInner: React.FC<{
|
||||
currentUserId,
|
||||
memberMap,
|
||||
replyMessage,
|
||||
replyLoading,
|
||||
replyNotFound,
|
||||
onAtPress,
|
||||
onReplyPress,
|
||||
onImagePress,
|
||||
@@ -725,6 +825,8 @@ const MessageSegmentsRendererInner: React.FC<{
|
||||
<ReplyPreviewSegment
|
||||
replyData={replySegment.data as ReplySegmentData}
|
||||
replyMessage={replyMessage}
|
||||
replyLoading={replyLoading}
|
||||
replyNotFound={replyNotFound}
|
||||
isMe={isMe}
|
||||
onPress={() => onReplyPress?.((replySegment.data as ReplySegmentData).id)}
|
||||
getSenderInfo={getSenderInfo}
|
||||
@@ -977,7 +1079,10 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
// 文件 - QQ风格:卡片式设计
|
||||
// 文件 - 卡片式设计
|
||||
// 注意:文件卡片渲染在聊天气泡内部,因此卡片本身不再绘制背景与阴影。
|
||||
// 自带的底色会与气泡背景叠加出「白框」,阴影会被气泡的 overflow:'hidden'
|
||||
// 裁切成「黑框」,二者都会遮挡文件内容。这里改为透明,让内容直接落在气泡上。
|
||||
fileContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -985,17 +1090,12 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
|
||||
borderRadius: 16,
|
||||
minWidth: 220,
|
||||
maxWidth: 300,
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
fileMe: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.25)',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
fileOther: {
|
||||
backgroundColor: colors.chat.surfaceRaised,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
fileIcon: {
|
||||
marginRight: spacing.md,
|
||||
@@ -1021,6 +1121,14 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// 文件过期失效态
|
||||
fileExpired: {
|
||||
opacity: 0.65,
|
||||
},
|
||||
fileExpiredText: {
|
||||
color: colors.chat.textTertiary,
|
||||
},
|
||||
|
||||
// 链接 - QQ风格:卡片式设计
|
||||
linkContainer: {
|
||||
borderRadius: 16,
|
||||
@@ -1136,6 +1244,8 @@ export const MessageSegmentsRenderer = React.memo(MessageSegmentsRendererInner,
|
||||
if (prev.currentUserId !== next.currentUserId) return false;
|
||||
if (prev.memberMap !== next.memberMap) return false;
|
||||
if (prev.replyMessage !== next.replyMessage) return false;
|
||||
if (prev.replyLoading !== next.replyLoading) return false;
|
||||
if (prev.replyNotFound !== next.replyNotFound) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user