Better status editor
This commit is contained in:
parent
99dc57a023
commit
03e5a960d2
|
@ -51,7 +51,7 @@
|
|||
"location" : "https://github.com/Dimillian/TextView",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "26b2930e82bb379a4abf0fcba408c0a09fbbb407"
|
||||
"revision" : "8a52d16dc428780c8bcad6c0c9301a31704bcc1a"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
@ -8,7 +8,7 @@ public struct Application: Codable, Identifiable {
|
|||
public let website: URL?
|
||||
}
|
||||
|
||||
public enum Visibility: String, Codable {
|
||||
public enum Visibility: String, Codable, CaseIterable {
|
||||
case pub = "public"
|
||||
case unlisted
|
||||
case priv = "private"
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
import Foundation
|
||||
import Models
|
||||
|
||||
public enum Statuses: Endpoint {
|
||||
case postStatus(status: String,
|
||||
inReplyTo: String?,
|
||||
mediaIds: [String]?,
|
||||
spoilerText: String?)
|
||||
spoilerText: String?,
|
||||
visibility: Visibility)
|
||||
case editStatus(id: String,
|
||||
status: String,
|
||||
mediaIds: [String]?,
|
||||
spoilerText: String?)
|
||||
spoilerText: String?,
|
||||
visibility: Visibility)
|
||||
case status(id: String)
|
||||
case context(id: String)
|
||||
case favourite(id: String)
|
||||
|
@ -24,7 +27,7 @@ public enum Statuses: Endpoint {
|
|||
return "statuses"
|
||||
case .status(let id):
|
||||
return "statuses/\(id)"
|
||||
case .editStatus(let id, _, _, _):
|
||||
case .editStatus(let id, _, _, _, _):
|
||||
return "statuses/\(id)"
|
||||
case .context(let id):
|
||||
return "statuses/\(id)/context"
|
||||
|
@ -45,8 +48,9 @@ public enum Statuses: Endpoint {
|
|||
|
||||
public func queryItems() -> [URLQueryItem]? {
|
||||
switch self {
|
||||
case let .postStatus(status, inReplyTo, mediaIds, spoilerText):
|
||||
var params: [URLQueryItem] = [.init(name: "status", value: status)]
|
||||
case let .postStatus(status, inReplyTo, mediaIds, spoilerText, visibility):
|
||||
var params: [URLQueryItem] = [.init(name: "status", value: status),
|
||||
.init(name: "visibility", value: visibility.rawValue)]
|
||||
if let inReplyTo {
|
||||
params.append(.init(name: "in_reply_to_id", value: inReplyTo))
|
||||
}
|
||||
|
@ -59,8 +63,9 @@ public enum Statuses: Endpoint {
|
|||
params.append(.init(name: "spoiler_text", value: spoilerText))
|
||||
}
|
||||
return params
|
||||
case let .editStatus(_, status, mediaIds, spoilerText):
|
||||
var params: [URLQueryItem] = [.init(name: "status", value: status)]
|
||||
case let .editStatus(_, status, mediaIds, spoilerText, visibility):
|
||||
var params: [URLQueryItem] = [.init(name: "status", value: status),
|
||||
.init(name: "visibility", value: visibility.rawValue)]
|
||||
if let mediaIds {
|
||||
for mediaId in mediaIds {
|
||||
params.append(.init(name: "media_ids[]", value: mediaId))
|
||||
|
|
|
@ -24,19 +24,23 @@ public struct StatusEditorView: View {
|
|||
NavigationStack {
|
||||
ZStack(alignment: .bottom) {
|
||||
ScrollView {
|
||||
Divider()
|
||||
VStack(spacing: 12) {
|
||||
accountHeaderView
|
||||
TextView($viewModel.statusText)
|
||||
.padding(.horizontal, DS.Constants.layoutPadding)
|
||||
TextView($viewModel.statusText, $viewModel.selectedRange)
|
||||
.placeholder("What's on your mind")
|
||||
.padding(.horizontal, DS.Constants.layoutPadding)
|
||||
if let status = viewModel.embededStatus {
|
||||
StatusEmbededView(status: status)
|
||||
.padding(.horizontal, DS.Constants.layoutPadding)
|
||||
}
|
||||
mediasView
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
accessoryView
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.client = client
|
||||
|
@ -45,7 +49,6 @@ public struct StatusEditorView: View {
|
|||
dismiss()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, DS.Constants.layoutPadding)
|
||||
.navigationTitle(viewModel.mode.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
@ -95,11 +98,11 @@ public struct StatusEditorView: View {
|
|||
}
|
||||
|
||||
private var mediasView: some View {
|
||||
ScrollView(.horizontal) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(viewModel.mediasImages) { container in
|
||||
if let localImage = container.image {
|
||||
makeLocalImage(image: localImage)
|
||||
if container.image != nil {
|
||||
makeLocalImage(container: container)
|
||||
} else if let url = container.mediaAttachement?.url {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
makeLazyImage(url: url)
|
||||
|
@ -115,19 +118,45 @@ public struct StatusEditorView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, DS.Constants.layoutPadding)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeLocalImage(image: UIImage) -> some View {
|
||||
private func makeLocalImage(container: StatusEditorViewModel.ImageContainer) -> some View {
|
||||
ZStack(alignment: .center) {
|
||||
Image(uiImage: image)
|
||||
Image(uiImage: container.image!)
|
||||
.resizable()
|
||||
.blur(radius: 20 )
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 150, height: 150)
|
||||
.cornerRadius(8)
|
||||
|
||||
ProgressView()
|
||||
if container.error != nil {
|
||||
VStack {
|
||||
Text("Error uploading")
|
||||
Button {
|
||||
withAnimation {
|
||||
viewModel.mediasImages.removeAll(where: { $0.id == container.id })
|
||||
}
|
||||
} label: {
|
||||
VStack {
|
||||
Text("Delete")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.upload(container: container)
|
||||
}
|
||||
} label: {
|
||||
VStack {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -147,13 +176,49 @@ public struct StatusEditorView: View {
|
|||
}
|
||||
|
||||
private var accessoryView: some View {
|
||||
HStack {
|
||||
PhotosPicker(selection: $viewModel.selectedMedias,
|
||||
matching: .images) {
|
||||
Image(systemName: "photo.fill.on.rectangle.fill")
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
HStack(spacing: 16) {
|
||||
PhotosPicker(selection: $viewModel.selectedMedias,
|
||||
matching: .images) {
|
||||
Image(systemName: "photo.fill.on.rectangle.fill")
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.insertStatusText(text: " @")
|
||||
} label: {
|
||||
Image(systemName: "at")
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.insertStatusText(text: " #")
|
||||
} label: {
|
||||
Image(systemName: "number")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
visibilityMenu
|
||||
}
|
||||
Spacer()
|
||||
.padding(.horizontal, DS.Constants.layoutPadding)
|
||||
.padding(.vertical, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
private var visibilityMenu: some View {
|
||||
Menu {
|
||||
ForEach(Models.Visibility.allCases, id: \.self) { visibility in
|
||||
Button {
|
||||
viewModel.visibility = visibility
|
||||
} label: {
|
||||
Label(visibility.title, systemImage: visibility.iconName)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: viewModel.visibility.iconName)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -25,21 +25,35 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
@Published var selectedRange: NSRange = .init(location: 0, length: 0)
|
||||
|
||||
@Published var isPosting: Bool = false
|
||||
@Published var selectedMedias: [PhotosPickerItem] = [] {
|
||||
didSet {
|
||||
if selectedMedias.count > 4 {
|
||||
selectedMedias = selectedMedias.prefix(4).map{ $0 }
|
||||
}
|
||||
inflateSelectedMedias()
|
||||
}
|
||||
}
|
||||
@Published var mediasImages: [ImageContainer] = []
|
||||
@Published var embededStatus: Status?
|
||||
|
||||
@Published var visibility: Models.Visibility = .pub
|
||||
|
||||
private var uploadTask: Task<Void, Never>?
|
||||
|
||||
init(mode: Mode) {
|
||||
self.mode = mode
|
||||
}
|
||||
|
||||
func insertStatusText(text: String) {
|
||||
let string = statusText
|
||||
string.mutableString.insert(text, at: selectedRange.location)
|
||||
statusText = string
|
||||
selectedRange = NSRange(location: selectedRange.location + text.utf16.count, length: 0)
|
||||
}
|
||||
|
||||
func postStatus() async -> Status? {
|
||||
guard let client else { return nil }
|
||||
do {
|
||||
|
@ -50,12 +64,14 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
postStatus = try await client.post(endpoint: Statuses.postStatus(status: statusText.string,
|
||||
inReplyTo: mode.replyToStatus?.id,
|
||||
mediaIds: mediasImages.compactMap{ $0.mediaAttachement?.id },
|
||||
spoilerText: nil))
|
||||
spoilerText: nil,
|
||||
visibility: visibility))
|
||||
case let .edit(status):
|
||||
postStatus = try await client.put(endpoint: Statuses.editStatus(id: status.id,
|
||||
status: statusText.string,
|
||||
mediaIds: mediasImages.compactMap{ $0.mediaAttachement?.id },
|
||||
spoilerText: nil))
|
||||
spoilerText: nil,
|
||||
visibility: visibility))
|
||||
}
|
||||
generator.notificationOccurred(.success)
|
||||
isPosting = false
|
||||
|
@ -71,12 +87,15 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
switch mode {
|
||||
case let .replyTo(status):
|
||||
statusText = .init(string: "@\(status.reblog?.account.acct ?? status.account.acct) ")
|
||||
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
|
||||
case let .edit(status):
|
||||
statusText = .init(status.content.asSafeAttributedString)
|
||||
selectedRange = .init(location: 0, length: 0)
|
||||
case let .quote(status):
|
||||
self.embededStatus = status
|
||||
if let url = status.reblog?.url ?? status.url {
|
||||
statusText = .init(string: "\n\nFrom: @\(status.reblog?.account.acct ?? status.account.acct)\n\(url)")
|
||||
selectedRange = .init(location: 0, length: 0)
|
||||
}
|
||||
default:
|
||||
break
|
||||
|
@ -129,6 +148,12 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Media related function
|
||||
|
||||
private func indexOf(container: ImageContainer) -> Int? {
|
||||
mediasImages.firstIndex(where: { $0.id == container.id })
|
||||
}
|
||||
|
||||
func inflateSelectedMedias() {
|
||||
self.mediasImages = []
|
||||
|
||||
|
@ -155,15 +180,29 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
uploadTask?.cancel()
|
||||
let mediasCopy = mediasImages
|
||||
uploadTask = Task {
|
||||
for (index, media) in mediasCopy.enumerated() {
|
||||
do {
|
||||
if !Task.isCancelled,
|
||||
let data = media.image?.jpegData(compressionQuality: 0.90),
|
||||
let uploadedMedia = try await uploadMedia(data: data) {
|
||||
for media in mediasCopy {
|
||||
if !Task.isCancelled {
|
||||
await upload(container: media)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func upload(container: ImageContainer) async {
|
||||
if let index = indexOf(container: container) {
|
||||
let originalContainer = mediasImages[index]
|
||||
let newContainer = ImageContainer(image: originalContainer.image, mediaAttachement: nil, error: nil)
|
||||
mediasImages[index] = newContainer
|
||||
do {
|
||||
if let data = originalContainer.image?.jpegData(compressionQuality: 0.90) {
|
||||
let uploadedMedia = try await uploadMedia(data: data)
|
||||
if let index = indexOf(container: newContainer) {
|
||||
mediasImages[index] = .init(image: nil, mediaAttachement: uploadedMedia, error: nil)
|
||||
}
|
||||
} catch {
|
||||
mediasImages[index] = .init(image: nil, mediaAttachement: nil, error: error)
|
||||
}
|
||||
} catch {
|
||||
if let index = indexOf(container: newContainer) {
|
||||
mediasImages[index] = .init(image: originalContainer.image, mediaAttachement: nil, error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -171,10 +210,6 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
|
||||
private func uploadMedia(data: Data) async throws -> MediaAttachement? {
|
||||
guard let client else { return nil }
|
||||
do {
|
||||
return try await client.mediaUpload(mimeType: "image/jpeg", data: data)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
return try await client.mediaUpload(mimeType: "image/jpeg", data: data)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,4 +13,17 @@ extension Visibility {
|
|||
return "at.circle"
|
||||
}
|
||||
}
|
||||
|
||||
public var title: String {
|
||||
switch self {
|
||||
case .pub:
|
||||
return "Everyone"
|
||||
case .unlisted:
|
||||
return "Unlisted"
|
||||
case .priv:
|
||||
return "Followers"
|
||||
case .direct:
|
||||
return "Private Mention"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue