fix #5 camera video preview with settling and diagnostics
- Wait until the picker movie's file size stops changing before staging it, so a movie whose final bytes are still being flushed is never copied truncated (which produced drafts without a thumbnail and unplayable previews). - Log the whole camera video path (pickup size, staging result, analyze outcome, thumbnail decode) under the video-preview os_log category for fast on-device diagnosis. - Cover the video analysis pipeline with a simulator test that generates an HEVC QuickTime movie and asserts duration plus JPEG thumbnail, and guard the diagnostics in Scripts/verify.sh.
This commit is contained in:
1 parent
75fbcc5346
commit
496ee6f0f2
7 files changed
+137
-5
No files matched your search
@@ -58,6 +58,7 @@
|
|||||||
2F44B4FDA9AF67EB24776A16 /* WatchViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35158C37C26CA9ACB6C9FFB /* WatchViews.swift */; };
|
2F44B4FDA9AF67EB24776A16 /* WatchViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35158C37C26CA9ACB6C9FFB /* WatchViews.swift */; };
|
||||||
30BE19F459FDDAD640F66C68 /* MessageReplyFallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */; };
|
30BE19F459FDDAD640F66C68 /* MessageReplyFallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */; };
|
||||||
3121293D13481C39489CBC8F /* AttachmentDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04D749B7CA93CB7C59127D6 /* AttachmentDraft.swift */; };
|
3121293D13481C39489CBC8F /* AttachmentDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04D749B7CA93CB7C59127D6 /* AttachmentDraft.swift */; };
|
||||||
|
312B8D266C5BEC4F7B2F3276 /* MediaPreviewProcessorVideoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E29E7B704EE71C5AC70008E /* MediaPreviewProcessorVideoTests.swift */; };
|
||||||
3245372B071227092B8594C0 /* AccountPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */; };
|
3245372B071227092B8594C0 /* AccountPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */; };
|
||||||
32528D1AA72FB1B1A72389E9 /* MartinOMEMO in Frameworks */ = {isa = PBXBuildFile; productRef = 7A670E49149C9C7E13F0A6A2 /* MartinOMEMO */; };
|
32528D1AA72FB1B1A72389E9 /* MartinOMEMO in Frameworks */ = {isa = PBXBuildFile; productRef = 7A670E49149C9C7E13F0A6A2 /* MartinOMEMO */; };
|
||||||
32790D95A577DCE99937B539 /* AccountPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */; };
|
32790D95A577DCE99937B539 /* AccountPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */; };
|
||||||
@@ -349,6 +350,7 @@
|
|||||||
83A5787450609B0C14093679 /* WatchSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionModel.swift; sourceTree = "<group>"; };
|
83A5787450609B0C14093679 /* WatchSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionModel.swift; sourceTree = "<group>"; };
|
||||||
876CDF98063A4C948B00C7CE /* DeviceResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceResource.swift; sourceTree = "<group>"; };
|
876CDF98063A4C948B00C7CE /* DeviceResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceResource.swift; sourceTree = "<group>"; };
|
||||||
8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoAttachmentPreview.swift; sourceTree = "<group>"; };
|
8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoAttachmentPreview.swift; sourceTree = "<group>"; };
|
||||||
|
8E29E7B704EE71C5AC70008E /* MediaPreviewProcessorVideoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewProcessorVideoTests.swift; sourceTree = "<group>"; };
|
||||||
8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaConnectionStatsModule.swift; sourceTree = "<group>"; };
|
8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaConnectionStatsModule.swift; sourceTree = "<group>"; };
|
||||||
96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycle.swift; sourceTree = "<group>"; };
|
96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycle.swift; sourceTree = "<group>"; };
|
||||||
99BE34DC082D19007241AA62 /* NewGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGroupView.swift; sourceTree = "<group>"; };
|
99BE34DC082D19007241AA62 /* NewGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGroupView.swift; sourceTree = "<group>"; };
|
||||||
@@ -558,6 +560,7 @@
|
|||||||
772A4F24CAF527E328657749 /* MediaFileIOTests.swift */,
|
772A4F24CAF527E328657749 /* MediaFileIOTests.swift */,
|
||||||
A127BF01F5C488CD3E053379 /* MediaMetadataTests.swift */,
|
A127BF01F5C488CD3E053379 /* MediaMetadataTests.swift */,
|
||||||
B38702A403DA3E943525FB62 /* MediaPickerSelectionPolicyTests.swift */,
|
B38702A403DA3E943525FB62 /* MediaPickerSelectionPolicyTests.swift */,
|
||||||
|
8E29E7B704EE71C5AC70008E /* MediaPreviewProcessorVideoTests.swift */,
|
||||||
247FBE680C61D12AFB583862 /* MediaSendActivityTrackerTests.swift */,
|
247FBE680C61D12AFB583862 /* MediaSendActivityTrackerTests.swift */,
|
||||||
76FEB73D4B67CB98C93F6244 /* MediaTimeFormatterTests.swift */,
|
76FEB73D4B67CB98C93F6244 /* MediaTimeFormatterTests.swift */,
|
||||||
6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */,
|
6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */,
|
||||||
@@ -1081,6 +1084,7 @@
|
|||||||
DC4FAE63AB8AF89C30512763 /* MediaFileIOTests.swift in Sources */,
|
DC4FAE63AB8AF89C30512763 /* MediaFileIOTests.swift in Sources */,
|
||||||
10C5382981A87A2963DD397C /* MediaMetadataTests.swift in Sources */,
|
10C5382981A87A2963DD397C /* MediaMetadataTests.swift in Sources */,
|
||||||
958FD3CBEC600651E1171B55 /* MediaPickerSelectionPolicyTests.swift in Sources */,
|
958FD3CBEC600651E1171B55 /* MediaPickerSelectionPolicyTests.swift in Sources */,
|
||||||
|
312B8D266C5BEC4F7B2F3276 /* MediaPreviewProcessorVideoTests.swift in Sources */,
|
||||||
4B2CFA4008D1234DCE35ADDE /* MediaSendActivityTrackerTests.swift in Sources */,
|
4B2CFA4008D1234DCE35ADDE /* MediaSendActivityTrackerTests.swift in Sources */,
|
||||||
F27ACACEF60AFAB5AF96EF98 /* MediaTimeFormatterTests.swift in Sources */,
|
F27ACACEF60AFAB5AF96EF98 /* MediaTimeFormatterTests.swift in Sources */,
|
||||||
AC242F69C28F69A001D6559D /* MediaViewerDismissGestureTests.swift in Sources */,
|
AC242F69C28F69A001D6559D /* MediaViewerDismissGestureTests.swift in Sources */,
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ required=(
|
|||||||
Tests/ArchiveStoreTests.swift
|
Tests/ArchiveStoreTests.swift
|
||||||
Tests/SASLprepTests.swift
|
Tests/SASLprepTests.swift
|
||||||
Tests/SaslFailureMessageTests.swift
|
Tests/SaslFailureMessageTests.swift
|
||||||
|
Tests/MediaPreviewProcessorVideoTests.swift
|
||||||
Sources/Shared/XMPP/SASLprep.swift
|
Sources/Shared/XMPP/SASLprep.swift
|
||||||
Sources/Shared/XMPP/LumaSaslFailureModule.swift
|
Sources/Shared/XMPP/LumaSaslFailureModule.swift
|
||||||
Sources/Shared/XMPP/SaslFailureMessage.swift
|
Sources/Shared/XMPP/SaslFailureMessage.swift
|
||||||
@@ -348,6 +349,10 @@ grep -q 'moveItem(at: sourceURL' Sources/Shared/UI/SystemPhotoCameraView.swift |
|
|||||||
echo "The picker movie must be claimed synchronously before dismissal cleanup"
|
echo "The picker movie must be claimed synchronously before dismissal cleanup"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
grep -q 'video-preview' Sources/Shared/UI/ChatView.swift || {
|
||||||
|
echo "The camera video path must log diagnostics"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
grep -q 'ArchiveSyncCursorPolicy.requestPosition' Sources/Shared/XMPP/XMPPService.swift || {
|
grep -q 'ArchiveSyncCursorPolicy.requestPosition' Sources/Shared/XMPP/XMPPService.swift || {
|
||||||
echo "MAM reconnects must prefer the durable archive UID over timestamps"
|
echo "MAM reconnects must prefer the durable archive UID over timestamps"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import ImageIO
|
import ImageIO
|
||||||
|
import os
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
actor MediaPreviewProcessor {
|
actor MediaPreviewProcessor {
|
||||||
@@ -44,6 +45,12 @@ actor MediaPreviewProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let waveform = kind == .voice ? voiceWaveform(for: url) : nil
|
let waveform = kind == .voice ? voiceWaveform(for: url) : nil
|
||||||
|
if kind == .video {
|
||||||
|
let logger = Logger(subsystem: "Luma", category: "video-preview")
|
||||||
|
logger.info(
|
||||||
|
"analyze video: \(url.lastPathComponent) duration=\(String(describing: duration)) thumbnailBytes=\(thumbnailData?.count ?? 0)"
|
||||||
|
)
|
||||||
|
}
|
||||||
return Analysis(
|
return Analysis(
|
||||||
duration: duration,
|
duration: duration,
|
||||||
thumbnailData: thumbnailData,
|
thumbnailData: thumbnailData,
|
||||||
@@ -94,6 +101,8 @@ actor MediaPreviewProcessor {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
Logger(subsystem: "Luma", category: "video-preview")
|
||||||
|
.error("image generator failed at \(second)s: \(error.localizedDescription)")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -239,7 +240,13 @@ struct AttachmentPreviewView: View {
|
|||||||
thumbnailImages = thumbnailImages.filter { retainedIDs.contains($0.key) }
|
thumbnailImages = thumbnailImages.filter { retainedIDs.contains($0.key) }
|
||||||
for draft in drafts where thumbnailImages[draft.id] == nil {
|
for draft in drafts where thumbnailImages[draft.id] == nil {
|
||||||
guard let data = draft.thumbnailData,
|
guard let data = draft.thumbnailData,
|
||||||
let image = DraftThumbnailImage(data: data) else { continue }
|
let image = DraftThumbnailImage(data: data) else {
|
||||||
|
if draft.kind == .video {
|
||||||
|
Logger(subsystem: "Luma", category: "video-preview")
|
||||||
|
.warning("video draft has no decodable thumbnail: \(draft.filename) thumbnailBytes=\(draft.thumbnailData?.count ?? 0)")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
thumbnailImages[draft.id] = image
|
thumbnailImages[draft.id] = image
|
||||||
await Task.yield()
|
await Task.yield()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import CoreTransferable
|
import CoreTransferable
|
||||||
import Foundation
|
import Foundation
|
||||||
import ImageIO
|
import ImageIO
|
||||||
|
import os
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -1263,12 +1264,44 @@ struct ChatView: View {
|
|||||||
photoCameraIsPreparingResult = false
|
photoCameraIsPreparingResult = false
|
||||||
releaseArchiveSyncAfterCapture()
|
releaseArchiveSyncAfterCapture()
|
||||||
}
|
}
|
||||||
|
let logger = Logger(subsystem: "Luma", category: "video-preview")
|
||||||
|
await waitForCameraFileSettling(at: media.url, logger: logger)
|
||||||
let staged = await stageImportedFiles([media.url], preferredKind: media.kind)
|
let staged = await stageImportedFiles([media.url], preferredKind: media.kind)
|
||||||
if !staged, model.errorMessage == nil {
|
if staged {
|
||||||
model.errorMessage = "Камера вернула файл, который не удалось подготовить к отправке."
|
if let draft = attachmentDrafts.last {
|
||||||
|
logger.info(
|
||||||
|
"camera draft staged: kind=\(draft.kind.rawValue) mime=\(draft.mimeType) size=\(draft.byteCount) duration=\(String(describing: draft.duration)) thumbnailBytes=\(draft.thumbnailData?.count ?? 0)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error("camera staging produced no drafts")
|
||||||
|
if model.errorMessage == nil {
|
||||||
|
model.errorMessage = "Камера вернула файл, который не удалось подготовить к отправке."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The picker can hand over a movie whose final bytes are still being
|
||||||
|
/// flushed. Wait until the file size stops changing so staging never
|
||||||
|
/// copies a truncated file.
|
||||||
|
private func waitForCameraFileSettling(at url: URL, logger: Logger) async {
|
||||||
|
guard let initial = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var previous = initial
|
||||||
|
for _ in 0..<10 {
|
||||||
|
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||||
|
guard let current = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if current == previous {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previous = current
|
||||||
|
}
|
||||||
|
logger.warning("camera file size kept changing; staging anyway")
|
||||||
|
}
|
||||||
|
|
||||||
private func presentCamera() {
|
private func presentCamera() {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
guard SystemPhotoCameraView.isAvailable else {
|
guard SystemPhotoCameraView.isAvailable else {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
struct CapturedCameraMedia: Sendable {
|
struct CapturedCameraMedia: Sendable {
|
||||||
let url: URL
|
let url: URL
|
||||||
@@ -121,7 +122,10 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable {
|
|||||||
/// races that cleanup and fails with an unreadable source. The move
|
/// races that cleanup and fails with an unreadable source. The move
|
||||||
/// is a same-volume rename and does not block the main thread.
|
/// is a same-volume rename and does not block the main thread.
|
||||||
private func prepareMovie(from sourceURL: URL) {
|
private func prepareMovie(from sourceURL: URL) {
|
||||||
|
let logger = Logger(subsystem: "Luma", category: "video-preview")
|
||||||
do {
|
do {
|
||||||
|
let sourceSize = (try? sourceURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? -1
|
||||||
|
logger.info("picker movie ready: source=\(sourceURL.lastPathComponent) size=\(sourceSize)")
|
||||||
let fileExtension = sourceURL.pathExtension.isEmpty
|
let fileExtension = sourceURL.pathExtension.isEmpty
|
||||||
? "mov"
|
? "mov"
|
||||||
: sourceURL.pathExtension
|
: sourceURL.pathExtension
|
||||||
@@ -130,13 +134,15 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable {
|
|||||||
let values = try destination.resourceValues(
|
let values = try destination.resourceValues(
|
||||||
forKeys: [.fileSizeKey, .isRegularFileKey]
|
forKeys: [.fileSizeKey, .isRegularFileKey]
|
||||||
)
|
)
|
||||||
guard values.isRegularFile == true,
|
let movedSize = values.fileSize ?? 0
|
||||||
(values.fileSize ?? 0) > 0 else {
|
logger.info("picker movie moved: size=\(movedSize) regular=\(values.isRegularFile == true)")
|
||||||
|
guard values.isRegularFile == true, movedSize > 0 else {
|
||||||
try? FileManager.default.removeItem(at: destination)
|
try? FileManager.default.removeItem(at: destination)
|
||||||
throw SystemCameraError.emptyMovie
|
throw SystemCameraError.emptyMovie
|
||||||
}
|
}
|
||||||
onMedia(.success(CapturedCameraMedia(url: destination, kind: .video)))
|
onMedia(.success(CapturedCameraMedia(url: destination, kind: .video)))
|
||||||
} catch {
|
} catch {
|
||||||
|
logger.error("picker movie preparation failed: \(error.localizedDescription)")
|
||||||
onMedia(.failure(error))
|
onMedia(.failure(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import XCTest
|
||||||
|
@testable import Luma
|
||||||
|
|
||||||
|
final class MediaPreviewProcessorVideoTests: XCTestCase {
|
||||||
|
/// Reproduces the camera-video analysis on the simulator: the pipeline
|
||||||
|
/// must produce a duration and a JPEG thumbnail for a freshly written
|
||||||
|
/// HEVC QuickTime movie.
|
||||||
|
func testVideoAnalysisProducesDurationAndThumbnail() async throws {
|
||||||
|
let url = try await Self.makeTestMovie()
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
let processor = MediaPreviewProcessor()
|
||||||
|
let analysis = await processor.analyze(
|
||||||
|
url: url,
|
||||||
|
kind: .video,
|
||||||
|
mimeType: "video/quicktime"
|
||||||
|
)
|
||||||
|
XCTAssertNotNil(analysis.duration)
|
||||||
|
if let duration = analysis.duration {
|
||||||
|
XCTAssertGreaterThan(duration, 0)
|
||||||
|
}
|
||||||
|
XCTAssertNotNil(analysis.thumbnailData)
|
||||||
|
XCTAssertFalse(analysis.thumbnailData?.isEmpty ?? true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeTestMovie() async throws -> URL {
|
||||||
|
let output = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("test-camera-\(UUID().uuidString).mov")
|
||||||
|
let writer = try AVAssetWriter(outputURL: output, fileType: .mov)
|
||||||
|
let input = AVAssetWriterInput(
|
||||||
|
mediaType: .video,
|
||||||
|
outputSettings: [
|
||||||
|
AVVideoCodecKey: AVVideoCodecType.hevc,
|
||||||
|
AVVideoWidthKey: 640,
|
||||||
|
AVVideoHeightKey: 480,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
input.expectsMediaDataInRealTime = true
|
||||||
|
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
|
||||||
|
assetWriterInput: input,
|
||||||
|
sourcePixelBufferAttributes: [
|
||||||
|
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
||||||
|
kCVPixelBufferWidthKey as String: 640,
|
||||||
|
kCVPixelBufferHeightKey as String: 480,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
writer.add(input)
|
||||||
|
guard writer.startWriting() else {
|
||||||
|
throw writer.error ?? NSError(domain: "test", code: 1)
|
||||||
|
}
|
||||||
|
writer.startSession(atSourceTime: .zero)
|
||||||
|
for frame in 0..<60 {
|
||||||
|
while !input.isReadyForMoreMediaData {
|
||||||
|
try await Task.sleep(nanoseconds: 2_000_000)
|
||||||
|
}
|
||||||
|
var buffer: CVPixelBuffer?
|
||||||
|
CVPixelBufferCreate(
|
||||||
|
kCFAllocatorDefault, 640, 480, kCVPixelFormatType_32BGRA, nil, &buffer
|
||||||
|
)
|
||||||
|
adaptor.append(buffer!, withPresentationTime: CMTime(value: Int64(frame), timescale: 30))
|
||||||
|
}
|
||||||
|
input.markAsFinished()
|
||||||
|
await writer.finishWriting()
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user