Compare commits

3 Commits

Author SHA1 Message Date
lafay
0e79dbf655 feat(push): add OPPO and vivo push plugins, bump JPush to 6.1.0
Some checks failed
Frontend CI / ota (ios) (push) Successful in 1m56s
Frontend CI / ota (android) (push) Successful in 2m49s
Frontend CI / build-and-push-web (push) Successful in 3m9s
Frontend CI / build-android-apk (push) Failing after 15m8s
- Add withOppoPush.js and withVivoPush.js config plugins
- Register new plugins in app.json
- Bump JPush version from 5.8.0 to 6.1.0 in Honor/Huawei/Xiaomi plugins
2026-06-18 12:46:34 +08:00
lafay
a921aacefd refactor: streamline post sync, fix image gallery, and clean up chat screen
- **Post sync optimization**: Clear store immediately when params change to prevent flashing old posts; replace instead of merge on refresh
- **ImageGallery fix**: Use idempotent download option, migrate to Asset.create() API, fix Android file path requirement, ensure proper cleanup
- **UserProfileScreen**: Add ImageGallery for post image viewing with consistent implementation
- **SearchScreen**: Add entrance animation and empty state
- **ChatScreen**: Remove unused state variables (lastSeq, firstSeq, isProgrammaticScrollRef) and dead imports
2026-06-18 02:29:54 +08:00
lafay
96e8de18bf feat(push): integrate Honor and Xiaomi push plugins with related updates 2026-06-18 00:03:37 +08:00
31 changed files with 1621 additions and 453 deletions

View File

@@ -48,7 +48,7 @@ jobs:
- name: Resolve runtime version
id: runtime
run: |
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
RUNTIME_VERSION="$(node -p "require('./app.json').expo.runtimeVersion")"
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
@@ -276,7 +276,7 @@ jobs:
- name: Resolve runtime version
id: runtime
run: |
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
RUNTIME_VERSION="$(node -p "require('./app.json').expo.runtimeVersion")"
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"

View File

@@ -2,14 +2,11 @@ FROM node:25-alpine AS builder
WORKDIR /app
# git required by app.config.js for version derivation (has try-catch fallback)
RUN apk add --no-cache git
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN NODE_OPTIONS="--max-old-space-size=4096" node /app/node_modules/.bin/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

View File

