修复样片详情解析并优化列表上下架交互。
兼容 Android 媒体项 size 字段格式,增强灵活解码容错,并统一样片列表开关确认逻辑与安全区布局。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -329,6 +329,31 @@ struct SampleDetailMediaItem: Decodable, Sendable, Equatable, Hashable, Identifi
|
|||||||
case height
|
case height
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
|
||||||
|
originalName = try container.decodeIfPresent(String.self, forKey: .originalName)
|
||||||
|
paidMaterialId = try container.decodeIfPresent(Int.self, forKey: .paidMaterialId)
|
||||||
|
ossUrl = try container.decodeIfPresent(String.self, forKey: .ossUrl)
|
||||||
|
thumbnailUrl = try container.decodeIfPresent(String.self, forKey: .thumbnailUrl)
|
||||||
|
type = try container.decodeIfPresent(Int.self, forKey: .type) ?? 0
|
||||||
|
if let numericSize = try? container.decode(Int64.self, forKey: .size) {
|
||||||
|
size = numericSize
|
||||||
|
} else if let stringSize = try? container.decode(String.self, forKey: .size) {
|
||||||
|
size = Int64(stringSize)
|
||||||
|
} else {
|
||||||
|
size = nil
|
||||||
|
}
|
||||||
|
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt)
|
||||||
|
updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt)
|
||||||
|
deletedAt = try container.decodeIfPresent(String.self, forKey: .deletedAt)
|
||||||
|
likesCount = try container.decodeIfPresent(Int.self, forKey: .likesCount) ?? 0
|
||||||
|
downloadCount = try container.decodeIfPresent(Int.self, forKey: .downloadCount) ?? 0
|
||||||
|
showUrl = try container.decodeIfPresent(String.self, forKey: .showUrl)
|
||||||
|
width = try container.decodeIfPresent(Int.self, forKey: .width)
|
||||||
|
height = try container.decodeIfPresent(Int.self, forKey: .height)
|
||||||
|
}
|
||||||
|
|
||||||
/// 详情轮播优先展示地址。
|
/// 详情轮播优先展示地址。
|
||||||
var displayURL: String {
|
var displayURL: String {
|
||||||
(ossUrl?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
|
(ossUrl?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
|
||||||
@ -558,23 +583,23 @@ enum SampleDisplayFormatter {
|
|||||||
|
|
||||||
private extension KeyedDecodingContainer {
|
private extension KeyedDecodingContainer {
|
||||||
func decodeFlexibleDouble(forKey key: Key) throws -> Double? {
|
func decodeFlexibleDouble(forKey key: Key) throws -> Double? {
|
||||||
if let value = try decodeIfPresent(Double.self, forKey: key) {
|
if let value = try? decode(Double.self, forKey: key) {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
if let string = try decodeIfPresent(String.self, forKey: key) {
|
if let string = try? decode(String.self, forKey: key) {
|
||||||
return Double(string)
|
return Double(string)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeFlexibleString(forKey key: Key) throws -> String? {
|
func decodeFlexibleString(forKey key: Key) throws -> String? {
|
||||||
if let value = try decodeIfPresent(String.self, forKey: key) {
|
if let value = try? decode(String.self, forKey: key) {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
if let value = try decodeIfPresent(Double.self, forKey: key) {
|
if let value = try? decode(Double.self, forKey: key) {
|
||||||
return String(value)
|
return String(value)
|
||||||
}
|
}
|
||||||
if let value = try decodeIfPresent(Int.self, forKey: key) {
|
if let value = try? decode(Int.self, forKey: key) {
|
||||||
return String(value)
|
return String(value)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -503,6 +503,7 @@ final class UploadSampleViewModel {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard self.uploadedMediaList.indices.contains(index) else { return }
|
guard self.uploadedMediaList.indices.contains(index) else { return }
|
||||||
|
guard self.uploadedMediaList[index].isUploading else { return }
|
||||||
self.uploadedMediaList[index].uploadProgress = progress
|
self.uploadedMediaList[index].uploadProgress = progress
|
||||||
self.uploadedMediaList[index].isUploading = progress < 100
|
self.uploadedMediaList[index].isUploading = progress < 100
|
||||||
self.uploadDialogState = SampleUploadDialogState(
|
self.uploadDialogState = SampleUploadDialogState(
|
||||||
@ -552,6 +553,7 @@ final class UploadSampleViewModel {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard var current = self.coverImage else { return }
|
guard var current = self.coverImage else { return }
|
||||||
|
guard current.isUploading else { return }
|
||||||
current.uploadProgress = progress
|
current.uploadProgress = progress
|
||||||
current.isUploading = progress < 100
|
current.isUploading = progress < 100
|
||||||
self.coverImage = current
|
self.coverImage = current
|
||||||
|
|||||||
@ -68,7 +68,7 @@ final class SampleDetailViewController: SampleBaseViewController {
|
|||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
scrollView.snp.makeConstraints { make in
|
scrollView.snp.makeConstraints { make in
|
||||||
make.top.equalToSuperview()
|
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||||
make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
|
make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||||
}
|
}
|
||||||
contentStack.snp.makeConstraints { make in
|
contentStack.snp.makeConstraints { make in
|
||||||
|
|||||||
@ -97,15 +97,10 @@ final class SampleListViewController: SampleBaseViewController {
|
|||||||
withIdentifier: SampleListCell.reuseIdentifier,
|
withIdentifier: SampleListCell.reuseIdentifier,
|
||||||
for: indexPath
|
for: indexPath
|
||||||
) as! SampleListCell
|
) as! SampleListCell
|
||||||
cell.apply(item: item, isOperating: self?.viewModel.operatingSampleIDs.contains(item.id) == true)
|
cell.apply(item: item)
|
||||||
cell.onToggle = { [weak self, weak cell] checked in
|
cell.onToggle = { [weak self] checked in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task {
|
Task { await self.viewModel.toggleListing(sampleId: item.id, checked: checked, api: self.api) }
|
||||||
let resolved = await self.viewModel.toggleListing(sampleId: item.id, checked: checked, api: self.api)
|
|
||||||
await MainActor.run {
|
|
||||||
cell?.resolveToggle(sampleId: item.id, isOn: resolved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
@ -142,7 +137,7 @@ final class SampleListViewController: SampleBaseViewController {
|
|||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
searchContainer.snp.makeConstraints { make in
|
searchContainer.snp.makeConstraints { make in
|
||||||
make.top.equalToSuperview()
|
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||||
make.leading.trailing.equalToSuperview()
|
make.leading.trailing.equalToSuperview()
|
||||||
make.height.equalTo(68)
|
make.height.equalTo(68)
|
||||||
}
|
}
|
||||||
@ -349,13 +344,12 @@ private final class SampleListCell: UITableViewCell {
|
|||||||
private let scenicSpotLabel = UILabel()
|
private let scenicSpotLabel = UILabel()
|
||||||
private let statusBadge = SampleStatusBadgeView()
|
private let statusBadge = SampleStatusBadgeView()
|
||||||
private let listingSwitch = UISwitch()
|
private let listingSwitch = UISwitch()
|
||||||
private let listingActivityIndicator = UIActivityIndicatorView(style: .medium)
|
|
||||||
private let statsStack = UIStackView()
|
private let statsStack = UIStackView()
|
||||||
private let shareStatView = SampleStatView(iconName: "sample_ic_share_count")
|
private let shareStatView = SampleStatView(iconName: "sample_ic_share_count")
|
||||||
private let likeStatView = SampleStatView(iconName: "sample_ic_like_count")
|
private let likeStatView = SampleStatView(iconName: "sample_ic_like_count")
|
||||||
private let collectStatView = SampleStatView(iconName: "sample_ic_collect_count")
|
private let collectStatView = SampleStatView(iconName: "sample_ic_collect_count")
|
||||||
private var isApplying = false
|
private var isApplying = false
|
||||||
private var representedSampleID: Int?
|
private var confirmedListingState = false
|
||||||
|
|
||||||
var onToggle: ((Bool) -> Void)?
|
var onToggle: ((Bool) -> Void)?
|
||||||
|
|
||||||
@ -371,21 +365,17 @@ private final class SampleListCell: UITableViewCell {
|
|||||||
|
|
||||||
override func prepareForReuse() {
|
override func prepareForReuse() {
|
||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
representedSampleID = nil
|
|
||||||
onToggle = nil
|
onToggle = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func apply(item: SampleMaterialItem, isOperating: Bool) {
|
func apply(item: SampleMaterialItem) {
|
||||||
isApplying = true
|
isApplying = true
|
||||||
representedSampleID = item.id
|
confirmedListingState = item.listingStatus == 1
|
||||||
titleLabel.text = item.name
|
titleLabel.text = item.name
|
||||||
projectLabel.text = "项目: \(item.projectName)"
|
projectLabel.text = "项目: \(item.projectName)"
|
||||||
scenicSpotLabel.text = "打卡地: \(item.scenicSpotName)"
|
scenicSpotLabel.text = "打卡地: \(item.scenicSpotName)"
|
||||||
statusBadge.apply(status: item.status)
|
statusBadge.apply(status: item.status)
|
||||||
listingSwitch.setOn(item.listingStatus == 1, animated: false)
|
listingSwitch.setOn(confirmedListingState, animated: false)
|
||||||
listingSwitch.isEnabled = item.status == 1 && !isOperating
|
|
||||||
listingSwitch.isHidden = isOperating
|
|
||||||
isOperating ? listingActivityIndicator.startAnimating() : listingActivityIndicator.stopAnimating()
|
|
||||||
shareStatView.setCount(item.shareCount)
|
shareStatView.setCount(item.shareCount)
|
||||||
likeStatView.setCount(item.likesCount)
|
likeStatView.setCount(item.likesCount)
|
||||||
collectStatView.setCount(item.collectCount)
|
collectStatView.setCount(item.collectCount)
|
||||||
@ -400,17 +390,6 @@ private final class SampleListCell: UITableViewCell {
|
|||||||
isApplying = false
|
isApplying = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 请求结束后恢复服务端确认的开关状态,避免审核拒绝或接口失败时停留在错误位置。
|
|
||||||
func resolveToggle(sampleId: Int, isOn: Bool) {
|
|
||||||
guard representedSampleID == sampleId else { return }
|
|
||||||
isApplying = true
|
|
||||||
listingSwitch.setOn(isOn, animated: true)
|
|
||||||
listingSwitch.isEnabled = true
|
|
||||||
listingSwitch.isHidden = false
|
|
||||||
listingActivityIndicator.stopAnimating()
|
|
||||||
isApplying = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
selectionStyle = .none
|
selectionStyle = .none
|
||||||
backgroundColor = SampleManagementUITokens.pageBackground
|
backgroundColor = SampleManagementUITokens.pageBackground
|
||||||
@ -443,8 +422,6 @@ private final class SampleListCell: UITableViewCell {
|
|||||||
listingSwitch.onTintColor = AppColor.primary
|
listingSwitch.onTintColor = AppColor.primary
|
||||||
listingSwitch.accessibilityLabel = "样片上下架"
|
listingSwitch.accessibilityLabel = "样片上下架"
|
||||||
listingSwitch.addTarget(self, action: #selector(switchChanged), for: .valueChanged)
|
listingSwitch.addTarget(self, action: #selector(switchChanged), for: .valueChanged)
|
||||||
listingActivityIndicator.color = AppColor.primary
|
|
||||||
listingActivityIndicator.hidesWhenStopped = true
|
|
||||||
|
|
||||||
statsStack.axis = .horizontal
|
statsStack.axis = .horizontal
|
||||||
statsStack.spacing = 16
|
statsStack.spacing = 16
|
||||||
@ -458,7 +435,6 @@ private final class SampleListCell: UITableViewCell {
|
|||||||
cardView.addSubview(scenicSpotLabel)
|
cardView.addSubview(scenicSpotLabel)
|
||||||
cardView.addSubview(statusBadge)
|
cardView.addSubview(statusBadge)
|
||||||
cardView.addSubview(listingSwitch)
|
cardView.addSubview(listingSwitch)
|
||||||
cardView.addSubview(listingActivityIndicator)
|
|
||||||
cardView.addSubview(statsStack)
|
cardView.addSubview(statsStack)
|
||||||
[shareStatView, likeStatView, collectStatView].forEach(statsStack.addArrangedSubview)
|
[shareStatView, likeStatView, collectStatView].forEach(statsStack.addArrangedSubview)
|
||||||
|
|
||||||
@ -498,9 +474,6 @@ private final class SampleListCell: UITableViewCell {
|
|||||||
make.trailing.equalToSuperview().inset(12)
|
make.trailing.equalToSuperview().inset(12)
|
||||||
make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8)
|
make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8)
|
||||||
}
|
}
|
||||||
listingActivityIndicator.snp.makeConstraints { make in
|
|
||||||
make.center.equalTo(listingSwitch)
|
|
||||||
}
|
|
||||||
statsStack.snp.makeConstraints { make in
|
statsStack.snp.makeConstraints { make in
|
||||||
make.top.equalTo(statusBadge.snp.bottom).offset(10)
|
make.top.equalTo(statusBadge.snp.bottom).offset(10)
|
||||||
make.leading.equalTo(titleLabel)
|
make.leading.equalTo(titleLabel)
|
||||||
@ -511,10 +484,9 @@ private final class SampleListCell: UITableViewCell {
|
|||||||
|
|
||||||
@objc private func switchChanged() {
|
@objc private func switchChanged() {
|
||||||
guard !isApplying else { return }
|
guard !isApplying else { return }
|
||||||
listingSwitch.isEnabled = false
|
let requestedState = listingSwitch.isOn
|
||||||
listingSwitch.isHidden = true
|
listingSwitch.setOn(confirmedListingState, animated: true)
|
||||||
listingActivityIndicator.startAnimating()
|
onToggle?(requestedState)
|
||||||
onToggle?(listingSwitch.isOn)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -138,7 +138,7 @@ final class UploadSampleViewController: SampleBaseViewController {
|
|||||||
make.trailing.equalToSuperview().inset(15)
|
make.trailing.equalToSuperview().inset(15)
|
||||||
}
|
}
|
||||||
scrollView.snp.makeConstraints { make in
|
scrollView.snp.makeConstraints { make in
|
||||||
make.top.equalToSuperview()
|
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||||
make.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
make.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||||
make.bottom.equalTo(bottomBar.snp.top)
|
make.bottom.equalTo(bottomBar.snp.top)
|
||||||
}
|
}
|
||||||
@ -698,6 +698,10 @@ private final class SampleUploadThumbView: UIControl {
|
|||||||
deleteButton.layer.cornerRadius = 10
|
deleteButton.layer.cornerRadius = 10
|
||||||
playIcon.tintColor = .white
|
playIcon.tintColor = .white
|
||||||
playIcon.isHidden = true
|
playIcon.isHidden = true
|
||||||
|
[imageView, addCircle, addIcon, textLabel, playIcon].forEach {
|
||||||
|
$0.isUserInteractionEnabled = false
|
||||||
|
}
|
||||||
|
accessibilityLabel = mediaType == .image ? "上传图片" : "上传视频"
|
||||||
|
|
||||||
addSubview(imageView)
|
addSubview(imageView)
|
||||||
addSubview(addCircle)
|
addSubview(addCircle)
|
||||||
@ -828,6 +832,10 @@ private final class SampleCoverUploadView: UIControl {
|
|||||||
hintLabel.font = .systemFont(ofSize: 12)
|
hintLabel.font = .systemFont(ofSize: 12)
|
||||||
hintLabel.textColor = UIColor(hex: 0xB6BECA)
|
hintLabel.textColor = UIColor(hex: 0xB6BECA)
|
||||||
hintLabel.textAlignment = .center
|
hintLabel.textAlignment = .center
|
||||||
|
[imageView, addCircle, addIcon, titleLabel, hintLabel].forEach {
|
||||||
|
$0.isUserInteractionEnabled = false
|
||||||
|
}
|
||||||
|
accessibilityLabel = "上传封面图片"
|
||||||
deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
|
deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
|
||||||
deleteButton.tintColor = .white
|
deleteButton.tintColor = .white
|
||||||
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
||||||
|
|||||||
@ -70,6 +70,26 @@ final class SampleManagementAPITests: XCTestCase {
|
|||||||
XCTAssertEqual(response.mediaList?.first?.resolvedType(fallback: response.type ?? 1), 2)
|
XCTAssertEqual(response.mediaList?.first?.resolvedType(fallback: response.type ?? 1), 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDetailDecodesUploadedAlbumMediaWithoutItemTypeOrCounters() async throws {
|
||||||
|
let detail = envelopeJSON(
|
||||||
|
#"{"id":113,"name":"样片相册iOS测试","type":1,"status":0,"listing_status":0,"project_price":0.01,"project_price_deposit":"0.00","media_list":[{"id":392,"original_name":"IMG_2741.jpeg","oss_url":"https://cdn/IMG_2741.jpeg","show_url":"https://cdn/IMG_274_cover.jpeg","thumbnail_url":"https://cdn/thumb.jpeg","size":285534,"width":828,"height":1792,"created_at":"2026-07-13 15:06:26","updated_at":"2026-07-13 15:06:26"}]}"#
|
||||||
|
)
|
||||||
|
let session = MockURLSession(responses: [detail])
|
||||||
|
let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
let response = try await api.detail(id: 113)
|
||||||
|
let media = try XCTUnwrap(response.mediaList?.first)
|
||||||
|
|
||||||
|
XCTAssertEqual(response.projectPrice, 0.01)
|
||||||
|
XCTAssertEqual(media.type, 0)
|
||||||
|
XCTAssertEqual(media.likesCount, 0)
|
||||||
|
XCTAssertEqual(media.downloadCount, 0)
|
||||||
|
XCTAssertEqual(media.size, 285_534)
|
||||||
|
XCTAssertEqual(media.width, 828)
|
||||||
|
XCTAssertEqual(media.height, 1_792)
|
||||||
|
XCTAssertEqual(media.resolvedType(fallback: response.type ?? 1), 1)
|
||||||
|
}
|
||||||
|
|
||||||
func testUploadEncodesAndroidBodyKeys() async throws {
|
func testUploadEncodesAndroidBodyKeys() async throws {
|
||||||
let session = MockURLSession(responses: [envelopeJSON("{}")])
|
let session = MockURLSession(responses: [envelopeJSON("{}")])
|
||||||
let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session))
|
let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|||||||
@ -187,6 +187,19 @@ final class SampleManagementViewModelTests: XCTestCase {
|
|||||||
XCTAssertNil(viewModel.uploadDialogState)
|
XCTAssertNil(viewModel.uploadDialogState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testLateProgressCallbackCannotReopenCompletedUploadDialog() async {
|
||||||
|
let viewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 })
|
||||||
|
let uploader = MockSampleUploader(urls: ["https://cdn/a.jpg"])
|
||||||
|
uploader.emitLateProgress = true
|
||||||
|
|
||||||
|
await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: uploader)
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.uploadedMediaList.first?.ossUrl, "https://cdn/a.jpg")
|
||||||
|
XCTAssertFalse(viewModel.uploadedMediaList.first?.isUploading ?? true)
|
||||||
|
XCTAssertNil(viewModel.uploadDialogState)
|
||||||
|
}
|
||||||
|
|
||||||
func testUploadValidationMessages() async {
|
func testUploadValidationMessages() async {
|
||||||
let api = MockSampleAPI()
|
let api = MockSampleAPI()
|
||||||
let viewModel = UploadSampleViewModel(
|
let viewModel = UploadSampleViewModel(
|
||||||
@ -324,6 +337,7 @@ private final class MockSampleAPI: SampleManagementServing {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private final class MockSampleUploader: SampleOSSUploading {
|
private final class MockSampleUploader: SampleOSSUploading {
|
||||||
private var results: [Result<String, Error>]
|
private var results: [Result<String, Error>]
|
||||||
|
var emitLateProgress = false
|
||||||
|
|
||||||
init(urls: [String]) {
|
init(urls: [String]) {
|
||||||
results = urls.map(Result.success)
|
results = urls.map(Result.success)
|
||||||
@ -340,7 +354,15 @@ private final class MockSampleUploader: SampleOSSUploading {
|
|||||||
scenicId: Int,
|
scenicId: Int,
|
||||||
onProgress: @escaping (Int) -> Void
|
onProgress: @escaping (Int) -> Void
|
||||||
) async throws -> String {
|
) async throws -> String {
|
||||||
|
if emitLateProgress {
|
||||||
|
onProgress(20)
|
||||||
|
Task { @MainActor in
|
||||||
|
await Task.yield()
|
||||||
onProgress(100)
|
onProgress(100)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onProgress(100)
|
||||||
|
}
|
||||||
guard !results.isEmpty else { return "https://cdn/default.jpg" }
|
guard !results.isEmpty else { return "https://cdn/default.jpg" }
|
||||||
return try results.removeFirst().get()
|
return try results.removeFirst().get()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user