add pics support
Some checks failed
Build IPA / build (push) Has been cancelled

This commit is contained in:
Lain Iwakura 2025-08-08 15:45:20 +03:00
parent 4b20775c61
commit 8cab7bac36
No known key found for this signature in database
GPG Key ID: C7C18257F2ADC6F8
8 changed files with 249 additions and 51 deletions

View File

@ -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)

View File

@ -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 {

View File

@ -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 {

View File

@ -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)
}
}
}
}

View File

@ -8,6 +8,8 @@
</array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Нужно для выбора фото и загрузки вложений</string>
<key>UIBackgroundModes</key>
<array>
<string>background-processing</string>

View File

@ -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()

View File

@ -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) {