@@ -6,15 +6,21 @@ const releaseApiBaseUrl = 'https://withyou.littlelan.cn/api/v1';
const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1';
function getGitShortHash() {
function getCommitCount() {
try {
return execSync('git rev-parse --short=4 HEAD', { encoding: 'utf-8' }).trim();
return execSync('git rev-list --count HEAD', { encoding: 'utf-8' }).trim();
} catch {
return '0000';
return '1';
}
}
const gitBuildSuffix = getGitShortHash();
// 在 commit count 上加偏移,保证 versionCode / buildNumber / runtimeVersion
// 始终大于历史最大值(之前的最后一个版本是 5547避免被系统/Play Store
// 误判为降级。后续即使 commit count 重置或换仓库,偏移也能保证单调递增。
const BUILD_NUMBER_OFFSET = 100000;
const commitCount = getCommitCount();
const buildNumber = String(parseInt(commitCount, 10) + BUILD_NUMBER_OFFSET);
function toManifestUrl(baseUrl, portOverride) {
const parsed = new URL(baseUrl);
@@ -60,9 +66,9 @@ const filteredPlugins = isWeb
module.exports = {
...expo,
name: isDevVariant ? `${expo.name} Dev` : expo.name,
runtimeVersion: {
policy: 'appVersion',
},
// runtimeVersion 用 build numbercommit count + 偏移):单调递增、纯数字、与 version (语义版本) 解耦
// 字符串形式,等价于 policy: 'custom'
runtimeVersion: buildNumber,
updates: {
...(expo.updates || {}),
url: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
@@ -71,12 +77,12 @@ module.exports = {
},
ios: {
...expo.ios,
buildNumber: gitBuildSuffix,
buildNumber: buildNumber,
},
android: {
...expo.android,
package: 'cn.qczlit.withyou',
versionCode: parseInt(gitBuildSuffix, 16),
versionCode: parseInt(buildNumber, 10),
},
// Web 端使用过滤后的插件
plugins: filteredPlugins,

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "威友",
"slug": "qojo",
"version": "1.0.0",
"version": "1.0.1",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -36,7 +36,6 @@
},
"predictiveBackGestureEnabled": false,
"package": "cn.qczlit.withyou",
"versionCode": 7,
"permissions": [
"VIBRATE",
"RECORD_AUDIO",
@@ -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",
@@ -108,7 +106,7 @@
{
"photosPermission": "允许威友访问您的照片以发布内容",
"savePhotosPermission": "允许威友保存照片到您的相册",
"isAccessMediaLocationEnabled": true
"isAccessMediaLocationEnabled": false
}
],
[
@@ -157,6 +155,10 @@
}
],
"./plugins/withHuaweiPush",
"./plugins/withXiaomiPush",
"./plugins/withHonorPush",
"./plugins/withVivoPush",
"./plugins/withOppoPush",
"expo-callkit-telecom",
[
"expo-splash-screen",

14
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "with_you",
"version": "0.0.2",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "with_you",
"version": "0.0.2",
"version": "1.0.1",
"dependencies": {
"@expo/ui": "~56.0.17",
"@expo/vector-icons": "^15.1.1",
@@ -24,6 +24,7 @@
"expo-camera": "~56.0.8",
"expo-constants": "~56.0.18",
"expo-dev-client": "~56.0.20",
"expo-document-picker": "~56.0.4",
"expo-file-system": "~56.0.8",
"expo-font": "~56.0.6",
"expo-haptics": "~56.0.3",
@@ -5530,6 +5531,15 @@
"expo": "*"
}
},
"node_modules/expo-document-picker": {
"version": "56.0.4",
"resolved": "https://registry.npmmirror.com/expo-document-picker/-/expo-document-picker-56.0.4.tgz",
"integrity": "sha512-75Apf74XNkYYohObIH19VZw42xpe0gmEnPccuzGXKVAzlvTYCfibSgW17F+6vt4paOfZEnAoZ1QFZM6dmaujRA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-eas-client": {
"version": "56.0.1",
"resolved": "https://registry.npmmirror.com/expo-eas-client/-/expo-eas-client-56.0.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "with_you",
"version": "1.0.0",
"version": "1.0.1",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
@@ -30,6 +30,7 @@
"expo-camera": "~56.0.8",
"expo-constants": "~56.0.18",
"expo-dev-client": "~56.0.20",
"expo-document-picker": "~56.0.4",
"expo-file-system": "~56.0.8",
"expo-font": "~56.0.6",
"expo-haptics": "~56.0.3",

205
plugins/withHonorPush.js Normal file
View File

@@ -0,0 +1,205 @@
const {
withProjectBuildGradle,
withSettingsGradle,
withDangerousMod,
} = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
// 荣耀厂商推送通道的 Expo config plugin.
//
// 客户端职责(依据极光官方文档 https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide
// 1) 添加 cn.jiguang.sdk.plugin:honor 依赖
// 2) 注入 manifestPlaceholders: HONOR_APPID
// 3) 添加荣耀 Maven 仓库 https://developer.hihonor.com/repo (v5.9.0+ 必需)
// 4) 添加 Proguard 规则 (com.hihonor.push.** 保留 + 5 条 -keepattributes)
//
// 实现说明app/build.gradle 改动用 withDangerousMod 直接读写磁盘文件。
// 原因Expo SDK 56+ 的 withAppBuildGradle hook 链中,第二个以后的 plugin 写回
// modResults.contents 会被静默忽略,导致多个 vendor plugin 只能写入第一个的修改。
// 仓库settings.gradle / build.gradle改动用 withProjectBuildGradle / withSettingsGradle
// 没有这个问题。
const withHonorPush = (config, options = {}) => {
const {
jpushVersion = '6.1.0',
appId = '104562917',
} = options;
const honorRepoUrl = 'https://developer.hihonor.com/repo';
// 1. settings.gradle: 添加荣耀 Maven 仓库
config = withSettingsGradle(config, (config) => {
let contents = config.modResults.contents;
if (!contents.includes('developer.hihonor.com/repo')) {
const repoLine = ` maven { url '${honorRepoUrl}' }`;
const hasRepositoriesInPM = /pluginManagement\s*\{[\s\S]*?repositories\s*\{/.test(contents);
if (hasRepositoriesInPM) {
config.modResults.contents = contents.replace(
/(pluginManagement\s*\{[\s\S]*?repositories\s*\{)/,
`$1\n ${repoLine}`
);
} else if (contents.includes('pluginManagement')) {
config.modResults.contents = contents.replace(
/(pluginManagement\s*\{)/,
`$1\n repositories {\n gradlePluginPortal()\n google()\n mavenCentral()\n ${repoLine}\n }`
);
} else {
config.modResults.contents =
`pluginManagement {\n repositories {\n ${repoLine}\n }\n}\n\n` +
config.modResults.contents;
}
}
// Gradle 9+ layout
if (
!config.modResults.contents.match(
/dependencyResolutionManagement[\s\S]*developer\.hihonor\.com\/repo/
)
) {
const depMgmtPattern = /dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{/;
if (depMgmtPattern.test(config.modResults.contents)) {
config.modResults.contents = config.modResults.contents.replace(
depMgmtPattern,
`$&\n maven { url '${honorRepoUrl}' }`
);
}
}
return config;
});
// 2. 根 build.gradle: 添加荣耀 Maven 仓库到 buildscript.repositories
config = withProjectBuildGradle(config, (config) => {
let contents = config.modResults.contents;
if (!contents.includes('developer.hihonor.com/repo')) {
const bsRepoPattern = /(buildscript\s*\{[\s\S]*?repositories\s*\{)/;
if (bsRepoPattern.test(contents)) {
contents = contents.replace(
bsRepoPattern,
`$1\n maven { url '${honorRepoUrl}' }`
);
}
}
config.modResults.contents = contents;
return config;
});
// 3. app/build.gradle: 注入 AAR 依赖 + manifestPlaceholders (用 withDangerousMod 直接读文件)
config = withDangerousMod(config, [
'android',
async (config) => {
const appBuildGradlePath = path.join(
config.modRequest.platformProjectRoot,
'app',
'build.gradle'
);
const proguardFilePath = path.join(
config.modRequest.platformProjectRoot,
'app',
'proguard-rules.pro'
);
// --- 3a. 处理 build.gradle ---
if (fs.existsSync(appBuildGradlePath)) {
let gradle = fs.readFileSync(appBuildGradlePath, 'utf-8');
let changed = false;
// 注入 cn.jiguang.sdk.plugin:honor 依赖
if (!gradle.includes('cn.jiguang.sdk.plugin:honor')) {
const depsPattern = /dependencies\s*\{/;
if (depsPattern.test(gradle)) {
gradle = gradle.replace(
depsPattern,
`dependencies {\n implementation 'cn.jiguang.sdk.plugin:honor:${jpushVersion}'`
);
changed = true;
}
}
// 注入 manifestPlaceholders: HONOR_APPID
const manifestPlaceholderRegex = /manifestPlaceholders\s*=\s*\[([\s\S]*?)\]/;
const match = gradle.match(manifestPlaceholderRegex);
const requiredPlaceholders = {
HONOR_APPID: appId,
};
if (match) {
const existingBlock = match[1];
const existingEntries = {};
const entryRegex = /(\w+)\s*:\s*"([^"]*)"/g;
let entryMatch;
while ((entryMatch = entryRegex.exec(existingBlock)) !== null) {
existingEntries[entryMatch[1]] = entryMatch[2];
}
let placeholdersChanged = false;
for (const [key, value] of Object.entries(requiredPlaceholders)) {
if (existingEntries[key] !== value) {
existingEntries[key] = value;
placeholdersChanged = true;
}
}
if (placeholdersChanged) {
const entriesStr = Object.entries(existingEntries)
.map(([k, v]) => `${k}: "${v}"`)
.join(',\n ');
const newBlock = `\n manifestPlaceholders = [\n ${entriesStr}\n ]`;
gradle = gradle.replace(manifestPlaceholderRegex, newBlock.trim());
changed = true;
}
} else if (gradle.includes('REACT_NATIVE_RELEASE_LEVEL')) {
// 没有 manifestPlaceholders block 时新建
const entriesStr = Object.entries(requiredPlaceholders)
.map(([k, v]) => `${k}: "${v}"`)
.join(',\n ');
const block = `\n manifestPlaceholders = [\n ${entriesStr}\n ]\n`;
const lines = gradle.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('REACT_NATIVE_RELEASE_LEVEL')) {
lines.splice(i + 1, 0, block);
break;
}
}
gradle = lines.join('\n');
changed = true;
}
if (changed) {
fs.writeFileSync(appBuildGradlePath, gradle);
console.log('[withHonorPush] updated app/build.gradle (AAR + manifestPlaceholders)');
}
}
// --- 3b. 处理 proguard-rules.pro ---
if (fs.existsSync(proguardFilePath)) {
let proguard = fs.readFileSync(proguardFilePath, 'utf-8');
if (!proguard.includes('-keep class com.hihonor.push.**')) {
const honorRules = `
# JPush Honor vendor channel — keep rules (https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide)
-ignorewarnings
-keepattributes *Annotation*
-keepattributes Exceptions
-keepattributes InnerClasses
-keepattributes Signature
-keepattributes SourceFile,LineNumberTable
-keep class com.hihonor.push.**{*;}
`;
fs.writeFileSync(proguardFilePath, proguard + honorRules);
console.log('[withHonorPush] appended Honor Proguard rules to proguard-rules.pro');
}
}
return config;
},
]);
return config;
};
module.exports = withHonorPush;

View File

@@ -9,7 +9,7 @@ const fs = require('fs');
const path = require('path');
const withHuaweiPush = (config, options = {}) => {
const { jpushVersion = '5.8.0' } = options;
const { jpushVersion = '6.1.0' } = options;
config = withSettingsGradle(config, (config) => {
const contents = config.modResults.contents;

144
plugins/withOppoPush.js Normal file
View 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.05.9.0 maven 已自动包含。
//
// 实现说明:用 withDangerousMod 直接读写磁盘文件。
// 原因Expo SDK 56+ 的 withAppBuildGradle hook 链中,第二个以后的 plugin 写回
// modResults.contents 会被静默忽略,导致多个 vendor plugin 只能写入第一个的修改。
const withOppoPush = (config, options = {}) => {
const {
jpushVersion = '6.1.0',
appId = '37219099',
appKey = '4eb532fcddd147c0bf7c356a149b2058',
appSecret = '9a0c290b2b8049e1893246ed7b9367d0',
} = options;
config = withDangerousMod(config, [
'android',
async (config) => {
const appBuildGradlePath = path.join(
config.modRequest.platformProjectRoot,
'app',
'build.gradle'
);
const proguardFilePath = path.join(
config.modRequest.platformProjectRoot,
'app',
'proguard-rules.pro'
);
// --- 1. 处理 build.gradle ---
if (fs.existsSync(appBuildGradlePath)) {
let gradle = fs.readFileSync(appBuildGradlePath, 'utf-8');
let changed = false;
// 1a. 注入 AAR 依赖
if (!gradle.includes('cn.jiguang.sdk.plugin:oppo')) {
const depsPattern = /dependencies\s*\{/;
if (depsPattern.test(gradle)) {
gradle = gradle.replace(
depsPattern,
`dependencies {\n implementation 'cn.jiguang.sdk.plugin:oppo:${jpushVersion}'`
);
changed = true;
}
}
// 1b. 注入 manifestPlaceholders带 OP- 前缀)
const manifestPlaceholderRegex = /manifestPlaceholders\s*=\s*\[([\s\S]*?)\]/;
const match = gradle.match(manifestPlaceholderRegex);
// OPPO 官方要求占位符值必须以 "OP-" 前缀开头
const requiredPlaceholders = {
OPPO_APPKEY: `OP-${appKey}`,
OPPO_APPID: `OP-${appId}`,
OPPO_APPSECRET: `OP-${appSecret}`,
};
if (match) {
const existingBlock = match[1];
const existingEntries = {};
const entryRegex = /(\w+)\s*:\s*"([^"]*)"/g;
let entryMatch;
while ((entryMatch = entryRegex.exec(existingBlock)) !== null) {
existingEntries[entryMatch[1]] = entryMatch[2];
}
let placeholdersChanged = false;
for (const [key, value] of Object.entries(requiredPlaceholders)) {
if (existingEntries[key] !== value) {
existingEntries[key] = value;
placeholdersChanged = true;
}
}
if (placeholdersChanged) {
const entriesStr = Object.entries(existingEntries)
.map(([k, v]) => `${k}: "${v}"`)
.join(',\n ');
const newBlock = `\n manifestPlaceholders = [\n ${entriesStr}\n ]`;
gradle = gradle.replace(manifestPlaceholderRegex, newBlock.trim());
changed = true;
}
} else if (gradle.includes('REACT_NATIVE_RELEASE_LEVEL')) {
// 没有 manifestPlaceholders block 时新建
const entriesStr = Object.entries(requiredPlaceholders)
.map(([k, v]) => `${k}: "${v}"`)
.join(',\n ');
const block = `\n manifestPlaceholders = [\n ${entriesStr}\n ]\n`;
const lines = gradle.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('REACT_NATIVE_RELEASE_LEVEL')) {
lines.splice(i + 1, 0, block);
break;
}
}
gradle = lines.join('\n');
changed = true;
}
if (changed) {
fs.writeFileSync(appBuildGradlePath, gradle);
console.log('[withOppoPush] updated app/build.gradle (AAR + manifestPlaceholders)');
}
}
// --- 2. 处理 proguard-rules.pro ---
if (fs.existsSync(proguardFilePath)) {
let proguard = fs.readFileSync(proguardFilePath, 'utf-8');
if (!proguard.includes('-keep class com.heytap.**')) {
const oppoRules = `
# JPush OPPO vendor channel — keep rules (https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide)
-dontwarn com.coloros.mcsdk.**
-dontwarn com.heytap.**
-dontwarn com.mcs.**
-keep class com.coloros.mcsdk.** { *; }
-keep class com.heytap.** { *; }
-keep class com.mcs.** { *; }
`;
fs.writeFileSync(proguardFilePath, proguard + oppoRules);
console.log('[withOppoPush] appended OPPO Proguard rules to proguard-rules.pro');
}
}
return config;
},
]);
return config;
};
module.exports = withOppoPush;

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

133
plugins/withXiaomiPush.js Normal file
View 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;

View File

@@ -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: {

View File

@@ -24,6 +24,7 @@ export { default as VoteEditor } from './VoteEditor';
export { default as VotePreview } from './VotePreview';
export { default as PostContentRenderer } from './PostContentRenderer';
export { default as PostMentionInput } from './PostMentionInput';
export type { PostMentionInputHandle } from './PostMentionInput';
export { default as BlockEditor } from './BlockEditor';
export type { BlockEditorHandle } from './BlockEditor';
export { default as ReportDialog } from './ReportDialog';

View File

@@ -213,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';
@@ -222,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');
@@ -236,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]);

View File

@@ -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);

View File

@@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react';
import { Platform } from 'react-native';
import { jpushService } from '../services/notification/jpushService';
import { isAutoStartAllowed } from '../services/consent/autoStartConsent';
export function useRegisterPushDevice(isAuthenticated: boolean, userID?: string) {
const deviceRegistered = useRef(false);
@@ -10,12 +9,12 @@ export function useRegisterPushDevice(isAuthenticated: boolean, userID?: string)
useEffect(() => {
if (Platform.OS === 'web' || !isAuthenticated || !userID) return;
// 仅在用户同意自启动后才初始化 JPush
if (!isAutoStartAllowed()) {
console.log('[useRegisterPushDevice] 用户未同意自启动,跳过 JPush 初始化');
return;
}
// JPush 初始化只取决于登录态——这是基础推送通道(获取 RegistrationID、
// 向服务器注册设备 token与"是否允许后台自启动/保活"无关。
// 后台保活由 backgroundService 控制;退后台是否保持 JPush 长连接由
// jpushService._setBackgroundKeepLongConn() 根据 autoStart 同意状态自行处理。
// 若在此处用 consent 拦截 init未同意用户将永远拿不到 RegistrationID
// 即便在前台/已授予通知权限也无法收到推送。
if (!deviceRegistered.current) {
deviceRegistered.current = true;

View File

@@ -437,7 +437,6 @@ export const HomeScreen: React.FC = () => {
refreshing: isRefreshing,
hasMore,
error,
reset,
} = useDifferentialPosts<Post>(
[],
{
@@ -497,8 +496,9 @@ export const HomeScreen: React.FC = () => {
useEffect(() => {
let cancelled = false;
const run = async () => {
reset();
await new Promise(resolve => requestAnimationFrame(resolve));
// 注意:不再在此处调用 reset()。
// fetchPosts 内部会在参数变化时立即清空 store 中的旧数据并设置 loading
// 这样可以避免本地清空后、请求发起前的间隙渲染到上一个分区的帖子造成闪烁。
if (!cancelled) {
await refresh();
}

View File

@@ -42,6 +42,7 @@ import { postService, commentService, authService, showPrompt, voteService } fro
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 {
@@ -1271,10 +1272,22 @@ export const PostDetailScreen: React.FC = () => {
// 回复评论
const [replyingTo, setReplyingTo] = useState<Comment | null>(null);
const [isComposerVisible, setIsComposerVisible] = useState(false);
const commentInputRef = useRef<PostMentionInputHandle>(null);
// 展开编辑器后自动聚焦输入框、唤起键盘,提升回复体验
const focusCommentInput = useCallback(() => {
// 展开态需要先渲染,延迟一帧再 focus避免 ref 尚未挂载到真实 TextInput
requestAnimationFrame(() => {
setTimeout(() => {
commentInputRef.current?.focus();
}, 60);
});
}, []);
const handleReply = (comment: Comment) => {
setReplyingTo(comment);
setIsComposerVisible(true);
focusCommentInput();
};
const handleCancelReply = () => {
@@ -1363,6 +1376,7 @@ export const PostDetailScreen: React.FC = () => {
const openComposer = () => {
setIsComposerVisible(true);
focusCommentInput();
};
const closeComposer = () => {
@@ -1471,6 +1485,7 @@ export const PostDetailScreen: React.FC = () => {
{/* Text Input —— 小红书风格:无边框、自动扩展 */}
<PostMentionInput
ref={commentInputRef}
value={commentText}
onChangeText={setCommentText}
onSegmentsChange={setCommentSegments}

View File

@@ -12,6 +12,7 @@ import {
TouchableOpacity,
ScrollView,
RefreshControl,
Animated,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
@@ -23,7 +24,7 @@ import { useUserStore } from '../../stores';
import { postService, authService } from '../../services';
import { tradeService } from '../../services/trade/tradeService';
import { postSyncService } from '@/services/post';
import { PostCard, TabBar, SearchBar } from '../../components/business';
import { PostCard, SearchBar } from '../../components/business';
import { TradeCard } from '../../components/business/TradeCard/TradeCard';
import type { PostCardAction } from '../../components/business/PostCard';
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
@@ -94,6 +95,25 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
// 保存当前搜索关键词用于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 {
@@ -508,10 +528,16 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
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"
@@ -522,7 +548,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
>
</Text>
<TouchableOpacity onPress={handleClearHistory}>
<TouchableOpacity onPress={handleClearHistory} activeOpacity={0.7}>
<MaterialCommunityIcons name="delete-outline" size={isDesktop ? 22 : 18} color={colors.text.secondary} />
</TouchableOpacity>
</View>
@@ -540,6 +566,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
}
]}
onPress={() => handleHistoryPress(keyword)}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="history" size={isDesktop ? 16 : 14} color={colors.text.secondary} />
<Text
@@ -557,7 +584,18 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
</View>
</View>
)}
</View>
{/* 空状态提示 */}
{history.length === 0 && (
<View style={styles.emptySuggestions}>
<EmptyState
title="开始搜索"
description="输入关键词搜索帖子、用户或商品"
icon="magnify"
/>
</View>
)}
</Animated.View>
);
};
@@ -581,6 +619,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
onSubmit={handleSearch}
placeholder={isMarket ? "搜索商品、用户" : "搜索帖子、用户"}
autoFocus
compact
/>
</View>
<TouchableOpacity
@@ -598,20 +637,30 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
</TouchableOpacity>
</View>
{/* Tab切换 */}
{/* Tab切换 - 与主页风格一致的下划线样式 */}
<View style={styles.tabWrapper}>
<TabBar
tabs={TABS}
activeIndex={activeIndex}
onTabChange={(index) => {
setActiveIndex(index);
if (currentKeyword && hasSearched) {
performSearch(currentKeyword);
}
}}
variant="modern"
icons={isMarket ? ['shopping-outline', 'account-outline'] : ['file-document-outline', 'account-outline']}
/>
<View style={[styles.homeTabSwitcher, { paddingHorizontal: responsivePadding }]}>
{TABS.map((tab, index) => {
const isActive = activeIndex === index;
return (
<TouchableOpacity
key={tab}
activeOpacity={0.7}
style={[styles.homeTabItem, isActive && styles.homeTabItemActive]}
onPress={() => {
setActiveIndex(index);
if (currentKeyword && hasSearched) {
performSearch(currentKeyword);
}
}}
>
<Text style={isActive ? styles.homeTabTextActive : styles.homeTabText}>
{tab}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
{/* 内容区域 */}
@@ -629,15 +678,12 @@ function createSearchScreenStyles(colors: AppColors) {
searchHeader: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: `${colors.divider}70`,
backgroundColor: colors.background.default,
},
searchShell: {
flex: 1,
},
cancelButton: {
backgroundColor: `${colors.primary.main}12`,
borderRadius: borderRadius.full,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
@@ -647,13 +693,43 @@ function createSearchScreenStyles(colors: AppColors) {
fontWeight: '600',
},
tabWrapper: {
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: `${colors.divider}50`,
backgroundColor: colors.background.default,
paddingVertical: spacing.xs,
},
homeTabSwitcher: {
flexDirection: 'row',
alignItems: 'center',
gap: 20,
},
homeTabItem: {
paddingVertical: spacing.sm,
alignItems: 'center',
justifyContent: 'center',
borderBottomWidth: 2,
borderBottomColor: 'transparent',
},
homeTabItemActive: {
borderBottomColor: colors.text.primary,
},
homeTabText: {
fontSize: 18,
fontWeight: '400',
color: colors.text.hint,
},
homeTabTextActive: {
fontSize: 18,
fontWeight: '700',
color: colors.text.primary,
},
suggestionsContainer: {
flex: 1,
},
emptySuggestions: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
marginTop: -40,
},
section: {
marginTop: spacing.md,
},
@@ -674,13 +750,14 @@ function createSearchScreenStyles(colors: AppColors) {
tag: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: `${colors.primary.main}10`,
backgroundColor: colors.background.paper,
borderRadius: borderRadius.full,
borderWidth: 0,
borderWidth: 1,
borderColor: colors.divider,
},
tagText: {
marginLeft: spacing.xs,
color: colors.primary.main,
color: colors.text.secondary,
fontWeight: '500',
},
userCard: {

View File

@@ -159,7 +159,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
longPressMenuVisible,
selectedMessage,
selectedMessageId,
setSelectedMessageId,
menuPosition,
isGroupChat,
groupInfo,
@@ -207,7 +206,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
handleMentionAll,
getSenderInfo,
getTypingHint,
getInputBottom,
handleDismiss,
navigateToInfo,
navigateToChatSettings,

View File

@@ -476,23 +476,102 @@ const renderVideoSegment = (data: VideoSegmentData, isMe: boolean): React.ReactN
return <VideoSegment key={`video-${data.url}`} data={data} isMe={isMe} />;
};
/**
* 根据 MIME 类型返回文件图标名与主题色
*/
function getFileVisual(mime?: string): { icon: string; color: string } {
if (!mime) return { icon: 'file-document-outline', color: '#5C6BC0' };
const m = mime.toLowerCase();
if (m === 'application/pdf') return { icon: 'file-pdf-box', color: '#E53935' };
if (m.includes('word') || m === 'application/msword') return { icon: 'file-word-box', color: '#1E88E5' };
if (m.includes('excel') || m === 'application/vnd.ms-excel' || m === 'text/csv') return { icon: 'file-excel-box', color: '#43A047' };
if (m.includes('powerpoint') || m === 'application/vnd.ms-powerpoint') return { icon: 'file-powerpoint-box', color: '#FB8C00' };
if (m === 'application/zip' || m.includes('compressed') || m.includes('rar') || m.includes('7z') || m.includes('gzip') || m.includes('tar')) {
return { icon: 'folder-zip-outline', color: '#8D6E63' };
}
if (m.startsWith('audio/')) return { icon: 'file-music-outline', color: '#8E24AA' };
if (m.startsWith('video/')) return { icon: 'file-video-outline', color: '#00897B' };
if (m.startsWith('image/')) return { icon: 'file-image-outline', color: '#FF6B35' };
if (m === 'application/json' || m === 'text/xml' || m === 'application/xml') return { icon: 'code-json', color: '#455A64' };
if (m === 'text/plain' || m === 'text/markdown') return { icon: 'file-document-outline', color: '#607D8B' };
return { icon: 'file-document-outline', color: '#5C6BC0' };
}
/**
* 打开/下载文件:移动端用浏览器在新页面打开 URL系统会提示下载/预览),
* 此处不再依赖 expo-file-systemSDK 56 已废弃 downloadAsync
*/
async function openRemoteFile(url: string, name: string) {
if (!url) return;
try {
await Linking.openURL(url);
} catch (error) {
console.warn('打开文件失败:', error);
}
}
/**
* 渲染文件 Segment
* 当 data.expired 为 true 时显示"文件已过期"失效态,禁用点击。
*/
const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ data, isMe }) => {
const styles = useSegmentStyles();
const themeColors = useAppColors();
const [downloading, setDownloading] = useState(false);
const fileSize = data.size ? formatFileSize(data.size) : '';
const visual = getFileVisual(data.mime_type);
const isExpired = !!data.expired;
const handlePress = async () => {
if (isExpired || !data.url || downloading) return;
setDownloading(true);
try {
await openRemoteFile(data.url, data.name || 'file');
} finally {
setDownloading(false);
}
};
// 失效态:灰态卡片 + 时钟图标 + "文件已过期",禁用点击
if (isExpired) {
return (
<View
style={[styles.fileContainer, styles.fileExpired, isMe ? styles.fileMe : styles.fileOther]}
pointerEvents="none"
>
<View style={[styles.fileIcon, { backgroundColor: 'rgba(150,150,150,0.15)' }]}>
<MaterialCommunityIcons
name="clock-alert-outline"
size={26}
color={themeColors.chat.textTertiary}
/>
</View>
<View style={styles.fileInfo}>
<Text
style={[styles.fileName, styles.fileExpiredText]}
numberOfLines={1}
>
{data.name}
</Text>
<Text style={[styles.fileSize, styles.fileExpiredText]}>
</Text>
</View>
</View>
);
}
return (
<TouchableOpacity
style={[styles.fileContainer, isMe ? styles.fileMe : styles.fileOther]}
onPress={handlePress}
activeOpacity={0.7}
>
<View style={styles.fileIcon}>
<View style={[styles.fileIcon, { backgroundColor: `${visual.color}22` }]}>
<MaterialCommunityIcons
name="file-document"
size={28}
color={isMe ? themeColors.primary.contrast : themeColors.primary.main}
name={visual.icon as any}
size={26}
color={isMe ? themeColors.primary.contrast : visual.color}
/>
</View>
<View style={styles.fileInfo}>
@@ -502,8 +581,15 @@ const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ d
>
{data.name}
</Text>
{fileSize ? <Text style={styles.fileSize}>{fileSize}</Text> : null}
<Text style={styles.fileSize}>
{downloading ? '下载中…' : (fileSize || (data.mime_type || '文件'))}
</Text>
</View>
<MaterialCommunityIcons
name={downloading ? 'progress-download' : 'download-outline'}
size={20}
color={isMe ? themeColors.primary.contrast : themeColors.chat.textTertiary}
/>
</TouchableOpacity>
);
};
@@ -1021,6 +1107,14 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
fontWeight: '500',
},
// 文件过期失效态
fileExpired: {
opacity: 0.65,
},
fileExpiredText: {
color: colors.chat.textTertiary,
},
// 链接 - QQ风格卡片式设计
linkContainer: {
borderRadius: 16,

View File

@@ -178,13 +178,6 @@ export const MORE_ACTIONS: MoreAction[] = [
color: '#5C6BC0',
gradientColors: ['#7986CB', '#3F51B5'],
},
{
id: 'location',
icon: 'map-marker',
name: '位置',
color: '#EC407A',
gradientColors: ['#F06292', '#D81B60'],
},
];
// 消息撤回时间限制(毫秒)

View File

@@ -2,7 +2,7 @@
* ChatScreen 类型定义
*/
import { MessageResponse, UserDTO, GroupMemberResponse, GroupResponse } from '../../../../types/dto';
import { MessageResponse, UserDTO, GroupMemberResponse } from '../../../../types/dto';
// 面板类型
export type PanelType = 'none' | 'emoji' | 'more' | 'mention';
@@ -215,39 +215,3 @@ export interface SwipeableMessageBubbleProps {
onReply: () => void;
enabled?: boolean;
}
// ChatScreen 状态接口
export interface ChatScreenState {
// 基础状态
messages: GroupMessage[];
conversationId: string | null;
inputText: string;
otherUser: UserDTO | null;
currentUser: UserDTO | null;
keyboardHeight: number;
loading: boolean;
sending: boolean;
currentUserId: string;
lastSeq: number;
otherUserLastReadSeq: number;
activePanel: PanelType;
sendingImage: boolean;
// 回复消息状态
replyingTo: GroupMessage | null;
// 长按菜单状态
longPressMenuVisible: boolean;
selectedMessage: GroupMessage | null;
// 群聊相关状态
groupInfo: GroupResponse | null;
groupMembers: GroupMemberResponse[];
typingUsers: string[];
currentUserRole: UserRole;
mentionQuery: string;
selectedMentions: string[];
mentionAll: boolean;
isMuted: boolean;
muteAll: boolean;
}

View File

@@ -20,7 +20,9 @@ import {
import { useLocalSearchParams, router } from 'expo-router';
import { formatChatTime } from '@/utils/formatTime';
import * as ImagePicker from 'expo-image-picker';
import { GroupMemberResponse, MessageSegment, TextSegmentData, ImageSegmentData, AtSegmentData, ReplySegmentData, MessageStatus } from '../../../../types/dto';
import * as DocumentPicker from 'expo-document-picker';
import { Linking } from 'react-native';
import { GroupMemberResponse, MessageSegment, TextSegmentData, ImageSegmentData, FileSegmentData, AtSegmentData, ReplySegmentData, MessageStatus } from '../../../../types/dto';
import { messageService } from '@/services/message';
import { uploadService } from '@/services/upload';
import { ApiError } from '@/services/core';
@@ -181,8 +183,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
const [keyboardHeight, setKeyboardHeight] = useState(0);
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [lastSeq, setLastSeq] = useState<number>(0);
const [firstSeq, setFirstSeq] = useState<number>(0);
const [otherUserLastReadSeq, setOtherUserLastReadSeq] = useState<number>(0);
const [activePanel, setActivePanel] = useState<PanelType>('none');
const [sendingImage, setSendingImage] = useState(false);
@@ -196,11 +196,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
// 滚动状态机 refsTelegram/Element 风格:底部粘附 + 阅读锚点)
const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0, viewportHeight: 0 });
const hasInitialAnchorDoneRef = useRef(false);
const prevMessageCountRef = useRef(0);
const prevLatestSeqRef = useRef(0);
const prevMarkedReadSeqRef = useRef(0);
const enterMarkedKeyRef = useRef<string>('');
const isProgrammaticScrollRef = useRef(false);
const suppressAutoFollowRef = useRef(false);
const isBrowsingHistoryRef = useRef(false);
const hasShownMessageListRef = useRef(false);
@@ -229,6 +227,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
setGroupInfo(prev => prev ? prev : { name: name || undefined, avatar: avatar || null });
}
}, [isGroupChat, effectiveGroupName, effectiveGroupAvatar]);
const [currentUserRole, setCurrentUserRole] = useState<UserRole>('member');
const [mentionQuery, setMentionQuery] = useState<string>('');
const [selectedMentions, setSelectedMentions] = useState<string[]>([]);
@@ -369,10 +368,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
};
}, [isGroupChat, otherUserId]);
// 进入新会话时重置滚动状态
// 进入新会话时重置滚动/草稿状态
useEffect(() => {
hasInitialAnchorDoneRef.current = false;
prevMessageCountRef.current = 0;
prevLatestSeqRef.current = 0;
prevMarkedReadSeqRef.current = 0;
enterMarkedKeyRef.current = '';
@@ -381,9 +379,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
hasShownMessageListRef.current = false;
historyLoadingLockUntilRef.current = 0;
scrollToSeqRef.current = routeScrollToSeq ?? null;
}, [conversationId]);
useEffect(() => {
setPendingAttachments([]);
}, [conversationId]);
@@ -403,14 +398,10 @@ export const useChatScreen = (props?: ChatScreenProps) => {
const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => {
if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked())) return;
// inverted 列表下,最新消息端对应 offset=0
isProgrammaticScrollRef.current = true;
flatListRef.current?.scrollToOffset({
offset: 0,
animated,
});
setTimeout(() => {
isProgrammaticScrollRef.current = false;
}, animated ? 220 : 32);
}, [flatListRef, isHistoryLoadingLocked]);
const isNearBottom = useCallback(() => {
@@ -420,18 +411,23 @@ export const useChatScreen = (props?: ChatScreenProps) => {
}, [scrollPositionRef]);
// 首屏加载完成后仅锚定一次到最新消息端(有 scrollToSeq 时跳过)
// 注意:必须在消息首次出现时立刻把 hasInitialAnchorDoneRef 置为 true
// 不能再依赖 viewportHeight 等通过 ref 同步的值——否则后续首次 loadMoreHistory
// 让 messages.length 变化时此 effect 会被再度命中,并以 force=true 调用
// scrollToLatest 强行把列表拉回最底端,造成"上滑首次加载更多就回底"的问题。
useEffect(() => {
if (
loading ||
loadingMore ||
messages.length === 0 ||
hasInitialAnchorDoneRef.current ||
scrollPositionRef.current.viewportHeight <= 0
hasInitialAnchorDoneRef.current
) {
return;
}
hasInitialAnchorDoneRef.current = true;
if (scrollToSeqRef.current != null) return; // 由下方 scrollToSeq effect 处理
// inverted FlashList 天然从 offset=0最新端开始
// 这里仅做一次保险锚定,即使布局尚未就绪也不会有副作用。
const timer = setTimeout(() => {
scrollToLatest(false, true, 'initial-anchor');
}, 0);
@@ -442,10 +438,8 @@ export const useChatScreen = (props?: ChatScreenProps) => {
// 仅当"最新端消息 seq 增长"且用户在底部附近时才跟随;
// 历史加载只会增加旧消息,不会提升 latest seq因此不会触发回底。
useEffect(() => {
const currentCount = messages.length;
const latestSeq = currentCount > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
const latestSeq = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
const prevLatestSeq = prevLatestSeqRef.current;
prevMessageCountRef.current = currentCount;
prevLatestSeqRef.current = latestSeq;
if (loading || loadingMore) return;
@@ -575,33 +569,20 @@ export const useChatScreen = (props?: ChatScreenProps) => {
};
}, [routeUserId, conversationId, currentUserId, isGroupChat]);
// 加载更多历史消息inverted 下保持阅读锚点
// 加载更多历史消息inverted + maintainVisibleContentPosition 由列表原生保持位置
const loadMoreHistory = useCallback(async () => {
if (!conversationId || !hasMoreHistory || loadingMore) {
return;
}
// 历史加载期间禁止新消息自动跟随到底
// 历史加载期间禁止"新消息自动跟随到底"
suppressAutoFollowRef.current = true;
isBrowsingHistoryRef.current = true;
historyLoadingLockUntilRef.current = Date.now() + 3000;
// 保存加载前的滚动位置和内容高度
const scrollYBefore = scrollPositionRef.current.scrollY;
const contentHeightBefore = scrollPositionRef.current.contentHeight;
setLoadingMore(true);
try {
await loadMoreMessages();
// 更新 firstSeq
if (messages.length > 0) {
const minSeq = Math.min(...messages.map(m => m.seq));
setFirstSeq(minSeq);
}
// inverted + maintainVisibleContentPosition 下由列表原生保持位置
// 不做手动 scrollToOffset避免与原生锚点冲突导致回到底部
} catch (error) {
console.error('加载历史消息失败:', error);
} finally {
@@ -609,7 +590,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
// 加载结束后继续保留短暂锁窗,避免布局结算阶段误触自动回底
historyLoadingLockUntilRef.current = Date.now() + 800;
}
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]);
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages]);
// 从搜索结果跳转:滚动到目标 seq
useEffect(() => {
@@ -658,13 +639,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
isBrowsingHistoryRef.current = false;
// FlashList 在 inverted 模式下 scrollToOffset({ offset: 0 }) 可能无法真正滚到最底部,
// 因为列表内容高度变化后最小 offset 可能不是 0。先尝试滚到负值确保到底再补偿回 0。
isProgrammaticScrollRef.current = true;
flatListRef.current?.scrollToOffset({ offset: -99999, animated: false });
requestAnimationFrame(() => {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
setTimeout(() => {
isProgrammaticScrollRef.current = false;
}, 220);
});
}, [flatListRef]);
@@ -698,6 +675,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
// 自动标记已读QQ/Telegram 风格):
// 仅当出现更大的 latest seq且用户在最新端附近时才上报。
// 历史加载/浏览历史不会触发 read。
// 注意:不能复用 prevLatestSeqRef 做判定——它被新消息跟随 effect 无条件推进,
// 会导致这里的去重判断永远成立,标记已读逻辑事实上不会执行。
// 这里只依赖专属的 prevMarkedReadSeqRef 做去重。
useEffect(() => {
if (!conversationId || messages.length === 0) return;
if (loading || loadingMore) return;
@@ -707,10 +687,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
const latestSeq = Math.max(...messages.map(m => m.seq), 0);
if (latestSeq <= 0) return;
// 没有新增最新消息,不重复上报
if (latestSeq <= prevLatestSeqRef.current) return;
prevLatestSeqRef.current = latestSeq;
// 避免对同一 seq 重复标记
if (latestSeq <= prevMarkedReadSeqRef.current) return;
prevMarkedReadSeqRef.current = latestSeq;
@@ -720,18 +696,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
});
}, [messages, conversationId, markAsRead, loading, loadingMore, isNearBottom]);
// 使用 ref 存储 groupMembers
const groupMembersRef = useRef(groupMembers);
useEffect(() => {
groupMembersRef.current = groupMembers;
}, [groupMembers]);
// 使用 ref 存储 currentUserId
const currentUserIdRef = useRef(currentUserId);
useEffect(() => {
currentUserIdRef.current = currentUserId;
}, [currentUserId]);
// 监听键盘事件
useEffect(() => {
const keyboardWillShow = (e: KeyboardEvent) => {
@@ -1049,6 +1013,38 @@ export const useChatScreen = (props?: ChatScreenProps) => {
return segments;
}, []);
/**
* 构建文件消息的 segments 数组
*/
const buildFileSegments = useCallback((
fileUrl: string,
name: string,
size?: number,
mimeType?: string,
replyToMessage?: GroupMessage | null,
): MessageSegment[] => {
const segments: MessageSegment[] = [];
if (replyToMessage) {
segments.push({
type: 'reply',
data: { id: replyToMessage.id, seq: replyToMessage.seq } as ReplySegmentData
});
}
segments.push({
type: 'file',
data: {
url: fileUrl,
name,
size,
mime_type: mimeType,
} as FileSegmentData
});
return segments;
}, []);
// 【新架构】发送消息(支持纯文字、纯多图、图文同条)
const handleSend = useCallback(async () => {
const trimmedText = inputText.trim();
@@ -1253,6 +1249,89 @@ export const useChatScreen = (props?: ChatScreenProps) => {
setActivePanel('none');
}, [pendingAttachments.length]);
// 选择并发送文件(单选,立即上传后发送)
const handlePickFile = useCallback(async () => {
if (!conversationId) {
setActivePanel('none');
return;
}
if (isGroupChat && isMuted) {
Alert.alert('无法发送', muteAll ? '当前群组已开启全员禁言' : '你已被管理员禁言');
setActivePanel('none');
return;
}
setActivePanel('none');
try {
const result = await DocumentPicker.getDocumentAsync({
multiple: false,
copyToCacheDirectory: true,
});
if (result.canceled || !result.assets || result.assets.length === 0) {
return;
}
const asset = result.assets[0];
setUploadingAttachments(true);
const uploaded = await uploadService.uploadFile(
{
uri: asset.uri,
name: asset.name,
type: asset.mimeType || undefined,
},
'chat'
);
if (!uploaded?.url) {
Alert.alert('上传失败', getSendErrorMessage(null, '文件上传失败,请重试'));
return;
}
const segments = buildFileSegments(
uploaded.url,
uploaded.name || asset.name,
uploaded.size ?? asset.size,
uploaded.mime_type ?? asset.mimeType,
replyingTo,
);
setSending(true);
try {
if (isGroupChat && effectiveGroupId) {
await messageService.sendMessageByAction('group', conversationId, segments);
} else {
await sendMessageViaManager(segments);
}
setReplyingTo(null);
setTimeout(() => scrollToLatest(false, false, 'send-file'), 100);
} catch (error) {
console.error('发送文件消息失败:', error);
Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试'));
} finally {
setSending(false);
}
} catch (error) {
console.error('选择文件失败:', error);
Alert.alert('错误', getSendErrorMessage(error, '选择文件失败'));
} finally {
setUploadingAttachments(false);
}
}, [
conversationId,
isGroupChat,
isMuted,
muteAll,
effectiveGroupId,
replyingTo,
buildFileSegments,
sendMessageViaManager,
scrollToLatest,
getSendErrorMessage,
]);
// 处理更多功能
const handleMoreAction = useCallback((actionId: string) => {
switch (actionId) {
@@ -1281,17 +1360,12 @@ export const useChatScreen = (props?: ChatScreenProps) => {
handleTakePhoto();
break;
case 'file':
Alert.alert('提示', '文件功能即将上线');
setActivePanel('none');
break;
case 'location':
Alert.alert('提示', '位置功能即将上线');
setActivePanel('none');
handlePickFile();
break;
default:
setActivePanel('none');
}
}, [handlePickImage, handleTakePhoto, isGroupChat, otherUser, conversationId]);
}, [handlePickImage, handleTakePhoto, handlePickFile, isGroupChat, otherUser, conversationId]);
// 插入表情
const handleInsertEmoji = useCallback((emoji: string) => {
@@ -1364,20 +1438,15 @@ export const useChatScreen = (props?: ChatScreenProps) => {
setActivePanel('none');
}, []);
// 撤回消息 - 现在通过 MessageManager 处理
// 撤回消息 - 现在通过 MessageManager 处理(撤回事件由 MessageManager 同步状态)
const handleRecall = useCallback(async (messageId: string) => {
try {
if (isGroupChat && effectiveGroupId) {
await messageService.recallMessage(messageId);
} else {
await messageService.recallMessage(messageId);
}
// 不需要手动更新状态MessageManager 会处理撤回事件
await messageService.recallMessage(messageId);
} catch (error) {
console.error('撤回消息失败:', error);
Alert.alert('撤回失败', '无法撤回消息');
}
}, [isGroupChat, effectiveGroupId, conversationId]);
}, []);
// 长按消息显示操作菜单
const handleLongPressMessage = useCallback((message: GroupMessage, position?: MenuPosition) => {
@@ -1416,8 +1485,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
if (!conversationId) return;
try {
setLastSeq(0);
setFirstSeq(0);
setHasMoreHistory(true);
await messageRepository.clearConversation(conversationId);
// 刷新消息列表
@@ -1426,9 +1493,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
console.error('清空会话失败:', error);
Alert.alert('清空失败', '无法清空聊天记录');
}
}, [conversationId, refreshMessages]);
// 回复消息
}, [conversationId, refreshMessages]); // 回复消息
const handleReplyMessage = useCallback((message: GroupMessage) => {
setReplyingTo(message);
textInputRef.current?.focus();
@@ -1481,26 +1546,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
}
}, [groupTypingUsers, groupMembers]);
// 计算底部面板高度
const getPanelHeight = useCallback(() => {
if (activePanel === 'none') return 0;
if (activePanel === 'mention') return 250;
return 350;
}, [activePanel]);
// 计算输入框的bottom值
const getInputBottom = useCallback(() => {
if (keyboardHeight > 0) return keyboardHeight;
if (activePanel !== 'none') return getPanelHeight();
return 0;
}, [keyboardHeight, activePanel, getPanelHeight]);
// 计算消息列表的底部padding
const getListPaddingBottom = useCallback(() => {
if (activePanel !== 'none' && keyboardHeight === 0) return getPanelHeight();
return 0;
}, [keyboardHeight, activePanel, getPanelHeight]);
// 关闭所有面板
const handleDismiss = useCallback(() => {
Keyboard.dismiss();
@@ -1544,7 +1589,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
currentUserId,
keyboardHeight,
loading,
sending,
activePanel,
sendingImage,
uploadingAttachments,
@@ -1554,16 +1598,12 @@ export const useChatScreen = (props?: ChatScreenProps) => {
longPressMenuVisible,
selectedMessage,
selectedMessageId,
setSelectedMessageId,
menuPosition,
isGroupChat,
groupInfo,
groupMembers,
// 【改造】使用 MessageManager 的输入状态
typingUsers: groupTypingUsers,
currentUserRole,
mentionQuery,
selectedMentions,
isMuted,
muteAll,
followRestrictionHint,
@@ -1585,9 +1625,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
shouldShowTime,
handleInputChange,
handleSend,
handlePickImage,
removePendingAttachment,
handleTakePhoto,
handleMoreAction,
handleInsertEmoji,
handleSendSticker,
@@ -1606,9 +1644,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
handleMentionAll,
getSenderInfo,
getTypingHint,
getPanelHeight,
getInputBottom,
getListPaddingBottom,
handleDismiss,
navigateToInfo,
navigateToChatSettings,

View File

@@ -1,8 +1,10 @@
import { StatusBar } from 'expo-status-bar';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
TextInput,
@@ -13,7 +15,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { authService } from '../../services';
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
import { spacing, fontSizes, useAppColors, type AppColors } from '../../theme';
import { Text, SimpleHeader } from '../../components/common';
import { useResponsive, useResponsiveSpacing } from '../../hooks';
import { useAuthStore } from '../../stores';
@@ -33,6 +35,9 @@ function createAccountDeletionStyles(colors: AppColors) {
alignItems: 'center',
justifyContent: 'center',
},
keyboardView: {
flex: 1,
},
scrollContent: {
paddingVertical: spacing.lg,
},
@@ -41,53 +46,153 @@ function createAccountDeletionStyles(colors: AppColors) {
alignSelf: 'center',
width: '100%',
},
section: {
marginBottom: spacing['2xl'],
// 顶部叙述区:左对齐、有呼吸感
heroSection: {
paddingHorizontal: spacing['2xl'],
paddingTop: spacing.md,
paddingBottom: spacing.xl,
},
// 警告卡片 - 扁平化风格
warningCard: {
backgroundColor: colors.error.light + '20',
borderRadius: 14,
padding: spacing.lg,
marginHorizontal: spacing['2xl'],
marginBottom: spacing.xl,
},
warningTitle: {
fontWeight: '700',
fontSize: fontSizes.md,
heroEyebrow: {
fontSize: 13,
color: colors.text.hint,
letterSpacing: 1,
marginBottom: spacing.sm,
color: colors.error.main,
},
warningText: {
color: colors.error.dark,
marginBottom: spacing.xs,
fontSize: fontSizes.sm,
},
// 状态卡片 - 扁平化风格
statusCard: {
backgroundColor: colors.warning.light + '30',
borderRadius: 14,
padding: spacing.lg,
marginHorizontal: spacing['2xl'],
marginBottom: spacing.xl,
},
statusTitle: {
heroTitle: {
fontSize: 24,
fontWeight: '700',
fontSize: fontSizes.md,
color: colors.text.primary,
lineHeight: 32,
marginBottom: spacing.sm,
color: colors.warning.dark,
},
daysText: {
heroDesc: {
fontSize: 15,
color: colors.text.secondary,
lineHeight: 22,
},
// 倒计时区:圆环 + 数字 + 文案,替代"大数字 + 标签"卡片
countdownSection: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing['2xl'],
paddingVertical: spacing.lg,
},
countdownRing: {
width: 88,
height: 88,
borderRadius: 44,
borderWidth: 4,
borderColor: colors.warning.main,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.lg,
},
countdownNumber: {
fontSize: 32,
fontWeight: '700',
color: colors.warning.dark,
textAlign: 'center',
marginVertical: spacing.md,
lineHeight: 36,
},
// 内容区域
sectionContent: {
marginHorizontal: spacing['2xl'],
marginBottom: spacing.xl,
countdownUnit: {
fontSize: 11,
color: colors.text.secondary,
marginTop: -2,
},
countdownTextWrap: {
flex: 1,
},
countdownTitle: {
fontSize: 16,
fontWeight: '600',
color: colors.text.primary,
marginBottom: 4,
},
countdownDesc: {
fontSize: 13,
color: colors.text.secondary,
lineHeight: 19,
},
// 引导文 + 列表(无背景、无圆角,自然排版)
guideSection: {
paddingHorizontal: spacing['2xl'],
paddingTop: spacing.lg,
},
guideLabel: {
fontSize: 13,
color: colors.text.secondary,
fontWeight: '500',
marginBottom: spacing.md,
},
guideItem: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingVertical: 10,
},
guideIcon: {
marginRight: spacing.md,
marginTop: 2,
},
guideTextWrap: {
flex: 1,
},
guideTitle: {
fontSize: 15,
fontWeight: '500',
color: colors.text.primary,
marginBottom: 2,
},
guideDesc: {
fontSize: 13,
color: colors.text.secondary,
lineHeight: 19,
},
guideDivider: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.divider,
marginLeft: spacing['2xl'] + 22 + spacing.md,
},
// 注意事项(无填色,仅左侧色条)
noticeSection: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingHorizontal: spacing['2xl'],
paddingTop: spacing.lg,
paddingBottom: spacing.md,
},
noticeBar: {
width: 3,
alignSelf: 'stretch',
backgroundColor: colors.error.main,
borderRadius: 2,
marginRight: spacing.md,
},
noticeTextWrap: {
flex: 1,
},
noticeTitle: {
fontSize: 14,
fontWeight: '600',
color: colors.error.main,
marginBottom: 4,
},
noticeDesc: {
fontSize: 13,
color: colors.text.secondary,
lineHeight: 20,
},
// 表单区(与 AccountSecurity 风格一致:分节标题 + 输入框)
formSection: {
marginTop: spacing.lg,
},
sectionHeader: {
paddingHorizontal: spacing['2xl'],
marginBottom: spacing.sm,
marginTop: spacing.sm,
},
sectionTitle: {
fontWeight: '600',
@@ -95,47 +200,40 @@ function createAccountDeletionStyles(colors: AppColors) {
color: colors.text.secondary,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: spacing.md,
},
listItem: {
// 输入框
inputWrapper: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: spacing.sm,
},
listBullet: {
marginRight: spacing.sm,
marginTop: 2,
},
// 输入框 - 扁平化风格
input: {
alignItems: 'center',
backgroundColor: colors.background.default,
borderRadius: 14,
padding: spacing.md,
fontSize: 16,
paddingHorizontal: spacing.lg,
height: 56,
marginHorizontal: spacing['2xl'],
marginBottom: spacing.md,
},
inputIcon: {
marginRight: spacing.sm,
},
input: {
flex: 1,
color: colors.text.primary,
fontSize: fontSizes.md,
height: 56,
},
// 按钮行
eyeButton: {
padding: 4,
marginLeft: 4,
},
// 按钮行:次按钮在左、危险按钮在右,与全站保持一致
buttonRow: {
flexDirection: 'row',
gap: spacing.md,
marginTop: spacing.lg,
marginHorizontal: spacing['2xl'],
},
// 扁平化按钮
primaryButton: {
flex: 1,
height: 56,
borderRadius: 14,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
},
primaryButtonText: {
color: colors.text.inverse,
fontSize: fontSizes.md,
fontWeight: '600',
},
secondaryButton: {
flex: 1,
height: 56,
@@ -165,11 +263,38 @@ function createAccountDeletionStyles(colors: AppColors) {
fontWeight: '600',
},
buttonDisabled: {
opacity: 0.6,
opacity: 0.5,
},
cancelButton: {
marginTop: spacing.lg,
primaryButton: {
height: 56,
borderRadius: 14,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginHorizontal: spacing['2xl'],
marginTop: spacing.md,
},
primaryButtonText: {
color: colors.text.inverse,
fontSize: fontSizes.md,
fontWeight: '600',
},
// 页脚:联系客服
footer: {
alignItems: 'center',
marginTop: spacing.xl,
paddingHorizontal: spacing['2xl'],
},
footerText: {
fontSize: 13,
color: colors.text.hint,
lineHeight: 20,
textAlign: 'center',
},
footerLink: {
color: colors.primary.main,
fontWeight: '500',
},
});
}
@@ -185,9 +310,9 @@ export const AccountDeletionScreen: React.FC = () => {
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md;
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
const loadStatus = useCallback(async () => {
@@ -284,121 +409,244 @@ export const AccountDeletionScreen: React.FC = () => {
);
}
// 待注销状态
if (status?.is_pending_deletion) {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<StatusBar style="auto" />
<SimpleHeader title="注销账号" onBack={() => router.back()} />
<ScrollView
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
]}
showsVerticalScrollIndicator={false}
>
<View style={styles.content}>
{/* 顶部叙述 */}
<View style={styles.heroSection}>
<Text style={styles.heroEyebrow}>ACCOUNT · </Text>
<Text style={styles.heroTitle}></Text>
<Text style={styles.heroDesc}>
使
</Text>
</View>
{/* 倒计时 */}
<View style={styles.countdownSection}>
<View style={styles.countdownRing}>
<Text style={styles.countdownNumber}>{status.cool_down_days || 90}</Text>
<Text style={styles.countdownUnit}></Text>
</View>
<View style={styles.countdownTextWrap}>
<Text style={styles.countdownTitle}></Text>
<Text style={styles.countdownDesc}>
</Text>
</View>
</View>
{/* 取消按钮 */}
<TouchableOpacity
style={[
styles.primaryButton,
submitting && styles.buttonDisabled,
]}
onPress={handleCancelDeletion}
disabled={submitting}
activeOpacity={0.9}
>
{submitting ? (
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.primaryButtonText}></Text>
)}
</TouchableOpacity>
{/* 页脚 */}
<View style={styles.footer}>
<Text style={styles.footerText}>
<Text style={styles.footerLink}> </Text>
</Text>
</View>
</View>
</ScrollView>
</SafeAreaView>
);
}
// 正常状态:申请注销
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<StatusBar style="auto" />
<SimpleHeader title="注销账号" onBack={() => router.back()} />
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
<View style={styles.content}>
{status?.is_pending_deletion ? (
<View style={styles.section}>
<View style={styles.statusCard}>
<Text variant="body" style={styles.statusTitle}>
</Text>
<Text variant="body" color={colors.text.secondary}>
</Text>
<Text variant="body" style={styles.daysText}>
{status.cool_down_days || 90}
</Text>
<Text variant="caption" color={colors.text.secondary}>
</Text>
<TouchableOpacity
style={[styles.secondaryButton, styles.cancelButton, submitting && styles.buttonDisabled]}
onPress={handleCancelDeletion}
disabled={submitting}
>
{submitting ? (
<ActivityIndicator size="small" color={colors.text.primary} />
) : (
<Text style={styles.secondaryButtonText}></Text>
)}
</TouchableOpacity>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<View style={styles.content}>
{/* 顶部叙述 */}
<View style={styles.heroSection}>
<Text style={styles.heroEyebrow}>ACCOUNT · </Text>
<Text style={styles.heroTitle}></Text>
<Text style={styles.heroDesc}>
90
</Text>
</View>
{/* 注销影响 - 列表式(无背景) */}
<View style={styles.guideSection}>
<Text style={styles.guideLabel}></Text>
<View style={styles.guideItem}>
<MaterialCommunityIcons
name="account-remove-outline"
size={22}
color={colors.error.main}
style={styles.guideIcon}
/>
<View style={styles.guideTextWrap}>
<Text style={styles.guideTitle}></Text>
<Text style={styles.guideDesc}></Text>
</View>
</View>
<View style={styles.guideDivider} />
<View style={styles.guideItem}>
<MaterialCommunityIcons
name="message-text-outline"
size={22}
color={colors.warning.main}
style={styles.guideIcon}
/>
<View style={styles.guideTextWrap}>
<Text style={styles.guideTitle}></Text>
<Text style={styles.guideDesc}></Text>
</View>
</View>
<View style={styles.guideDivider} />
<View style={styles.guideItem}>
<MaterialCommunityIcons
name="heart-broken-outline"
size={22}
color={colors.error.main}
style={styles.guideIcon}
/>
<View style={styles.guideTextWrap}>
<Text style={styles.guideTitle}></Text>
<Text style={styles.guideDesc}></Text>
</View>
</View>
<View style={styles.guideDivider} />
<View style={styles.guideItem}>
<MaterialCommunityIcons
name="clock-time-four-outline"
size={22}
color={colors.text.secondary}
style={styles.guideIcon}
/>
<View style={styles.guideTextWrap}>
<Text style={styles.guideTitle}>90 </Text>
<Text style={styles.guideDesc}></Text>
</View>
</View>
</View>
) : (
<View style={styles.section}>
{/* 警告卡片 */}
<View style={styles.warningCard}>
<Text variant="body" style={styles.warningTitle}>
{/* 红色提示 - 左侧细线代替大色块 */}
<View style={styles.noticeSection}>
<View style={styles.noticeBar} />
<View style={styles.noticeTextWrap}>
<Text style={styles.noticeTitle}></Text>
<Text style={styles.noticeDesc}>
90
</Text>
<Text variant="body" style={styles.warningText}>
90
</Text>
<Text variant="body" style={styles.warningText}>
</View>
</View>
{/* 密码确认 */}
<View style={styles.formSection}>
<View style={styles.sectionHeader}>
<Text variant="caption" style={styles.sectionTitle}>
</Text>
</View>
{/* 注销说明 */}
<View style={styles.sectionContent}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.listItem}>
<MaterialCommunityIcons name="close-circle" size={16} color={colors.error.main} style={styles.listBullet} />
<Text variant="body" color={colors.text.secondary}>
</Text>
</View>
<View style={styles.listItem}>
<MaterialCommunityIcons name="information" size={16} color={colors.warning.main} style={styles.listBullet} />
<Text variant="body" color={colors.text.secondary}>
</Text>
</View>
<View style={styles.listItem}>
<MaterialCommunityIcons name="close-circle" size={16} color={colors.error.main} style={styles.listBullet} />
<Text variant="body" color={colors.text.secondary}>
</Text>
</View>
<View style={styles.listItem}>
<MaterialCommunityIcons name="close-circle" size={16} color={colors.error.main} style={styles.listBullet} />
<Text variant="body" color={colors.text.secondary}>
</Text>
</View>
</View>
{/* 密码确认 */}
<View style={styles.sectionContent}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons
name="lock-outline"
size={20}
color={colors.text.secondary}
style={styles.inputIcon}
/>
<TextInput
style={styles.input}
placeholder="请输入密码"
placeholder="请输入登录密码以确认"
placeholderTextColor={colors.text.hint}
secureTextEntry
secureTextEntry={!showPassword}
value={password}
onChangeText={setPassword}
autoCapitalize="none"
returnKeyType="done"
/>
</View>
{/* 按钮行 */}
<View style={styles.buttonRow}>
<TouchableOpacity
style={styles.secondaryButton}
onPress={() => router.back()}
onPress={() => setShowPassword(!showPassword)}
style={styles.eyeButton}
>
<Text style={styles.secondaryButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.dangerButton, submitting && styles.buttonDisabled]}
onPress={handleRequestDeletion}
disabled={submitting || !password.trim()}
>
{submitting ? (
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.dangerButtonText}></Text>
)}
<MaterialCommunityIcons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={colors.text.hint}
/>
</TouchableOpacity>
</View>
</View>
)}
</View>
</ScrollView>
{/* 按钮行 */}
<View style={styles.buttonRow}>
<TouchableOpacity
style={styles.secondaryButton}
onPress={() => router.back()}
activeOpacity={0.8}
>
<Text style={styles.secondaryButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.dangerButton,
(submitting || !password.trim()) && styles.buttonDisabled,
]}
onPress={handleRequestDeletion}
disabled={submitting || !password.trim()}
activeOpacity={0.9}
>
{submitting ? (
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.dangerButtonText}></Text>
)}
</TouchableOpacity>
</View>
{/* 页脚 */}
<View style={styles.footer}>
<Text style={styles.footerText}>
<Text style={styles.footerLink}> </Text>
</Text>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
};

View File

@@ -175,37 +175,6 @@ export const NotificationSettingsScreen: React.FC = () => {
}
};
const handleAutoStartModeChange = async (mode: AutoStartMode) => {
if (mode === AutoStartMode.BACKGROUND) {
Alert.alert(
'后台消息接收',
getAutoStartDescription() + '\n\n开启后应用可在后台接收消息推送。',
[
{ text: '取消', style: 'cancel' },
{
text: '同意并开启',
onPress: async () => {
await setAutoStartMode(AutoStartMode.BACKGROUND);
setAutoStartModeState(AutoStartMode.BACKGROUND);
setAutoStartConsented(true);
// 重新初始化后台服务以应用新设置
const { reinitBackgroundService } = await import('@/services/background');
await reinitBackgroundService();
Alert.alert('已开启', '后台消息接收已开启。您可随时在设置中关闭。');
},
},
]
);
} else {
await setAutoStartMode(AutoStartMode.SILENT);
setAutoStartModeState(AutoStartMode.SILENT);
setAutoStartConsented(false);
// 重新初始化后台服务以应用新设置
const { reinitBackgroundService } = await import('@/services/background');
await reinitBackgroundService();
}
};
const syncModeOptions: { mode: BackgroundSyncMode; title: string; subtitle: string; icon: string }[] = [
{
mode: BackgroundSyncMode.DISABLED,

View File

@@ -16,7 +16,8 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { useAppColors } from '../../theme';
import { Post } from '../../types';
import { PostCard, TabBar, UserProfileHeader } from '../../components/business';
import { Loading, EmptyState, ResponsiveContainer } from '../../components/common';
import type { PostCardAction } from '../../components/business/PostCard';
import { Loading, EmptyState, ResponsiveContainer, ImageGallery, ImageGridItem } from '../../components/common';
import { useResponsive } from '../../hooks';
import { useUserProfile, ProfileMode, TABS, TAB_ICONS, createSharedProfileStyles } from './useUserProfile';
@@ -116,6 +117,39 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
currentUser,
} = useUserProfile({ mode, userId, isDesktop, isTablet });
// 图片查看器状态(与 HomeScreen / PostDetailScreen 一致)
const [showImageViewer, setShowImageViewer] = useState(false);
const [postImages, setPostImages] = useState<ImageGridItem[]>([]);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const closeImageViewer = useCallback(() => setShowImageViewer(false), []);
const stableGalleryImages = useMemo(
() =>
postImages.map((img, i) => ({
id: img.id || img.url || `img-${i}`,
url: img.url || img.uri || '',
})),
[postImages]
);
// 包装 handlePostAction在 hook 通用逻辑之上处理 imagePress
const onPostAction = useCallback(
(post: Post, action: PostCardAction) => {
if (action.type === 'imagePress') {
const images = action.payload?.images;
const imageIndex = action.payload?.imageIndex;
if (images && imageIndex !== undefined) {
setPostImages(images);
setSelectedImageIndex(imageIndex);
setShowImageViewer(true);
}
return;
}
handlePostAction(post, action);
},
[handlePostAction]
);
// 当前显示的帖子列表
const currentPosts = activeTab === 0 ? posts : favorites;
@@ -130,12 +164,12 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
]}>
<PostCard
post={item}
onAction={(action) => handlePostAction(item, action)}
onAction={(action) => onPostAction(item, action)}
isPostAuthor={isPostAuthor}
/>
</View>
);
}, [currentUser?.id, handlePostAction, currentPosts.length]);
}, [currentUser?.id, onPostAction, currentPosts.length]);
const postKeyExtractor = useCallback((item: Post) => item.id, []);
@@ -269,6 +303,15 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
</View>
</View>
</ResponsiveContainer>
{/* 图片查看器 */}
<ImageGallery
visible={showImageViewer}
images={stableGalleryImages}
initialIndex={selectedImageIndex}
onClose={closeImageViewer}
enableSave
/>
</SafeAreaView>
);
}
@@ -310,6 +353,15 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
}
drawDistance={250}
/>
{/* 图片查看器 */}
<ImageGallery
visible={showImageViewer}
images={stableGalleryImages}
initialIndex={selectedImageIndex}
onClose={closeImageViewer}
enableSave
/>
</SafeAreaView>
);
};

