diff --git a/MobileMkch.xcodeproj/project.xcworkspace/xcuserdata/platon.xcuserdatad/UserInterfaceState.xcuserstate b/MobileMkch.xcodeproj/project.xcworkspace/xcuserdata/platon.xcuserdatad/UserInterfaceState.xcuserstate
new file mode 100644
index 0000000..525b529
Binary files /dev/null and b/MobileMkch.xcodeproj/project.xcworkspace/xcuserdata/platon.xcuserdatad/UserInterfaceState.xcuserstate differ
diff --git a/MobileMkch/APIClient.swift b/MobileMkch/APIClient.swift
index a1bbf48..fa72b96 100644
--- a/MobileMkch/APIClient.swift
+++ b/MobileMkch/APIClient.swift
@@ -1,6 +1,13 @@
import Foundation
import Network
+struct UploadFile {
+ let name: String
+ let filename: String
+ let mimeType: String
+ let data: Data
+}
+
class APIClient: ObservableObject {
private let baseURL = "https://mkch.pooziqo.xyz"
private let apiURL = "https://mkch.pooziqo.xyz/api"
@@ -437,21 +444,21 @@ class APIClient: ObservableObject {
}
}
- func createThread(boardCode: String, title: String, text: String, passcode: String, completion: @escaping (Error?) -> Void) {
+ func createThread(boardCode: String, title: String, text: String, passcode: String, files: [UploadFile] = [], completion: @escaping (Error?) -> Void) {
if !passcode.isEmpty {
loginWithPasscode(passcode: passcode) { error in
if let error = error {
completion(error)
return
}
- self.performCreateThread(boardCode: boardCode, title: title, text: text, completion: completion)
+ self.performCreateThread(boardCode: boardCode, title: title, text: text, files: files, completion: completion)
}
} else {
- performCreateThread(boardCode: boardCode, title: title, text: text, completion: completion)
+ performCreateThread(boardCode: boardCode, title: title, text: text, files: files, completion: completion)
}
}
- private func performCreateThread(boardCode: String, title: String, text: String, completion: @escaping (Error?) -> Void) {
+ private func performCreateThread(boardCode: String, title: String, text: String, files: [UploadFile] = [], completion: @escaping (Error?) -> Void) {
let formURL = "\(baseURL)/boards/board/\(boardCode)/new"
let url = URL(string: formURL)!
@@ -485,19 +492,30 @@ class APIClient: ObservableObject {
return
}
- var formData = URLComponents()
- formData.queryItems = [
- URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken),
- URLQueryItem(name: "title", value: title),
- URLQueryItem(name: "text", value: text)
- ]
-
var postRequest = URLRequest(url: url)
postRequest.httpMethod = "POST"
- postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
postRequest.setValue(formURL, forHTTPHeaderField: "Referer")
postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
- postRequest.httpBody = formData.query?.data(using: .utf8)
+
+ if files.isEmpty {
+ var formData = URLComponents()
+ formData.queryItems = [
+ URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken),
+ URLQueryItem(name: "title", value: title),
+ URLQueryItem(name: "text", value: text)
+ ]
+ postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
+ postRequest.httpBody = formData.query?.data(using: .utf8)
+ } else {
+ let boundary = "Boundary-\(UUID().uuidString)"
+ postRequest.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ let body = self.buildMultipartBody(parameters: [
+ "csrfmiddlewaretoken": csrfToken,
+ "title": title,
+ "text": text
+ ], files: files, boundary: boundary)
+ postRequest.httpBody = body
+ }
self.session.dataTask(with: postRequest) { _, postResponse, postError in
DispatchQueue.main.async {
@@ -520,21 +538,21 @@ class APIClient: ObservableObject {
}.resume()
}
- func addComment(boardCode: String, threadId: Int, text: String, passcode: String, completion: @escaping (Error?) -> Void) {
+ func addComment(boardCode: String, threadId: Int, text: String, passcode: String, files: [UploadFile] = [], completion: @escaping (Error?) -> Void) {
if !passcode.isEmpty {
loginWithPasscode(passcode: passcode) { error in
if let error = error {
completion(error)
return
}
- self.performAddComment(boardCode: boardCode, threadId: threadId, text: text, completion: completion)
+ self.performAddComment(boardCode: boardCode, threadId: threadId, text: text, files: files, completion: completion)
}
} else {
- performAddComment(boardCode: boardCode, threadId: threadId, text: text, completion: completion)
+ performAddComment(boardCode: boardCode, threadId: threadId, text: text, files: files, completion: completion)
}
}
- private func performAddComment(boardCode: String, threadId: Int, text: String, completion: @escaping (Error?) -> Void) {
+ private func performAddComment(boardCode: String, threadId: Int, text: String, files: [UploadFile] = [], completion: @escaping (Error?) -> Void) {
let formURL = "\(baseURL)/boards/board/\(boardCode)/thread/\(threadId)/comment"
let url = URL(string: formURL)!
@@ -568,18 +586,28 @@ class APIClient: ObservableObject {
return
}
- var formData = URLComponents()
- formData.queryItems = [
- URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken),
- URLQueryItem(name: "text", value: text)
- ]
-
var postRequest = URLRequest(url: url)
postRequest.httpMethod = "POST"
- postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
postRequest.setValue(formURL, forHTTPHeaderField: "Referer")
postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
- postRequest.httpBody = formData.query?.data(using: .utf8)
+
+ if files.isEmpty {
+ var formData = URLComponents()
+ formData.queryItems = [
+ URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken),
+ URLQueryItem(name: "text", value: text)
+ ]
+ postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
+ postRequest.httpBody = formData.query?.data(using: .utf8)
+ } else {
+ let boundary = "Boundary-\(UUID().uuidString)"
+ postRequest.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ let body = self.buildMultipartBody(parameters: [
+ "csrfmiddlewaretoken": csrfToken,
+ "text": text
+ ], files: files, boundary: boundary)
+ postRequest.httpBody = body
+ }
self.session.dataTask(with: postRequest) { _, postResponse, postError in
DispatchQueue.main.async {
@@ -617,6 +645,25 @@ class APIClient: ObservableObject {
return String(html[range])
}
+ private func buildMultipartBody(parameters: [String: String], files: [UploadFile], boundary: String) -> Data {
+ var body = Data()
+ let boundaryPrefix = "--\(boundary)\r\n"
+ for (key, value) in parameters {
+ body.append(boundaryPrefix.data(using: .utf8)!)
+ body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
+ body.append("\(value)\r\n".data(using: .utf8)!)
+ }
+ for file in files {
+ body.append(boundaryPrefix.data(using: .utf8)!)
+ body.append("Content-Disposition: form-data; name=\"\(file.name)\"; filename=\"\(file.filename)\"\r\n".data(using: .utf8)!)
+ body.append("Content-Type: \(file.mimeType)\r\n\r\n".data(using: .utf8)!)
+ body.append(file.data)
+ body.append("\r\n".data(using: .utf8)!)
+ }
+ body.append("--\(boundary)--\r\n".data(using: .utf8)!)
+ return body
+ }
+
func checkNewThreads(forBoard boardCode: String, lastKnownThreadId: Int, completion: @escaping (Result<[Thread], Error>) -> Void) {
let url = URL(string: "\(apiURL)/board/\(boardCode)")!
var request = URLRequest(url: url)
diff --git a/MobileMkch/AddCommentView.swift b/MobileMkch/AddCommentView.swift
index 95dcf8a..55404bc 100644
--- a/MobileMkch/AddCommentView.swift
+++ b/MobileMkch/AddCommentView.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import PhotosUI
struct AddCommentView: View {
let boardCode: String
@@ -12,6 +13,8 @@ struct AddCommentView: View {
@State private var errorMessage: String?
@State private var showingSuccess = false
@FocusState private var isTextFocused: Bool
+ @State private var pickedImages: [UIImage] = []
+ @State private var showPicker: Bool = false
var body: some View {
NavigationView {
@@ -56,6 +59,38 @@ struct AddCommentView: View {
)
}
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Фото")
+ .font(.headline)
+ .foregroundColor(.primary)
+ Button {
+ showPicker = true
+ } label: {
+ HStack {
+ Image(systemName: "photo.on.rectangle")
+ Text("Выбрать фото")
+ Spacer()
+ }
+ .padding(12)
+ .background(Color(.secondarySystemBackground))
+ .cornerRadius(12)
+ }
+ if !pickedImages.isEmpty {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ ForEach(Array(pickedImages.enumerated()), id: \.offset) { _, img in
+ Image(uiImage: img)
+ .resizable()
+ .scaledToFill()
+ .frame(width: 64, height: 64)
+ .clipped()
+ .cornerRadius(8)
+ }
+ }
+ }
+ }
+ }
+
HStack(spacing: 8) {
Image(systemName: settings.passcode.isEmpty ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
.foregroundColor(settings.passcode.isEmpty ? .orange : .green)
@@ -126,6 +161,11 @@ struct AddCommentView: View {
} message: {
Text("Комментарий добавлен")
}
+ .sheet(isPresented: $showPicker) {
+ ImagePickerView(selectionLimit: 4) { images in
+ pickedImages = images
+ }
+ }
}
}
@@ -139,7 +179,8 @@ struct AddCommentView: View {
boardCode: boardCode,
threadId: threadId,
text: text,
- passcode: settings.passcode
+ passcode: settings.passcode,
+ files: buildUploadFiles()
) { error in
DispatchQueue.main.async {
self.isLoading = false
@@ -152,6 +193,22 @@ struct AddCommentView: View {
}
}
}
+
+ private func buildUploadFiles() -> [UploadFile] {
+ var result: [UploadFile] = []
+ for (idx, img) in pickedImages.enumerated() {
+ if let data = img.jpegData(compressionQuality: 0.9) {
+ let file = UploadFile(
+ name: "files",
+ filename: "photo_\(idx + 1).jpg",
+ mimeType: "image/jpeg",
+ data: data
+ )
+ result.append(file)
+ }
+ }
+ return result
+ }
}
#Preview {
diff --git a/MobileMkch/CreateThreadView.swift b/MobileMkch/CreateThreadView.swift
index 0ce8b6b..ca95e53 100644
--- a/MobileMkch/CreateThreadView.swift
+++ b/MobileMkch/CreateThreadView.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import PhotosUI
struct CreateThreadView: View {
let boardCode: String
@@ -13,6 +14,8 @@ struct CreateThreadView: View {
@State private var showingSuccess = false
@FocusState private var titleFocused: Bool
@FocusState private var textFocused: Bool
+ @State private var pickedImages: [UIImage] = []
+ @State private var showPicker: Bool = false
var body: some View {
NavigationView {
@@ -41,6 +44,38 @@ struct CreateThreadView: View {
)
}
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Фото")
+ .font(.headline)
+ .foregroundColor(.primary)
+ Button {
+ showPicker = true
+ } label: {
+ HStack {
+ Image(systemName: "photo.on.rectangle")
+ Text("Выбрать фото")
+ Spacer()
+ }
+ .padding(12)
+ .background(Color(.secondarySystemBackground))
+ .cornerRadius(12)
+ }
+ if !pickedImages.isEmpty {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ ForEach(Array(pickedImages.enumerated()), id: \.offset) { _, img in
+ Image(uiImage: img)
+ .resizable()
+ .scaledToFill()
+ .frame(width: 64, height: 64)
+ .clipped()
+ .cornerRadius(8)
+ }
+ }
+ }
+ }
+ }
+
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Содержание")
@@ -149,6 +184,11 @@ struct CreateThreadView: View {
} message: {
Text("Тред создан")
}
+ .sheet(isPresented: $showPicker) {
+ ImagePickerView(selectionLimit: 4) { images in
+ pickedImages = images
+ }
+ }
}
}
@@ -162,7 +202,8 @@ struct CreateThreadView: View {
boardCode: boardCode,
title: title,
text: text,
- passcode: settings.passcode
+ passcode: settings.passcode,
+ files: buildUploadFiles()
) { error in
DispatchQueue.main.async {
self.isLoading = false
@@ -175,6 +216,22 @@ struct CreateThreadView: View {
}
}
}
+
+ private func buildUploadFiles() -> [UploadFile] {
+ var result: [UploadFile] = []
+ for (idx, img) in pickedImages.enumerated() {
+ if let data = img.jpegData(compressionQuality: 0.9) {
+ let file = UploadFile(
+ name: "files",
+ filename: "photo_\(idx + 1).jpg",
+ mimeType: "image/jpeg",
+ data: data
+ )
+ result.append(file)
+ }
+ }
+ return result
+ }
}
#Preview {
diff --git a/MobileMkch/ImagePicker.swift b/MobileMkch/ImagePicker.swift
new file mode 100644
index 0000000..e1a2228
--- /dev/null
+++ b/MobileMkch/ImagePicker.swift
@@ -0,0 +1,57 @@
+import SwiftUI
+import PhotosUI
+
+struct ImagePickerView: UIViewControllerRepresentable {
+ let selectionLimit: Int
+ let onComplete: ([UIImage]) -> Void
+
+ func makeUIViewController(context: Context) -> PHPickerViewController {
+ var configuration = PHPickerConfiguration()
+ configuration.filter = .images
+ configuration.selectionLimit = selectionLimit
+ let picker = PHPickerViewController(configuration: configuration)
+ picker.delegate = context.coordinator
+ return picker
+ }
+
+ func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(onComplete: onComplete)
+ }
+
+ final class Coordinator: NSObject, PHPickerViewControllerDelegate {
+ let onComplete: ([UIImage]) -> Void
+
+ init(onComplete: @escaping ([UIImage]) -> Void) {
+ self.onComplete = onComplete
+ }
+
+ func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
+ guard !results.isEmpty else {
+ picker.dismiss(animated: true)
+ return
+ }
+ let providers = results.map { $0.itemProvider }
+ var images: [UIImage] = []
+ let group = DispatchGroup()
+ for provider in providers {
+ if provider.canLoadObject(ofClass: UIImage.self) {
+ group.enter()
+ provider.loadObject(ofClass: UIImage.self) { object, _ in
+ if let img = object as? UIImage {
+ images.append(img)
+ }
+ group.leave()
+ }
+ }
+ }
+ group.notify(queue: .main) {
+ self.onComplete(images)
+ picker.dismiss(animated: true)
+ }
+ }
+ }
+}
+
+
diff --git a/MobileMkch/Info.plist b/MobileMkch/Info.plist
index 2bd486b..b27b4e4 100644
--- a/MobileMkch/Info.plist
+++ b/MobileMkch/Info.plist
@@ -8,6 +8,8 @@
NSSupportsLiveActivities
+ NSPhotoLibraryUsageDescription
+ Нужно для выбора фото и загрузки вложений
UIBackgroundModes
background-processing
diff --git a/MobileMkch/MobileMkchApp.swift b/MobileMkch/MobileMkchApp.swift
index 0fcd0fc..8360add 100644
--- a/MobileMkch/MobileMkchApp.swift
+++ b/MobileMkch/MobileMkchApp.swift
@@ -37,7 +37,7 @@ struct MobileMkchApp: App {
private func handleNotificationLaunch() {
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
- let userInfo = scene.session.userInfo {
+ let _ = scene.session.userInfo {
print("Приложение запущено из уведомления")
}
notificationManager.clearBadge()
diff --git a/MobileMkch/ThreadDetailView.swift b/MobileMkch/ThreadDetailView.swift
index 372da34..3b7f52c 100644
--- a/MobileMkch/ThreadDetailView.swift
+++ b/MobileMkch/ThreadDetailView.swift
@@ -89,29 +89,7 @@ struct ThreadDetailView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
- HStack {
- Button("Обновить") { loadThreadDetail() }
- if #available(iOS 16.1, *) {
- Toggle("", isOn: $activityOn)
- .toggleStyle(SwitchToggleStyle(tint: .blue))
- .labelsHidden()
- .onChange(of: activityOn) { newValue in
- guard settings.liveActivityEnabled else { return }
- if newValue {
- if let detail = threadDetail {
- LiveActivityManager.shared.start(for: detail, comments: comments, settings: settings)
- }
- } else {
- LiveActivityManager.shared.end(threadId: thread.id)
- }
- }
- .onAppear {
- if settings.liveActivityEnabled {
- activityOn = LiveActivityManager.shared.isActive(threadId: thread.id)
- }
- }
- }
- }
+ Button("Обновить") { loadThreadDetail() }
}
}
.sheet(isPresented: $showingAddComment) {