mirror of
https://mirror.skon.top/github.com/ILoveBingLu/CipherTalk
synced 2026-04-22 06:10:10 +08:00
发布 4.2.0:优化图片解密与聊天滚动体验
本次提交将应用版本更新到 4.2.0,并同步更新 package-lock、README 版本徽标和 CHANGELOG 发布说明。 主要变更: - 接入 CipherTalk 自研图片 DAT 原生解密模块,替换原先迁移自 WeFlow 的命名与资源落点。 - 新增 Windows x64 与 macOS arm64 的预编译 native addon 资源,并补充 manifest、检查脚本和同步脚本。 - 保留 native 优先、TypeScript 兜底的图片解密链路,兼容 V3/V4 图片、wxgf 后处理、缓存命中、高清图回退和实况照片提取。 - 优化图片解密服务的缓存校验、wxgf/HEVC 白图规避、耗时诊断和默认日志输出,减少线上噪音。 - 聊天消息列表改为动态高度虚拟列表,卸载屏幕外消息 DOM 与图片节点,降低长会话内存和渲染压力。 - 修复虚拟列表初始挂载时滚底与顶部历史预加载互相打架导致界面上下晃动的问题。 - 顶部历史消息改为接近顶部并向上滚动时提前加载,同时加强 prepend 后的滚动位置恢复。 - 解析图片 XML 中的宽高信息,并用于聊天图片骨架屏、未解密占位、已解密图片和图片查看器初始窗口尺寸。 - 打包清理逻辑改为按当前平台保留对应 native addon,避免安装包携带无关平台产物。 验证: - 已执行 npx tsc --noEmit,通过 TypeScript 类型检查。 - 本地未执行应用构建,发布构建交由 GitHub Actions 的 tag 发布工作流完成。
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -53,7 +53,10 @@ WeFlow
|
||||
WxKey-CC
|
||||
upx
|
||||
native-dlls
|
||||
native/image-decrypt/
|
||||
native/image-decrypt/target
|
||||
resources/whisper
|
||||
xkey
|
||||
skills
|
||||
.claude/
|
||||
.tmp
|
||||
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -10,6 +10,27 @@
|
||||
### 变更
|
||||
- 暂无
|
||||
|
||||
## [4.2.0] - 2026-04-21
|
||||
|
||||
### 新增
|
||||
- 新增 CipherTalk 自研图片 DAT 原生解密模块接入,支持 Windows x64 与 macOS arm64 预编译 `.node` 资源,并在打包时按平台保留对应产物。
|
||||
- 新增图片 native 解密运行时检查与同步脚本,便于验证本机原生模块是否可加载、是否为当前平台正确产物。
|
||||
- 聊天图片消息新增 XML 宽高解析,支持从 `cdnthumbwidth/cdnthumbheight`、`cdnmidwidth/cdnmidheight`、`cdnhdwidth/cdnhdheight` 等字段提取比例信息。
|
||||
|
||||
### 优化
|
||||
- 图片 DAT 解密链路改为 native 优先、TypeScript 兜底,保留原有 V3/V4 解密兼容路径、wxgf 后处理、缓存命中、高清图回退和实况照片提取逻辑。
|
||||
- 聊天图片解密中、未解密、未配置密钥和已解密状态统一按图片比例渲染占位,减少图片加载前后的布局跳动。
|
||||
- 图片查看器打开时会参考消息中的图片宽高预设窗口尺寸,图片真实加载后继续按实际尺寸校正。
|
||||
- 聊天消息列表改为动态高度虚拟列表,屏幕外消息 DOM 和图片节点会自动卸载,降低长会话滚动时的内存与渲染压力。
|
||||
- 顶部历史消息改为接近顶部并向上滚动时提前加载,同时加入滚动锚点恢复,减少加载更早消息后的跳屏。
|
||||
- 优化聊天滚动事件更新频率,避免每次滚动都触发不必要的 React 状态更新。
|
||||
|
||||
### 修复
|
||||
- 修复虚拟列表初始挂载时被误判为滚到顶部,导致打开聊天后历史加载与滚底逻辑互相打架、界面上下晃动的问题。
|
||||
- 修复部分 wxgf/HEVC 图片解码后出现纯白图的问题,避免错误缓存影响后续显示。
|
||||
- 修复图片 native 解密调试日志和聊天表匹配日志默认过多输出的问题,改为仅在调试环境变量启用时打印。
|
||||
- 修复图片 DAT 路径搜索、缓存检查与写入耗时较高时缺少定位信息的问题,保留可按需开启的耗时诊断。
|
||||
|
||||
## [4.1.9] - 2026-04-11
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
**一款现代化的微信聊天记录查看与分析工具**
|
||||
|
||||
[](LICENSE)
|
||||
[](package.json)
|
||||
[](package.json)
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
|
||||
@@ -2243,8 +2243,7 @@ function registerIpcHandlers() {
|
||||
ipcMain.handle('imageDecrypt:decryptImage', async (_, inputPath: string, outputPath: string, xorKey: number, aesKey?: string) => {
|
||||
try {
|
||||
logService?.info('ImageDecrypt', '开始解密图片', { inputPath, outputPath })
|
||||
const aesKeyBuffer = aesKey ? imageDecryptService.asciiKey16(aesKey) : undefined
|
||||
await imageDecryptService.decryptToFile(inputPath, outputPath, xorKey, aesKeyBuffer)
|
||||
await imageDecryptService.decryptToFile(inputPath, outputPath, xorKey, aesKey)
|
||||
logService?.info('ImageDecrypt', '图片解密成功', { outputPath })
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
|
||||
@@ -892,7 +892,7 @@ class ChatService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
if (tables.length > 0) {
|
||||
if (tables.length > 0 && process.env.CIPHERTALK_CHAT_DEBUG === '1') {
|
||||
const sample = tables.slice(0, 8).map(t => t.name).join(', ')
|
||||
console.warn(`[ChatService] 未匹配到消息表: session=${sessionId}, hash=${hash}, tables=${tables.length}, sample=[${sample}]`)
|
||||
}
|
||||
|
||||
@@ -1042,7 +1042,7 @@ class DataManagementService {
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
const totalFiles = pendingImages.length
|
||||
const aesKeyBuffer = aesKeyStr ? imageDecryptService.asciiKey16(String(aesKeyStr)) : Buffer.alloc(16)
|
||||
const aesKeyText = aesKeyStr ? String(aesKeyStr) : undefined
|
||||
|
||||
// 分批处理,每批 50 个,避免内存溢出
|
||||
const BATCH_SIZE = 50
|
||||
@@ -1067,7 +1067,7 @@ class DataManagementService {
|
||||
const outputRelativePath = relativePath.replace(/\.dat$/, '')
|
||||
|
||||
// 解密图片
|
||||
const decrypted = imageDecryptService.decryptDatFile(img.filePath, xorKey, aesKeyBuffer)
|
||||
const decrypted = imageDecryptService.decryptDatFile(img.filePath, xorKey, aesKeyText)
|
||||
|
||||
// 检测图片格式
|
||||
const ext = this.detectImageFormat(decrypted)
|
||||
@@ -1213,14 +1213,14 @@ class DataManagementService {
|
||||
const outputRelativePath = relativePath.replace(/\.dat$/, '')
|
||||
|
||||
// 解密图片
|
||||
const aesKeyBuffer = aesKeyStr ? imageDecryptService.asciiKey16(String(aesKeyStr)) : undefined
|
||||
const aesKeyText = aesKeyStr ? String(aesKeyStr) : undefined
|
||||
console.log('解密图片:', filePath)
|
||||
console.log('XOR Key:', xorKey.toString(16))
|
||||
console.log('AES Key String:', aesKeyStr)
|
||||
console.log('AES Key Buffer:', aesKeyBuffer?.toString('hex'))
|
||||
console.log('图片版本:', imageDecryptService.getDatVersion(filePath))
|
||||
|
||||
const decrypted = imageDecryptService.decryptDatFile(filePath, xorKey, aesKeyBuffer || Buffer.alloc(16))
|
||||
const decrypted = imageDecryptService.decryptDatFile(filePath, xorKey, aesKeyText)
|
||||
|
||||
// 检测图片格式
|
||||
const ext = this.detectImageFormat(decrypted)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
155
electron/services/nativeImageDecrypt.ts
Normal file
155
electron/services/nativeImageDecrypt.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
const CURRENT_ADDON_NAME = 'ciphertalk-image-native'
|
||||
|
||||
type NativeDecryptResult = {
|
||||
data: Buffer
|
||||
ext: string
|
||||
isWxgf?: boolean
|
||||
is_wxgf?: boolean
|
||||
}
|
||||
|
||||
type NativeAddon = {
|
||||
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
|
||||
}
|
||||
|
||||
type NativeAddonMetadata = {
|
||||
name?: string
|
||||
version?: string
|
||||
vendor?: string
|
||||
source?: string
|
||||
platforms?: string[]
|
||||
}
|
||||
|
||||
let cachedAddon: NativeAddon | null | undefined
|
||||
let cachedMetadata: NativeAddonMetadata | null | undefined
|
||||
|
||||
function shouldEnableNative(): boolean {
|
||||
return process.env.CIPHERTALK_IMAGE_NATIVE !== '0'
|
||||
}
|
||||
|
||||
function expandAsarCandidates(filePath: string): string[] {
|
||||
if (!filePath.includes('app.asar') || filePath.includes('app.asar.unpacked')) {
|
||||
return [filePath]
|
||||
}
|
||||
return [filePath.replace('app.asar', 'app.asar.unpacked'), filePath]
|
||||
}
|
||||
|
||||
function getPlatformDir(): string {
|
||||
if (process.platform === 'win32') return 'win32'
|
||||
if (process.platform === 'darwin') return 'macos'
|
||||
if (process.platform === 'linux') return 'linux'
|
||||
return process.platform
|
||||
}
|
||||
|
||||
function getArchDir(): string {
|
||||
if (process.arch === 'x64') return 'x64'
|
||||
if (process.arch === 'arm64') return 'arm64'
|
||||
return process.arch
|
||||
}
|
||||
|
||||
function getAddonCandidates(): string[] {
|
||||
const platformDir = getPlatformDir()
|
||||
const archDir = getArchDir()
|
||||
const cwd = process.cwd()
|
||||
const fileName = `${CURRENT_ADDON_NAME}-${platformDir}-${archDir}.node`
|
||||
const roots = [
|
||||
join(cwd, 'resources', 'wedecrypt'),
|
||||
...(process.resourcesPath
|
||||
? [
|
||||
join(process.resourcesPath, 'resources', 'wedecrypt'),
|
||||
join(process.resourcesPath, 'wedecrypt')
|
||||
]
|
||||
: [])
|
||||
]
|
||||
const candidates = roots.map((root) => join(root, fileName))
|
||||
return Array.from(new Set(candidates.flatMap(expandAsarCandidates)))
|
||||
}
|
||||
|
||||
function loadAddon(): NativeAddon | null {
|
||||
if (!shouldEnableNative()) return null
|
||||
if (cachedAddon !== undefined) return cachedAddon
|
||||
|
||||
for (const candidate of getAddonCandidates()) {
|
||||
if (!existsSync(candidate)) continue
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const addon = require(candidate) as NativeAddon
|
||||
if (addon && typeof addon.decryptDatNative === 'function') {
|
||||
cachedAddon = addon
|
||||
return addon
|
||||
}
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
|
||||
cachedAddon = null
|
||||
return null
|
||||
}
|
||||
|
||||
function getMetadataCandidates(): string[] {
|
||||
const cwd = process.cwd()
|
||||
const candidates = [
|
||||
join(cwd, 'resources', 'wedecrypt', 'manifest.json'),
|
||||
...(process.resourcesPath
|
||||
? [
|
||||
join(process.resourcesPath, 'resources', 'wedecrypt', 'manifest.json'),
|
||||
join(process.resourcesPath, 'wedecrypt', 'manifest.json')
|
||||
]
|
||||
: [])
|
||||
]
|
||||
return Array.from(new Set(candidates.flatMap(expandAsarCandidates)))
|
||||
}
|
||||
|
||||
export function nativeAddonMetadata(): NativeAddonMetadata | null {
|
||||
if (cachedMetadata !== undefined) return cachedMetadata
|
||||
|
||||
for (const candidate of getMetadataCandidates()) {
|
||||
if (!existsSync(candidate)) continue
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(candidate, 'utf8')) as NativeAddonMetadata
|
||||
cachedMetadata = parsed
|
||||
return parsed
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
|
||||
cachedMetadata = null
|
||||
return null
|
||||
}
|
||||
|
||||
export function nativeAddonLocation(): string | null {
|
||||
for (const candidate of getAddonCandidates()) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function nativeDecryptEnabled(): boolean {
|
||||
return shouldEnableNative()
|
||||
}
|
||||
|
||||
export function decryptDatViaNative(
|
||||
inputPath: string,
|
||||
xorKey: number,
|
||||
aesKey?: string
|
||||
): { data: Buffer; ext: string; isWxgf: boolean } | null {
|
||||
const addon = loadAddon()
|
||||
if (!addon) return null
|
||||
|
||||
try {
|
||||
const result = addon.decryptDatNative(inputPath, xorKey, aesKey)
|
||||
const isWxgf = Boolean(result?.isWxgf ?? result?.is_wxgf)
|
||||
if (!result || !Buffer.isBuffer(result.data)) return null
|
||||
const rawExt = typeof result.ext === 'string' && result.ext.trim()
|
||||
? result.ext.trim().toLowerCase()
|
||||
: ''
|
||||
const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : ''
|
||||
return { data: result.data, ext, isWxgf }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ciphertalk",
|
||||
"version": "4.1.8",
|
||||
"version": "4.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ciphertalk",
|
||||
"version": "4.1.8",
|
||||
"version": "4.2.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ciphertalk",
|
||||
"version": "4.1.9",
|
||||
"version": "4.2.0",
|
||||
"description": "密语 - 微信聊天记录查看工具",
|
||||
"author": "ILoveBingLu",
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
@@ -10,6 +10,11 @@
|
||||
"icon:mac": "bash scripts/build-macos-icon.sh",
|
||||
"native:macos": "bash native-dlls/build-macos.sh",
|
||||
"native:macos:check": "node scripts/check-macos-native.js",
|
||||
"native:image:check": "cargo check --manifest-path native/image-decrypt/Cargo.toml",
|
||||
"native:image:build": "cargo build --manifest-path native/image-decrypt/Cargo.toml --release",
|
||||
"native:image:check:current": "node scripts/check-image-native.cjs",
|
||||
"native:image:sync": "node scripts/sync-image-native.cjs",
|
||||
"native:image:build:sync": "npm run native:image:build && npm run native:image:sync",
|
||||
"build:prepare:mac": "node scripts/patch-dmg-builder.cjs",
|
||||
"build:prepare": "node scripts/update-readme-version.js && node scripts/prepare-release-announcement.js",
|
||||
"prebuild": "node scripts/update-readme-version.js && node scripts/prepare-release-announcement.js",
|
||||
@@ -214,6 +219,8 @@
|
||||
"node_modules/sherpa-onnx-node/**/*",
|
||||
"node_modules/koffi/**/*",
|
||||
"dist-electron/workers/**/*",
|
||||
"resources/wedecrypt/*.node",
|
||||
"resources/wedecrypt/**/*.node",
|
||||
"resources/**/*"
|
||||
]
|
||||
}
|
||||
|
||||
BIN
resources/wedecrypt/ciphertalk-image-native-macos-arm64.node
Normal file
BIN
resources/wedecrypt/ciphertalk-image-native-macos-arm64.node
Normal file
Binary file not shown.
BIN
resources/wedecrypt/ciphertalk-image-native-win32-x64.node
Normal file
BIN
resources/wedecrypt/ciphertalk-image-native-win32-x64.node
Normal file
Binary file not shown.
14
resources/wedecrypt/manifest.json
Normal file
14
resources/wedecrypt/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "ciphertalk-image-native",
|
||||
"version": "source-present-selfbuilt",
|
||||
"vendor": "CipherTalk",
|
||||
"source": "native/image-decrypt",
|
||||
"activeBinaries": {
|
||||
"win32-x64": "self-built-from-repo-source",
|
||||
"macos-arm64": "self-built-from-repo-source"
|
||||
},
|
||||
"platforms": [
|
||||
"win32-x64",
|
||||
"macos-arm64"
|
||||
]
|
||||
}
|
||||
43
scripts/check-image-native.cjs
Normal file
43
scripts/check-image-native.cjs
Normal file
@@ -0,0 +1,43 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
const baseDir = path.join(rootDir, 'resources', 'wedecrypt')
|
||||
const addonName = 'ciphertalk-image-native'
|
||||
|
||||
function resolvePlatformDir(value = process.platform) {
|
||||
if (value === 'win32') return 'win32'
|
||||
if (value === 'darwin' || value === 'macos') return 'macos'
|
||||
if (value === 'linux') return 'linux'
|
||||
throw new Error(`Unsupported platform: ${value}`)
|
||||
}
|
||||
|
||||
function resolveArchDir(value = process.arch) {
|
||||
if (value === 'x64') return 'x64'
|
||||
if (value === 'arm64') return 'arm64'
|
||||
throw new Error(`Unsupported arch: ${value}`)
|
||||
}
|
||||
|
||||
function main() {
|
||||
const platformDir = resolvePlatformDir(process.env.CIPHERTALK_IMAGE_NATIVE_PLATFORM || process.platform)
|
||||
const archDir = resolveArchDir(process.env.CIPHERTALK_IMAGE_NATIVE_ARCH || process.arch)
|
||||
const addonPath = path.join(baseDir, `${addonName}-${platformDir}-${archDir}.node`)
|
||||
|
||||
console.log(`[image-native-check] target: ${platformDir}/${archDir}`)
|
||||
console.log(`[image-native-check] addon path: ${addonPath}`)
|
||||
|
||||
if (!fs.existsSync(addonPath)) {
|
||||
console.error('[image-native-check] missing image native addon')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const stat = fs.statSync(addonPath)
|
||||
if (!stat.isFile() || stat.size <= 0) {
|
||||
console.error('[image-native-check] invalid image native addon')
|
||||
process.exit(3)
|
||||
}
|
||||
|
||||
console.log(`[image-native-check] ok (${stat.size} bytes)`)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -3,6 +3,7 @@ const path = require('path')
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
const macosDir = path.join(rootDir, 'resources', 'macos')
|
||||
const imageNativeBaseDir = path.join(rootDir, 'resources', 'wedecrypt')
|
||||
|
||||
const requiredArtifacts = [
|
||||
{ name: 'libwx_key.dylib', type: 'file', generated: true },
|
||||
@@ -65,6 +66,20 @@ function main() {
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const imageNativeArch = process.env.CIPHERTALK_IMAGE_NATIVE_ARCH || process.arch
|
||||
const imageNativeAddon = path.join(
|
||||
imageNativeBaseDir,
|
||||
`ciphertalk-image-native-macos-${imageNativeArch}.node`
|
||||
)
|
||||
|
||||
const imageNativeStat = statSafe(imageNativeAddon)
|
||||
if (!imageNativeStat || !imageNativeStat.isFile()) {
|
||||
console.error(`[macos-native-check] missing image native addon: ${imageNativeAddon}`)
|
||||
process.exit(3)
|
||||
}
|
||||
|
||||
console.log(`[macos-native-check] image native addon ok: ${imageNativeAddon} (${imageNativeStat.size} bytes)`)
|
||||
|
||||
console.log('[macos-native-check] all required macOS native artifacts are present')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,92 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Arch } = require('electron-builder');
|
||||
|
||||
const IMAGE_NATIVE_PREFIX = 'ciphertalk-image-native-';
|
||||
const IMAGE_NATIVE_SUFFIX = '.node';
|
||||
|
||||
function resolveNativePlatform(electronPlatformName) {
|
||||
if (electronPlatformName === 'darwin') return 'macos';
|
||||
if (electronPlatformName === 'win32') return 'win32';
|
||||
if (electronPlatformName === 'linux') return 'linux';
|
||||
return electronPlatformName;
|
||||
}
|
||||
|
||||
function resolveNativeArch(arch) {
|
||||
if (typeof arch === 'string') return arch;
|
||||
if (typeof arch === 'number' && Arch[arch]) return Arch[arch];
|
||||
return process.arch;
|
||||
}
|
||||
|
||||
function uniqueExistingDirs(candidates) {
|
||||
return Array.from(new Set(candidates)).filter((targetPath) => fs.existsSync(targetPath));
|
||||
}
|
||||
|
||||
function rewriteNativeManifest(manifestPath, targetKey) {
|
||||
if (!fs.existsSync(manifestPath)) return;
|
||||
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const nextActiveBinaries = {};
|
||||
if (manifest.activeBinaries && manifest.activeBinaries[targetKey]) {
|
||||
nextActiveBinaries[targetKey] = manifest.activeBinaries[targetKey];
|
||||
}
|
||||
manifest.activeBinaries = nextActiveBinaries;
|
||||
manifest.platforms = Object.keys(nextActiveBinaries);
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
console.log(`已收敛 image native manifest 到当前平台: ${targetKey}`);
|
||||
} catch (error) {
|
||||
console.warn(`收敛 image native manifest 失败: ${manifestPath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneImageNativeAddons(context) {
|
||||
const platformDir = resolveNativePlatform(context.electronPlatformName);
|
||||
const archDir = resolveNativeArch(context.arch);
|
||||
const targetFileName = `${IMAGE_NATIVE_PREFIX}${platformDir}-${archDir}${IMAGE_NATIVE_SUFFIX}`;
|
||||
const targetKey = `${platformDir}-${archDir}`;
|
||||
const productName = context.packager?.appInfo?.productFilename || 'CipherTalk';
|
||||
const resourceRoots = uniqueExistingDirs([
|
||||
path.join(context.appOutDir, 'resources'),
|
||||
path.join(context.appOutDir, 'Contents', 'Resources'),
|
||||
path.join(context.appOutDir, `${productName}.app`, 'Contents', 'Resources')
|
||||
]);
|
||||
|
||||
for (const resourceRoot of resourceRoots) {
|
||||
for (const nativeDir of [
|
||||
path.join(resourceRoot, 'resources', 'wedecrypt'),
|
||||
path.join(resourceRoot, 'wedecrypt')
|
||||
]) {
|
||||
if (!fs.existsSync(nativeDir)) continue;
|
||||
|
||||
const nativeFiles = fs.readdirSync(nativeDir)
|
||||
.filter((file) => file.startsWith(IMAGE_NATIVE_PREFIX) && file.endsWith(IMAGE_NATIVE_SUFFIX));
|
||||
if (nativeFiles.length === 0) continue;
|
||||
|
||||
if (!nativeFiles.includes(targetFileName)) {
|
||||
console.warn(`未找到当前平台 image native addon,跳过裁剪: ${targetFileName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let deletedCount = 0;
|
||||
for (const file of nativeFiles) {
|
||||
if (file === targetFileName) continue;
|
||||
fs.rmSync(path.join(nativeDir, file), { force: true });
|
||||
deletedCount++;
|
||||
}
|
||||
|
||||
rewriteNativeManifest(path.join(nativeDir, 'manifest.json'), targetKey);
|
||||
console.log(`已裁剪 image native addon,仅保留 ${targetFileName},删除 ${deletedCount} 个无关文件。`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exports.default = async function (context) {
|
||||
// context.appOutDir 是打包后的临时解压目录
|
||||
const localesDir = path.join(context.appOutDir, 'locales');
|
||||
|
||||
if (fs.existsSync(localesDir)) {
|
||||
console.log('🧹 正在清理多余的 Chromium 语言包...');
|
||||
console.log('正在清理多余的 Chromium 语言包...');
|
||||
const files = fs.readdirSync(localesDir);
|
||||
|
||||
// 只保留中文(简体/繁体)和英文
|
||||
@@ -22,9 +102,11 @@ exports.default = async function (context) {
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
console.log(`✅ 已删除 ${deletedCount} 个无关语言包,仅保留中英文。`);
|
||||
console.log(`已删除 ${deletedCount} 个无关语言包,仅保留中英文。`);
|
||||
}
|
||||
|
||||
pruneImageNativeAddons(context);
|
||||
|
||||
if (context.electronPlatformName === 'darwin') {
|
||||
const productName = context.packager?.appInfo?.productFilename || 'CipherTalk';
|
||||
const launcherCandidates = [
|
||||
@@ -35,7 +117,7 @@ exports.default = async function (context) {
|
||||
for (const launcherPath of launcherCandidates) {
|
||||
if (!fs.existsSync(launcherPath)) continue;
|
||||
fs.chmodSync(launcherPath, 0o755);
|
||||
console.log(`✅ 已确保 macOS MCP 启动器可执行: ${launcherPath}`);
|
||||
console.log(`已确保 macOS MCP 启动器可执行: ${launcherPath}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
126
scripts/sync-image-native.cjs
Normal file
126
scripts/sync-image-native.cjs
Normal file
@@ -0,0 +1,126 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
const crateRoot = path.join(projectRoot, 'native', 'image-decrypt')
|
||||
const releaseDir = path.join(crateRoot, 'target', 'release')
|
||||
const addonName = 'ciphertalk-image-native'
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {}
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i]
|
||||
if (!arg.startsWith('--')) continue
|
||||
const key = arg.slice(2)
|
||||
const next = argv[i + 1]
|
||||
if (!next || next.startsWith('--')) {
|
||||
parsed[key] = '1'
|
||||
continue
|
||||
}
|
||||
parsed[key] = next
|
||||
i += 1
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function resolvePlatformDir(value = process.platform) {
|
||||
if (value === 'win32') return 'win32'
|
||||
if (value === 'darwin' || value === 'macos') return 'macos'
|
||||
if (value === 'linux') return 'linux'
|
||||
throw new Error(`Unsupported platform: ${value}`)
|
||||
}
|
||||
|
||||
function resolveArchDir(value = process.arch) {
|
||||
if (value === 'x64') return 'x64'
|
||||
if (value === 'arm64') return 'arm64'
|
||||
throw new Error(`Unsupported arch: ${value}`)
|
||||
}
|
||||
|
||||
function resolveBuiltLibrary(platformDir, customLibPath) {
|
||||
if (customLibPath) {
|
||||
return path.resolve(projectRoot, customLibPath)
|
||||
}
|
||||
if (platformDir === 'win32') {
|
||||
return path.join(releaseDir, 'ciphertalk_image_native.dll')
|
||||
}
|
||||
if (platformDir === 'macos') {
|
||||
return path.join(releaseDir, 'libciphertalk_image_native.dylib')
|
||||
}
|
||||
if (platformDir === 'linux') {
|
||||
return path.join(releaseDir, 'libciphertalk_image_native.so')
|
||||
}
|
||||
throw new Error(`Unsupported platform: ${platformDir}`)
|
||||
}
|
||||
|
||||
function removeLegacyOutput(platformDir, archDir, outputName) {
|
||||
const legacyDir = path.join(projectRoot, 'resources', 'wedecrypt', platformDir, archDir)
|
||||
const legacyPath = path.join(legacyDir, outputName)
|
||||
if (fs.existsSync(legacyPath)) {
|
||||
fs.rmSync(legacyPath, { force: true })
|
||||
}
|
||||
if (fs.existsSync(legacyDir) && fs.readdirSync(legacyDir).length === 0) {
|
||||
fs.rmSync(legacyDir, { recursive: true, force: true })
|
||||
}
|
||||
const platformDirPath = path.join(projectRoot, 'resources', 'wedecrypt', platformDir)
|
||||
if (fs.existsSync(platformDirPath) && fs.readdirSync(platformDirPath).length === 0) {
|
||||
fs.rmSync(platformDirPath, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function buildManifest() {
|
||||
const baseDir = path.join(projectRoot, 'resources', 'wedecrypt')
|
||||
const matrix = [
|
||||
['win32', 'x64'],
|
||||
['win32', 'arm64'],
|
||||
['macos', 'x64'],
|
||||
['macos', 'arm64'],
|
||||
['linux', 'x64'],
|
||||
['linux', 'arm64']
|
||||
]
|
||||
|
||||
const activeBinaries = {}
|
||||
const platforms = []
|
||||
for (const [platformDir, archDir] of matrix) {
|
||||
const filePath = path.join(baseDir, `${addonName}-${platformDir}-${archDir}.node`)
|
||||
if (!fs.existsSync(filePath)) continue
|
||||
const key = `${platformDir}-${archDir}`
|
||||
activeBinaries[key] = 'self-built-from-repo-source'
|
||||
platforms.push(key)
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
name: addonName,
|
||||
version: 'source-present-selfbuilt',
|
||||
vendor: 'CipherTalk',
|
||||
source: 'native/image-decrypt',
|
||||
activeBinaries,
|
||||
platforms
|
||||
}
|
||||
|
||||
fs.mkdirSync(baseDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(baseDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const platformDir = resolvePlatformDir(args.platform || process.env.CIPHERTALK_IMAGE_NATIVE_PLATFORM || process.platform)
|
||||
const archDir = resolveArchDir(args.arch || process.env.CIPHERTALK_IMAGE_NATIVE_ARCH || process.arch)
|
||||
const builtLibrary = resolveBuiltLibrary(platformDir, args.lib || process.env.CIPHERTALK_IMAGE_NATIVE_LIB)
|
||||
|
||||
if (!fs.existsSync(builtLibrary)) {
|
||||
throw new Error(`Built library not found: ${builtLibrary}`)
|
||||
}
|
||||
|
||||
const outputDir = path.join(projectRoot, 'resources', 'wedecrypt')
|
||||
const outputName = `${addonName}-${platformDir}-${archDir}.node`
|
||||
const outputPath = path.join(outputDir, outputName)
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
fs.copyFileSync(builtLibrary, outputPath)
|
||||
removeLegacyOutput(platformDir, archDir, outputName)
|
||||
buildManifest()
|
||||
|
||||
console.log(`[sync-image-native] synced ${builtLibrary} -> ${outputPath}`)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -270,6 +270,10 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
const messagesRef = useRef<Message[]>([])
|
||||
const isLoadingMoreRef = useRef(false)
|
||||
const lastScrollTopRef = useRef(0)
|
||||
const scrollToBottomAfterRenderRef = useRef(false)
|
||||
const scrollRestoreTimersRef = useRef<number[]>([])
|
||||
const currentSessionIdRef = useRef<string | null>(null)
|
||||
const lastUpdateTimeRef = useRef<number>(0)
|
||||
const updateTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
@@ -354,6 +358,87 @@ function ChatPage(_props: ChatPageProps) {
|
||||
setTimeout(() => setTopToast(null), 2000)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
isLoadingMoreRef.current = isLoadingMore
|
||||
}, [isLoadingMore])
|
||||
|
||||
const getMessageDomKey = useCallback((message: Message): string => {
|
||||
return [
|
||||
message.serverId ?? '',
|
||||
message.localId ?? '',
|
||||
message.createTime ?? '',
|
||||
message.sortSeq ?? ''
|
||||
].join('-')
|
||||
}, [])
|
||||
|
||||
const findMessageWrapperByKey = useCallback((listEl: HTMLElement, key: string): HTMLElement | null => {
|
||||
const wrappers = Array.from(listEl.querySelectorAll<HTMLElement>('.message-wrapper[data-message-key]'))
|
||||
return wrappers.find(el => el.dataset.messageKey === key) || null
|
||||
}, [])
|
||||
|
||||
const captureScrollAnchor = useCallback((): { key: string; top: number } | null => {
|
||||
const listEl = messageListRef.current
|
||||
if (!listEl) return null
|
||||
|
||||
const listRect = listEl.getBoundingClientRect()
|
||||
const wrappers = Array.from(listEl.querySelectorAll<HTMLElement>('.message-wrapper[data-message-key]'))
|
||||
const anchorEl = wrappers.find((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return rect.bottom >= listRect.top + 12
|
||||
})
|
||||
|
||||
const key = anchorEl?.dataset.messageKey
|
||||
if (!anchorEl || !key) return null
|
||||
|
||||
return {
|
||||
key,
|
||||
top: anchorEl.getBoundingClientRect().top - listRect.top
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearScrollRestoreTimers = useCallback(() => {
|
||||
for (const timer of scrollRestoreTimersRef.current) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
scrollRestoreTimersRef.current = []
|
||||
}, [])
|
||||
|
||||
const restoreScrollAnchor = useCallback((anchor: { key: string; top: number } | null) => {
|
||||
if (!anchor) return
|
||||
|
||||
clearScrollRestoreTimers()
|
||||
|
||||
const restore = () => {
|
||||
const listEl = messageListRef.current
|
||||
if (!listEl) return
|
||||
const anchorEl = findMessageWrapperByKey(listEl, anchor.key)
|
||||
if (!anchorEl) return
|
||||
|
||||
const listTop = listEl.getBoundingClientRect().top
|
||||
const currentTop = anchorEl.getBoundingClientRect().top - listTop
|
||||
const delta = currentTop - anchor.top
|
||||
if (Math.abs(delta) > 1) {
|
||||
listEl.scrollTop += delta
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
restore()
|
||||
scrollRestoreTimersRef.current = [
|
||||
window.setTimeout(() => {
|
||||
restore()
|
||||
isLoadingMoreRef.current = false
|
||||
}, 150)
|
||||
]
|
||||
})
|
||||
}, [clearScrollRestoreTimers, findMessageWrapperByKey])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearScrollRestoreTimers()
|
||||
}
|
||||
}, [clearScrollRestoreTimers])
|
||||
|
||||
const copyText = useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text || '')
|
||||
@@ -568,11 +653,12 @@ function ChatPage(_props: ChatPageProps) {
|
||||
// 标记用户正在操作(首次加载)
|
||||
isUserOperatingRef.current = true
|
||||
} else {
|
||||
if (isLoadingMoreRef.current) return
|
||||
isLoadingMoreRef.current = true
|
||||
setLoadingMore(true)
|
||||
}
|
||||
|
||||
// 记录加载前的第一条消息元素
|
||||
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
|
||||
const anchor = offset > 0 ? captureScrollAnchor() : null
|
||||
|
||||
try {
|
||||
// 确保连接已建立(如果未连接,先连接)
|
||||
@@ -590,18 +676,10 @@ function ChatPage(_props: ChatPageProps) {
|
||||
if (result.success && result.messages) {
|
||||
if (offset === 0) {
|
||||
setMessages(result.messages)
|
||||
// 首次加载滚动到底部 (瞬间)
|
||||
requestAnimationFrame(() => {
|
||||
scrollToBottom(false)
|
||||
})
|
||||
scrollToBottomAfterRenderRef.current = true
|
||||
} else {
|
||||
appendMessages(result.messages, true)
|
||||
// 加载更多后保持位置:让之前的第一条消息保持在原来的视觉位置
|
||||
if (firstMsgEl && listEl) {
|
||||
requestAnimationFrame(() => {
|
||||
listEl.scrollTop = firstMsgEl.offsetTop - 80
|
||||
})
|
||||
}
|
||||
restoreScrollAnchor(anchor)
|
||||
}
|
||||
setHasMoreMessages(result.hasMore ?? false)
|
||||
setCurrentOffset(offset + result.messages.length)
|
||||
@@ -611,6 +689,9 @@ function ChatPage(_props: ChatPageProps) {
|
||||
} finally {
|
||||
setLoadingMessages(false)
|
||||
setLoadingMore(false)
|
||||
if (offset > 0) {
|
||||
isLoadingMoreRef.current = false
|
||||
}
|
||||
// 加载完成后,延迟重置用户操作标记(给一点缓冲时间)
|
||||
if (offset === 0) {
|
||||
setTimeout(() => {
|
||||
@@ -714,11 +795,11 @@ function ChatPage(_props: ChatPageProps) {
|
||||
|
||||
// 滚动加载更多 + 显示/隐藏回到底部按钮
|
||||
const loadMoreMessagesInDateJumpMode = useCallback(async () => {
|
||||
if (!currentSessionId || dateJumpCursorSortSeq === null || isLoadingMore || !hasMoreMessages) return
|
||||
if (!currentSessionId || dateJumpCursorSortSeq === null || isLoadingMoreRef.current || !hasMoreMessages) return
|
||||
|
||||
const listEl = messageListRef.current
|
||||
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
|
||||
const anchor = captureScrollAnchor()
|
||||
|
||||
isLoadingMoreRef.current = true
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getMessagesBefore(
|
||||
@@ -756,11 +837,7 @@ function ChatPage(_props: ChatPageProps) {
|
||||
setHasMoreMessages(result.hasMore ?? false)
|
||||
}
|
||||
|
||||
if (firstMsgEl && listEl) {
|
||||
requestAnimationFrame(() => {
|
||||
listEl.scrollTop = firstMsgEl.offsetTop - 80
|
||||
})
|
||||
}
|
||||
restoreScrollAnchor(anchor)
|
||||
} else {
|
||||
setHasMoreMessages(false)
|
||||
}
|
||||
@@ -768,22 +845,24 @@ function ChatPage(_props: ChatPageProps) {
|
||||
console.error('日期跳转模式加载更多失败:', e)
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
isLoadingMoreRef.current = false
|
||||
}
|
||||
}, [
|
||||
currentSessionId,
|
||||
dateJumpCursorSortSeq,
|
||||
dateJumpCursorCreateTime,
|
||||
dateJumpCursorLocalId,
|
||||
isLoadingMore,
|
||||
hasMoreMessages,
|
||||
appendMessages,
|
||||
captureScrollAnchor,
|
||||
restoreScrollAnchor,
|
||||
setHasMoreMessages,
|
||||
setLoadingMore
|
||||
])
|
||||
|
||||
// 日期跳转模式:向下滑动加载更新的消息
|
||||
const loadMoreMessagesAfterInDateJumpMode = useCallback(async () => {
|
||||
if (!currentSessionId || dateJumpCursorSortSeqEnd === null || isLoadingMore || !hasMoreMessagesAfter) return
|
||||
if (!currentSessionId || dateJumpCursorSortSeqEnd === null || isLoadingMoreRef.current || !hasMoreMessagesAfter) return
|
||||
|
||||
const listEl = messageListRef.current
|
||||
if (!listEl) return
|
||||
@@ -792,6 +871,7 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const oldScrollHeight = listEl.scrollHeight
|
||||
const oldScrollTop = listEl.scrollTop
|
||||
|
||||
isLoadingMoreRef.current = true
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getMessagesAfter(
|
||||
@@ -845,13 +925,13 @@ function ChatPage(_props: ChatPageProps) {
|
||||
console.error('日期跳转模式向下加载失败:', e)
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
isLoadingMoreRef.current = false
|
||||
}
|
||||
}, [
|
||||
currentSessionId,
|
||||
dateJumpCursorSortSeqEnd,
|
||||
dateJumpCursorCreateTimeEnd,
|
||||
dateJumpCursorLocalIdEnd,
|
||||
isLoadingMore,
|
||||
hasMoreMessagesAfter,
|
||||
appendMessages
|
||||
])
|
||||
@@ -860,17 +940,19 @@ function ChatPage(_props: ChatPageProps) {
|
||||
if (!messageListRef.current) return
|
||||
|
||||
const { scrollTop, clientHeight, scrollHeight } = messageListRef.current
|
||||
const isScrollingUp = scrollTop < lastScrollTopRef.current - 4
|
||||
lastScrollTopRef.current = scrollTop
|
||||
|
||||
// 显示回到底部按钮:距离底部超过 300px
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
setShowScrollToBottom(distanceFromBottom > 300)
|
||||
|
||||
if (!isLoadingMore && currentSessionId) {
|
||||
const topThreshold = clientHeight * 0.3
|
||||
if (!isLoadingMoreRef.current && currentSessionId) {
|
||||
const topThreshold = Math.max(clientHeight * 2, 1200)
|
||||
const bottomThreshold = clientHeight * 0.3
|
||||
|
||||
// 向上滑动:加载更早的消息
|
||||
if (scrollTop < topThreshold && hasMoreMessages) {
|
||||
// 向上滑动:提前加载更早的消息,不等用户真正顶到顶部
|
||||
if (isScrollingUp && scrollTop < topThreshold && hasMoreMessages) {
|
||||
if (isDateJumpMode) {
|
||||
loadMoreMessagesInDateJumpMode()
|
||||
} else {
|
||||
@@ -884,7 +966,6 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isLoadingMore,
|
||||
hasMoreMessages,
|
||||
hasMoreMessagesAfter,
|
||||
currentSessionId,
|
||||
@@ -897,20 +978,27 @@ function ChatPage(_props: ChatPageProps) {
|
||||
// 滚动到底部
|
||||
const scrollToBottom = useCallback((smooth: boolean | React.MouseEvent = true) => {
|
||||
if (messageListRef.current) {
|
||||
// 如果传入的是事件对象,默认为 smooth
|
||||
const isSmooth = typeof smooth === 'boolean' ? smooth : true;
|
||||
|
||||
if (isSmooth) {
|
||||
messageListRef.current.scrollTo({
|
||||
top: messageListRef.current.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
messageListRef.current.scrollTo({ top: messageListRef.current.scrollHeight, behavior: 'smooth' })
|
||||
} else {
|
||||
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Scroll to bottom after initial message render
|
||||
useEffect(() => {
|
||||
if (scrollToBottomAfterRenderRef.current) {
|
||||
scrollToBottomAfterRenderRef.current = false
|
||||
requestAnimationFrame(() => {
|
||||
if (messageListRef.current) {
|
||||
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
// 日期跳转处理
|
||||
const handleJumpToDate = useCallback(async () => {
|
||||
if (!selectedDate || !currentSessionId || isJumpingToDate) return
|
||||
@@ -1963,8 +2051,14 @@ function ChatPage(_props: ChatPageProps) {
|
||||
// 系统消息居中显示
|
||||
const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
|
||||
|
||||
const messageDomKey = getMessageDomKey(msg)
|
||||
|
||||
return (
|
||||
<div key={msg.localId} className={`message-wrapper ${wrapperClass}`}>
|
||||
<div
|
||||
key={messageDomKey}
|
||||
className={`message-wrapper ${wrapperClass}`}
|
||||
data-message-key={messageDomKey}
|
||||
>
|
||||
{showDateDivider && (
|
||||
<div className="date-divider">
|
||||
<span>{formatDateDivider(msg.createTime)}</span>
|
||||
@@ -2993,6 +3087,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
useEffect(() => {
|
||||
if (!isImage || !imageContainerRef.current) return
|
||||
|
||||
const scrollRoot = imageContainerRef.current.closest('.message-list') as HTMLElement | null
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
@@ -3003,7 +3098,8 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
})
|
||||
},
|
||||
{
|
||||
rootMargin: '1200px 0px', // 提前加载,减少滚动到位后的等待
|
||||
root: scrollRoot,
|
||||
rootMargin: '2800px 0px', // 提前约数屏预热,向上滚动时先解密上方图片
|
||||
threshold: 0
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user