View File

@@ -39,17 +39,27 @@ export interface VersionCheckResult {
hasUpdate: boolean;
currentVersion: string;
latestVersion: string;
runtimeVersion: string;
downloadUrl: string;
size: number;
}
/**
* 比较版本号
* 比较语义版本号SemVer 三段数字)
* 返回: 1 表示 v1 > v2, -1 表示 v1 < v2, 0 表示相等
*
* 注意:仅做 x.y.z 形式的纯数字比较任何一段非数字NaN会被当作 0 处理,
* 避免运行时版本号commit count 等)混入时导致方向相反的误判。
*/
function compareVersions(v1: string, v2: string): number {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
const parts1 = v1.split('.').map((s) => {
const n = Number.parseInt(s, 10);
return Number.isFinite(n) ? n : 0;
});
const parts2 = v2.split('.').map((s) => {
const n = Number.parseInt(s, 10);
return Number.isFinite(n) ? n : 0;
});
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const p1 = parts1[i] || 0;
@@ -86,10 +96,10 @@ async function fetchLatestAPKVersion(): Promise<APKVersionInfo | null> {
}
/**
* 获取当前应用版本
* 获取当前应用语义版本(来自 app.json / app.config.js 的 expo.version
*/
function getCurrentVersion(): string {
return Constants.expoConfig?.version || '1.0.0';
return Constants.expoConfig?.version || '1.0.1';
}
/**
@@ -144,6 +154,7 @@ async function downloadAndInstallAPK(downloadUrl: string, version: string): Prom
}
try {
// 文件名带语义版本用户可读runtimeVersion 已在 downloadUrl 中体现
const downloadPath = `${FileSystem.documentDirectory}with-you-${version}.apk`;
// 显示下载进度
@@ -247,15 +258,18 @@ export async function checkForAPKUpdate(force: boolean = false): Promise<Version
}
const currentVersion = getCurrentVersion();
// 远端必须返回独立的语义版本号;若缺失才回退到 runtimeVersion不推荐但兜底
const latestVersion = latestAPK.versionName || latestAPK.runtimeVersion;
const remoteRuntimeVersion = latestAPK.runtimeVersion;
// 比较版本
// 比较版本(仅基于语义版本,不受 buildNumber / runtimeVersion 影响)
const comparison = compareVersions(currentVersion, latestVersion);
const result: VersionCheckResult = {
hasUpdate: comparison < 0,
currentVersion,
latestVersion,
runtimeVersion: remoteRuntimeVersion,
downloadUrl: latestAPK.downloadUrl,
size: latestAPK.size,
};

View File

@@ -62,23 +62,36 @@ class PostSyncService {
}
async fetchPosts(params?: GetPostsParams, key: string = 'default'): Promise<PostsResult> {
const store = usePostListStore.getState();
store.updatePostsState(key, { isLoading: true, error: null });
const queryParams: GetPostsParams = {
pageSize: params?.pageSize || DEFAULT_PAGE_SIZE,
...params,
cursor: params?.cursor !== undefined ? params.cursor : '',
};
// 判断参数是否变化:参数变化(切换分区/排序/筛选)时直接清空旧数据,
// 避免渲染期间残留上一个分区的帖子造成闪烁
const prevState = usePostListStore.getState().getPostsState(key);
const paramsChanged = JSON.stringify(prevState.lastParams) !== JSON.stringify(queryParams);
// 参数变化时立即写入空数据 + loading 状态,确保 UI 第一时间进入加载态
// 而不是先渲染上一个分区的旧帖子
usePostListStore.getState().updatePostsState(key, {
isLoading: true,
error: null,
...(paramsChanged ? { posts: [], cursor: null, currentPage: 1, hasMore: true } : {}),
});
try {
const queryParams: GetPostsParams = {
pageSize: params?.pageSize || DEFAULT_PAGE_SIZE,
...params,
cursor: params?.cursor !== undefined ? params.cursor : '',
};
const result = await postRepository.getPosts(queryParams);
const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null;
const currentState = usePostListStore.getState().getPostsState(key);
const incomingPosts = Array.isArray(result.posts) ? result.posts : [];
const mergeStart = Date.now();
const mergedPosts = mergeRefreshWindow(currentState.posts, incomingPosts);
// 参数变化时直接替换;同参数刷新则合并以保留滚动位置上的帖子
const mergedPosts = paramsChanged
? incomingPosts
: mergeRefreshWindow(currentState.posts, incomingPosts);
const mergeCost = Date.now() - mergeStart;
if (mergeCost >= MERGE_PERF_WARN_THRESHOLD_MS) {
console.log('[PostPerf] fetchPosts merge cost', { key, costMs: mergeCost, prev: currentState.posts.length, next: incomingPosts.length });
@@ -116,7 +129,8 @@ class PostSyncService {
const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null;
const incomingPosts = Array.isArray(result.posts) ? result.posts : [];
const mergeStart = Date.now();
const mergedPosts = mergeRefreshWindow(state.posts, incomingPosts);
// refreshPosts 是刷新操作,应直接替换当前列表,避免旧数据残留
const mergedPosts = incomingPosts;
const mergeCost = Date.now() - mergeStart;
if (mergeCost >= MERGE_PERF_WARN_THRESHOLD_MS) {
console.log('[PostPerf] refreshPosts merge cost', { key, costMs: mergeCost, prev: state.posts.length, next: incomingPosts.length });

View File

@@ -41,6 +41,9 @@ export interface UploadResponse {
height?: number;
size?: number;
type?: string;
// 文件上传专用字段POST /uploads/files 返回)
name?: string;
mime_type?: string;
}
// 上传服务类
@@ -130,17 +133,18 @@ class UploadService {
}
}
// 上传文件(通用)
// 上传文件(通用,对接后端 POST /uploads/files
// 后端返回 { url, name, size, mime_type }
async uploadFile(
file: {
uri: string;
name?: string;
type?: string;
},
folder: string = 'general'
folder: string = 'chat'
): Promise<UploadResponse | null> {
try {
const response = await api.upload<UploadResponse>('/upload/file', {
const response = await api.upload<UploadResponse>('/uploads/files', {
uri: file.uri,
name: file.name || `file_${Date.now()}`,
type: file.type || 'application/octet-stream',

View File

@@ -46,6 +46,8 @@ export interface FileSegmentData {
name: string;
size?: number;
mime_type?: string;
/** 文件已过期(已被服务端清理),前端据此显示失效状态 */
expired?: boolean;
}
export interface AtSegmentData {