增强有线传图手动上传并发能力,并稳定列表行布局。
指定/勾选/批量上传支持恢复仅本地持久化的待传照片,手动上传与 pipeline 均按 3 路并发;pipeline 任务签名去重避免状态回退,照片行固定高度并预留进度条占位。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -80,21 +80,42 @@ final class CameraTransferPipeline {
|
||||
/// 手动上传指定 asset 列表(拍完再传模式)。
|
||||
func uploadAssets(withIDs assetIDs: [String]) async {
|
||||
guard uploadSink != nil else { return }
|
||||
for assetID in assetIDs {
|
||||
if activeUploadAssetIDs.contains(assetID) { continue }
|
||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue }
|
||||
if tasks[index].status == .uploaded { continue }
|
||||
var seenAssetIDs: Set<String> = []
|
||||
let uniqueAssetIDs = assetIDs.filter { seenAssetIDs.insert($0).inserted }
|
||||
var currentIndex = 0
|
||||
while currentIndex < uniqueAssetIDs.count {
|
||||
let upperBound = min(currentIndex + maxConcurrentUploads, uniqueAssetIDs.count)
|
||||
let batch = uniqueAssetIDs[currentIndex ..< upperBound]
|
||||
let uploadTasks = batch.map { assetID in
|
||||
Task { @MainActor in
|
||||
await self.uploadAssetIfNeeded(assetID: assetID)
|
||||
}
|
||||
}
|
||||
for task in uploadTasks {
|
||||
await task.value
|
||||
}
|
||||
currentIndex = upperBound
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadAssetIfNeeded(assetID: String) async {
|
||||
if activeUploadAssetIDs.contains(assetID) { return }
|
||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
if tasks[index].status == .uploaded { return }
|
||||
|
||||
activeUploadAssetIDs.insert(assetID)
|
||||
defer { activeUploadAssetIDs.remove(assetID) }
|
||||
|
||||
if tasks[index].localURL == nil {
|
||||
await downloadAsset(assetID: assetID)
|
||||
}
|
||||
guard let refreshedIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue }
|
||||
guard let refreshedIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
if tasks[refreshedIndex].status == .downloaded || tasks[refreshedIndex].status == .failed {
|
||||
tasks[refreshedIndex].status = .downloaded
|
||||
tasks[refreshedIndex].errorMessage = nil
|
||||
await uploadTask(assetID: assetID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 重传单张照片;若管道中无任务则根据本地相对路径恢复。
|
||||
func retryUpload(assetID: String, filename: String, localPath: String) async {
|
||||
|
||||
@ -59,6 +59,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
|
||||
- 有选中项 → 上传选中照片
|
||||
- 无选中项 → 取消选择
|
||||
3. **指定上传**:底部 Sheet 选择「上传所有未上传的照片」或「上传所有今日拍摄的照片」
|
||||
- 指定上传会同时处理当前管道任务和本地持久化记录;仅存在本地记录的照片会先按本地路径恢复到传输管道,再上传 OSS 并登记素材
|
||||
4. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section
|
||||
5. **格式 Chip**:当前固定展示 JPG,与「不修图」一样不可点击;RAW 等格式后续扩展
|
||||
6. **传输模式**:「边拍边传 / 拍后传输」按账号缓存,下次进入有线传图页自动恢复
|
||||
@ -76,7 +77,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
|
||||
## 有线传图性能策略
|
||||
|
||||
- 本地下载原图只用于上传与大图预览;列表缩略图在后台下采样生成到 `CameraDownloads/Thumbnails/`,由 Kingfisher 统一加载与缓存。
|
||||
- 连拍上传进度采用节流通知,下载/上传关键状态立即刷新,普通进度合并后再更新 UI;边拍边传自动队列最多 3 张照片并发上传,避免滚动时高频触发整页重绘或网络资源被打满。
|
||||
- 连拍上传进度采用节流通知和任务签名去重,下载/上传关键状态立即刷新,普通进度合并后再更新 UI;边拍边传、指定上传和勾选上传最多 3 张照片并发上传,避免滚动时高频触发整页重绘或网络资源被打满。
|
||||
- `WiredCameraTransferViewModel` 缓存当前 Tab、时间侧栏和 30 分钟分组;新增/删除/切换 Tab 才重建分组,单张进度变化只替换对应行数据。
|
||||
- 本地记录以相册和用户维度持久化,状态终态、路径变化立即保存,普通进度按步长或时间间隔降频保存。
|
||||
|
||||
|
||||
@ -417,6 +417,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
static let specifyUploadAllPending = "上传所有未上传的照片"
|
||||
static let specifyUploadTodayCaptured = "上传所有今日拍摄的照片"
|
||||
static let helpDocURL = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink")!
|
||||
private static let manualUploadConcurrency = 3
|
||||
|
||||
@Published private(set) var photos: [WiredTransferPhotoItem] = []
|
||||
@Published private(set) var visiblePhotos: [WiredTransferPhotoItem] = []
|
||||
@ -444,6 +445,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
private var userIDProvider: (() -> String)?
|
||||
private var sessionNewPhotoIDs: Set<String> = []
|
||||
private var persistedRecordsByID: [String: WiredTransferPhotoRecord] = [:]
|
||||
private var lastAppliedPipelineSignatures: [String: PipelineTaskSignature] = [:]
|
||||
private var lastProgressPersistDates: [String: Date] = [:]
|
||||
private var wasConnected = false
|
||||
private var disconnectAlertTask: Task<Void, Never>?
|
||||
@ -639,7 +641,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
errorMessage = "暂无未上传的照片"
|
||||
return
|
||||
}
|
||||
await pipeline.uploadAssets(withIDs: pendingIDs)
|
||||
await uploadPendingPhotoIDs(pendingIDs)
|
||||
}
|
||||
|
||||
/// 上传今日拍摄且未上传的照片。
|
||||
@ -650,13 +652,13 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
errorMessage = "暂无今日拍摄且未上传的照片"
|
||||
return
|
||||
}
|
||||
await pipeline.uploadAssets(withIDs: pendingIDs)
|
||||
await uploadPendingPhotoIDs(pendingIDs)
|
||||
}
|
||||
|
||||
/// 上传选中照片。
|
||||
func uploadSelected(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
|
||||
ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID)
|
||||
await pipeline.uploadAssets(withIDs: Array(selectedPhotoIDs))
|
||||
await uploadPendingPhotoIDs(Array(selectedPhotoIDs))
|
||||
exitSelectUploadMode()
|
||||
}
|
||||
|
||||
@ -699,10 +701,57 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
await refreshPhotoFromPipelineTask(id: id)
|
||||
}
|
||||
|
||||
private func uploadPendingPhotoIDs(_ ids: [String]) async {
|
||||
let pendingIDs = ids.filter { id in
|
||||
photos.first(where: { $0.id == id })?.isNotUploaded == true
|
||||
}
|
||||
var currentIndex = 0
|
||||
while currentIndex < pendingIDs.count {
|
||||
let upperBound = min(currentIndex + Self.manualUploadConcurrency, pendingIDs.count)
|
||||
let batch = pendingIDs[currentIndex ..< upperBound]
|
||||
let uploadTasks = batch.map { id in
|
||||
Task { @MainActor in
|
||||
await self.uploadSinglePendingPhoto(id: id)
|
||||
}
|
||||
}
|
||||
for task in uploadTasks {
|
||||
await task.value
|
||||
}
|
||||
currentIndex = upperBound
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadSinglePendingPhoto(id: String) async {
|
||||
updatePhotoStatus(id: id, status: .uploading, progress: 0, errorMessage: nil)
|
||||
|
||||
if pipeline.task(forAssetID: id) != nil {
|
||||
await pipeline.uploadAssets(withIDs: [id])
|
||||
await refreshPhotoFromPipelineTask(id: id)
|
||||
return
|
||||
}
|
||||
|
||||
guard let record = persistedRecord(for: id),
|
||||
let localURL = CameraDownloadStorage.resolveLocalURL(from: record.localPath),
|
||||
FileManager.default.fileExists(atPath: localURL.path) else {
|
||||
let message = "本地文件不存在"
|
||||
updatePhotoStatus(id: id, status: .failed, progress: 0, errorMessage: message)
|
||||
persistPhotoStatus(id: id, status: .failed, progress: 0, errorMessage: message)
|
||||
return
|
||||
}
|
||||
|
||||
await pipeline.retryUpload(
|
||||
assetID: id,
|
||||
filename: record.fileName,
|
||||
localPath: record.localPath
|
||||
)
|
||||
await refreshPhotoFromPipelineTask(id: id)
|
||||
}
|
||||
|
||||
/// 删除本地照片记录。
|
||||
func deletePhoto(id: String) {
|
||||
photoStore?.remove(albumID: context.albumId, photoID: id)
|
||||
persistedRecordsByID.removeValue(forKey: id)
|
||||
lastAppliedPipelineSignatures.removeValue(forKey: id)
|
||||
lastProgressPersistDates.removeValue(forKey: id)
|
||||
setPhotos(photos.filter { $0.id != id }, rebuildGroups: true)
|
||||
}
|
||||
@ -905,16 +954,24 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
for task in tasks {
|
||||
guard !enforceAlbumBinding || belongsToCurrentAlbum(task.assetID) else { continue }
|
||||
|
||||
let signature = PipelineTaskSignature(task: task)
|
||||
guard lastAppliedPipelineSignatures[task.assetID] != signature else { continue }
|
||||
|
||||
let status = mapPipelineStatus(task.status)
|
||||
let existing = existingByID[task.assetID] ?? existingByFileName[task.filename]
|
||||
if shouldIgnoreStalePipelineStatus(existing: existing?.status, incoming: status) {
|
||||
lastAppliedPipelineSignatures[task.assetID] = signature
|
||||
continue
|
||||
}
|
||||
let storedLocalPath = task.localPath ?? ""
|
||||
let localURL = task.localURL
|
||||
let fileSizeBytes = await CameraDownloadStorage.fileSizeBytes(for: localURL)
|
||||
|
||||
let existingRecord = persistedRecordsByID[task.assetID]
|
||||
let fileSizeBytes: Int64
|
||||
if let existingRecord, existingRecord.fileSizeBytes > 0 {
|
||||
fileSizeBytes = existingRecord.fileSizeBytes
|
||||
} else {
|
||||
fileSizeBytes = await CameraDownloadStorage.fileSizeBytes(for: localURL)
|
||||
}
|
||||
let capturedAt = await resolveCapturedAt(
|
||||
existing: existing,
|
||||
taskCapturedAt: task.capturedAt,
|
||||
@ -973,6 +1030,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
)
|
||||
persistRecord(record)
|
||||
}
|
||||
lastAppliedPipelineSignatures[task.assetID] = signature
|
||||
}
|
||||
}
|
||||
|
||||
@ -1091,3 +1149,19 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
return bound == context.albumId
|
||||
}
|
||||
}
|
||||
|
||||
private struct PipelineTaskSignature: Equatable {
|
||||
let status: CameraTransferStatus
|
||||
let progress: Int
|
||||
let localPath: String?
|
||||
let remoteURL: String?
|
||||
let errorMessage: String?
|
||||
|
||||
init(task: CameraTransferTask) {
|
||||
status = task.status
|
||||
progress = task.progress
|
||||
localPath = task.localPath
|
||||
remoteURL = task.remoteURL
|
||||
errorMessage = task.errorMessage
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,11 +25,12 @@ enum WiredTransferDesign {
|
||||
static let filterChipHeight: CGFloat = 34
|
||||
static let filterChipCornerRadius: CGFloat = 8
|
||||
static let tabRowHeight: CGFloat = 68
|
||||
static let thumbnailSize: CGFloat = 48
|
||||
static let photoRowHeight: CGFloat = 74
|
||||
static let thumbnailSize: CGFloat = 56
|
||||
static let thumbnailCornerRadius: CGFloat = 6
|
||||
static let bottomButtonHeight: CGFloat = 44
|
||||
static let bottomButtonCornerRadius: CGFloat = 10
|
||||
static let progressBarHeight: CGFloat = 2
|
||||
static let progressBarHeight: CGFloat = 3
|
||||
static let cardHorizontalPadding: CGFloat = 16
|
||||
static let cardOverlap: CGFloat = 10
|
||||
}
|
||||
|
||||
@ -20,9 +20,9 @@ struct WiredTransferPhotoRow: View {
|
||||
private var canSelect: Bool { photo.canSelectForSpecifyUpload }
|
||||
private var canRetry: Bool { photo.status == .failed || photo.status == .pending }
|
||||
private var rowOpacity: Double { selectUploadMode && !canSelect ? 0.45 : 1 }
|
||||
private var showsProgress: Bool { photo.status == .transferring || photo.status == .uploading }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
if selectUploadMode {
|
||||
@ -34,7 +34,7 @@ struct WiredTransferPhotoRow: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.thumbnailCornerRadius))
|
||||
.opacity(rowOpacity)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 4) {
|
||||
Text(photo.fileName)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
@ -48,6 +48,7 @@ struct WiredTransferPhotoRow: View {
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(WiredTransferDesign.text999.opacity(rowOpacity))
|
||||
.lineLimit(1)
|
||||
progressSlot
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@ -74,17 +75,8 @@ struct WiredTransferPhotoRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: WiredTransferDesign.photoRowHeight)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
if photo.status == .transferring || photo.status == .uploading {
|
||||
ProgressView(value: Double(photo.progress), total: 100)
|
||||
.tint(AppDesign.primary)
|
||||
.frame(height: WiredTransferDesign.progressBarHeight)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var metaText: String {
|
||||
@ -92,6 +84,13 @@ struct WiredTransferPhotoRow: View {
|
||||
return "\(photo.fileSizeText) \(photo.resolutionText)"
|
||||
}
|
||||
|
||||
private var progressSlot: some View {
|
||||
ProgressView(value: showsProgress ? Double(photo.progress) : 0, total: 100)
|
||||
.tint(AppDesign.primary)
|
||||
.opacity(showsProgress ? 1 : 0)
|
||||
.frame(height: WiredTransferDesign.progressBarHeight)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var thumbnailView: some View {
|
||||
if let url = URL(string: photo.thumbnailURL), !photo.thumbnailURL.isEmpty {
|
||||
|
||||
@ -60,6 +60,24 @@ final class CameraTransferPipelineTests: XCTestCase {
|
||||
XCTAssertEqual(pipeline.task(forAssetID: "asset_retry_001")?.status, .uploaded)
|
||||
}
|
||||
|
||||
/// 测试手动上传多张照片时按并发上限上传,而不是串行一张张上传。
|
||||
func testManualUploadAssetsUsesConcurrentLimit() async throws {
|
||||
let camera = MockCameraService()
|
||||
let sink = MockSlowUploadSink()
|
||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
||||
pipeline.configure(uploadSink: sink, uploadEnabled: true)
|
||||
camera.listedAssets = (1 ... 6).map { index in
|
||||
CameraAsset(id: "manual_\(index)", filename: "MANUAL_\(index).JPG", fileSize: 1024)
|
||||
}
|
||||
|
||||
await pipeline.syncExistingPhotos()
|
||||
await pipeline.uploadAssets(withIDs: camera.listedAssets.map(\.id))
|
||||
|
||||
XCTAssertEqual(sink.uploadCount, 6)
|
||||
XCTAssertGreaterThan(sink.maxActiveUploads, 1)
|
||||
XCTAssertLessThanOrEqual(sink.maxActiveUploads, 3)
|
||||
}
|
||||
|
||||
/// 测试拍后传输下载的旧照片,切到边拍边传后不会被新照片触发自动上传。
|
||||
func testSwitchingToLiveCaptureDoesNotUploadPostShootDownloads() async throws {
|
||||
let camera = MockCameraService()
|
||||
|
||||
@ -296,6 +296,65 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(viewModel.photos.first?.status, .uploaded)
|
||||
}
|
||||
|
||||
/// 测试指定上传“所有未上传”会恢复本地持久化照片并全部上传。
|
||||
func testBatchUploadAllUploadsPersistedPendingFiles() async throws {
|
||||
let albumID = 7711
|
||||
let userID = "batch-persist-user"
|
||||
let account = AccountContext()
|
||||
account.applyLogin(profile: AccountProfile(userId: userID, displayName: "测试"))
|
||||
|
||||
let fileURLs = try (1 ... 5).map { index in
|
||||
let url = CameraDownloadStorage.uniqueLocalURL(for: "batch_persist_\(index).JPG")
|
||||
try Data(repeating: UInt8(0xA0 + index), count: 32).write(to: url)
|
||||
return url
|
||||
}
|
||||
defer {
|
||||
fileURLs.forEach { try? FileManager.default.removeItem(at: $0) }
|
||||
}
|
||||
|
||||
let records = fileURLs.enumerated().map { offset, url in
|
||||
let index = offset + 1
|
||||
return makePhotoRecord(
|
||||
id: "batch_persist_\(index)",
|
||||
fileName: "batch_persist_\(index).JPG",
|
||||
localPath: CameraDownloadStorage.relativePath(for: url),
|
||||
albumID: albumID,
|
||||
userID: userID
|
||||
)
|
||||
}
|
||||
let store = WiredTransferPhotoStore(
|
||||
accountPrefixProvider: { account.accountCachePrefix ?? "guest_" },
|
||||
userIDProvider: { userID }
|
||||
)
|
||||
store.save(albumID: albumID, records: records)
|
||||
|
||||
let viewModel = WiredCameraTransferViewModel(
|
||||
context: WiredTransferContext(albumId: albumID, albumName: "批量上传", phone: "", orderNumber: ""),
|
||||
cameraService: MockWiredCameraService()
|
||||
)
|
||||
let oss = WiredTransferMockOSSUploadService()
|
||||
oss.uploadDelayNanoseconds = 150_000_000
|
||||
await viewModel.start(
|
||||
api: MockTravelAlbumAPIForWiredTransfer(),
|
||||
ossService: oss,
|
||||
scenicID: 1,
|
||||
accountContext: account
|
||||
)
|
||||
|
||||
XCTAssertEqual(viewModel.photos.filter(\.isNotUploaded).count, 5)
|
||||
|
||||
await viewModel.batchUploadAll(
|
||||
api: MockTravelAlbumAPIForWiredTransfer(),
|
||||
ossService: oss,
|
||||
scenicID: 1
|
||||
)
|
||||
|
||||
XCTAssertEqual(Set(oss.uploadedFileNames), Set((1 ... 5).map { "batch_persist_\($0).JPG" }))
|
||||
XCTAssertGreaterThan(oss.maxActiveUploads, 1)
|
||||
XCTAssertLessThanOrEqual(oss.maxActiveUploads, 3)
|
||||
XCTAssertTrue(viewModel.photos.allSatisfy { $0.status == .uploaded })
|
||||
}
|
||||
|
||||
/// 测试切换传输模式后会按账号缓存选项。
|
||||
func testSelectTransferModePersistsOption() async {
|
||||
let account = makeTransferModeTestAccount()
|
||||
@ -444,6 +503,32 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
private func makePhotoRecord(
|
||||
id: String,
|
||||
fileName: String,
|
||||
localPath: String,
|
||||
albumID: Int,
|
||||
userID: String,
|
||||
status: WiredTransferUploadStatus = .pending
|
||||
) -> WiredTransferPhotoRecord {
|
||||
WiredTransferPhotoRecord(
|
||||
id: id,
|
||||
sourceId: id,
|
||||
fileName: fileName,
|
||||
localPath: localPath,
|
||||
thumbnailPath: "",
|
||||
capturedAt: "2026-05-20 12:05:18",
|
||||
fileSizeBytes: 32,
|
||||
status: status.rawValue,
|
||||
progress: 0,
|
||||
errorMessage: nil,
|
||||
albumId: albumID,
|
||||
userId: userID,
|
||||
remoteURL: "",
|
||||
updatedAt: Date().timeIntervalSince1970
|
||||
)
|
||||
}
|
||||
|
||||
private func makeJPEGData() -> Data {
|
||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 8, height: 8))
|
||||
return renderer.jpegData(withCompressionQuality: 0.9) { context in
|
||||
@ -516,10 +601,22 @@ private final class MockTravelAlbumAPIForWiredTransfer: TravelAlbumServing {
|
||||
|
||||
@MainActor
|
||||
private final class WiredTransferMockOSSUploadService: OSSUploadServing {
|
||||
private(set) var uploadedFileNames: [String] = []
|
||||
var uploadDelayNanoseconds: UInt64 = 0
|
||||
private(set) var activeUploads = 0
|
||||
private(set) var maxActiveUploads = 0
|
||||
|
||||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
activeUploads += 1
|
||||
maxActiveUploads = max(maxActiveUploads, activeUploads)
|
||||
if uploadDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: uploadDelayNanoseconds)
|
||||
}
|
||||
activeUploads -= 1
|
||||
uploadedFileNames.append(fileName)
|
||||
onProgress(100)
|
||||
return "https://cdn/mock/\(fileName)"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user