Better status editor

This commit is contained in:
Thomas Ricouard 2022-12-27 19:10:31 +01:00
parent 99dc57a023
commit 03e5a960d2
6 changed files with 156 additions and 38 deletions

View file

@ -51,7 +51,7 @@
"location" : "https://github.com/Dimillian/TextView", "location" : "https://github.com/Dimillian/TextView",
"state" : { "state" : {
"branch" : "main", "branch" : "main",
"revision" : "26b2930e82bb379a4abf0fcba408c0a09fbbb407" "revision" : "8a52d16dc428780c8bcad6c0c9301a31704bcc1a"
} }
} }
], ],

View file

@ -8,7 +8,7 @@ public struct Application: Codable, Identifiable {
public let website: URL? public let website: URL?
} }
public enum Visibility: String, Codable { public enum Visibility: String, Codable, CaseIterable {
case pub = "public" case pub = "public"
case unlisted case unlisted
case priv = "private" case priv = "private"

View file

@ -1,14 +1,17 @@
import Foundation import Foundation
import Models
public enum Statuses: Endpoint { public enum Statuses: Endpoint {
case postStatus(status: String, case postStatus(status: String,
inReplyTo: String?, inReplyTo: String?,
mediaIds: [String]?, mediaIds: [String]?,
spoilerText: String?) spoilerText: String?,
visibility: Visibility)
case editStatus(id: String, case editStatus(id: String,
status: String, status: String,
mediaIds: [String]?, mediaIds: [String]?,
spoilerText: String?) spoilerText: String?,
visibility: Visibility)
case status(id: String) case status(id: String)
case context(id: String) case context(id: String)
case favourite(id: String) case favourite(id: String)
@ -24,7 +27,7 @@ public enum Statuses: Endpoint {
return "statuses" return "statuses"
case .status(let id): case .status(let id):
return "statuses/\(id)" return "statuses/\(id)"
case .editStatus(let id, _, _, _): case .editStatus(let id, _, _, _, _):
return "statuses/\(id)" return "statuses/\(id)"
case .context(let id): case .context(let id):
return "statuses/\(id)/context" return "statuses/\(id)/context"
@ -45,8 +48,9 @@ public enum Statuses: Endpoint {
public func queryItems() -> [URLQueryItem]? { public func queryItems() -> [URLQueryItem]? {
switch self { switch self {
case let .postStatus(status, inReplyTo, mediaIds, spoilerText): case let .postStatus(status, inReplyTo, mediaIds, spoilerText, visibility):
var params: [URLQueryItem] = [.init(name: "status", value: status)] var params: [URLQueryItem] = [.init(name: "status", value: status),
.init(name: "visibility", value: visibility.rawValue)]
if let inReplyTo { if let inReplyTo {
params.append(.init(name: "in_reply_to_id", value: 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)) params.append(.init(name: "spoiler_text", value: spoilerText))
} }
return params return params
case let .editStatus(_, status, mediaIds, spoilerText): case let .editStatus(_, status, mediaIds, spoilerText, visibility):
var params: [URLQueryItem] = [.init(name: "status", value: status)] var params: [URLQueryItem] = [.init(name: "status", value: status),
.init(name: "visibility", value: visibility.rawValue)]
if let mediaIds { if let mediaIds {
for mediaId in mediaIds { for mediaId in mediaIds {
params.append(.init(name: "media_ids[]", value: mediaId)) params.append(.init(name: "media_ids[]", value: mediaId))

View file

@ -24,19 +24,23 @@ public struct StatusEditorView: View {
NavigationStack { NavigationStack {
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
ScrollView { ScrollView {
Divider()
VStack(spacing: 12) { VStack(spacing: 12) {
accountHeaderView accountHeaderView
TextView($viewModel.statusText) .padding(.horizontal, DS.Constants.layoutPadding)
TextView($viewModel.statusText, $viewModel.selectedRange)
.placeholder("What's on your mind") .placeholder("What's on your mind")
.padding(.horizontal, DS.Constants.layoutPadding)
if let status = viewModel.embededStatus { if let status = viewModel.embededStatus {
StatusEmbededView(status: status) StatusEmbededView(status: status)
.padding(.horizontal, DS.Constants.layoutPadding)
} }
mediasView mediasView
Spacer() Spacer()
} }
.padding(.top, 8)
} }
accessoryView accessoryView
.padding(.bottom, 12)
} }
.onAppear { .onAppear {
viewModel.client = client viewModel.client = client
@ -45,7 +49,6 @@ public struct StatusEditorView: View {
dismiss() dismiss()
} }
} }
.padding(.horizontal, DS.Constants.layoutPadding)
.navigationTitle(viewModel.mode.title) .navigationTitle(viewModel.mode.title)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@ -95,11 +98,11 @@ public struct StatusEditorView: View {
} }
private var mediasView: some View { private var mediasView: some View {
ScrollView(.horizontal) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(viewModel.mediasImages) { container in ForEach(viewModel.mediasImages) { container in
if let localImage = container.image { if container.image != nil {
makeLocalImage(image: localImage) makeLocalImage(container: container)
} else if let url = container.mediaAttachement?.url { } else if let url = container.mediaAttachement?.url {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
makeLazyImage(url: url) makeLazyImage(url: url)
@ -115,21 +118,47 @@ 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) { ZStack(alignment: .center) {
Image(uiImage: image) Image(uiImage: container.image!)
.resizable() .resizable()
.blur(radius: 20 ) .blur(radius: 20 )
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.frame(width: 150, height: 150) .frame(width: 150, height: 150)
.cornerRadius(8) .cornerRadius(8)
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() ProgressView()
} }
} }
}
private func makeLazyImage(url: URL?) -> some View { private func makeLazyImage(url: URL?) -> some View {
LazyImage(url: url) { state in LazyImage(url: url) { state in
@ -147,13 +176,49 @@ public struct StatusEditorView: View {
} }
private var accessoryView: some View { private var accessoryView: some View {
HStack { VStack(spacing: 0) {
Divider()
HStack(spacing: 16) {
PhotosPicker(selection: $viewModel.selectedMedias, PhotosPicker(selection: $viewModel.selectedMedias,
matching: .images) { matching: .images) {
Image(systemName: "photo.fill.on.rectangle.fill") Image(systemName: "photo.fill.on.rectangle.fill")
} }
Button {
viewModel.insertStatusText(text: " @")
} label: {
Image(systemName: "at")
}
Button {
viewModel.insertStatusText(text: " #")
} label: {
Image(systemName: "number")
}
Spacer() Spacer()
visibilityMenu
}
.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)
}
}
} }

View file

@ -25,21 +25,35 @@ public class StatusEditorViewModel: ObservableObject {
} }
} }
@Published var selectedRange: NSRange = .init(location: 0, length: 0)
@Published var isPosting: Bool = false @Published var isPosting: Bool = false
@Published var selectedMedias: [PhotosPickerItem] = [] { @Published var selectedMedias: [PhotosPickerItem] = [] {
didSet { didSet {
if selectedMedias.count > 4 {
selectedMedias = selectedMedias.prefix(4).map{ $0 }
}
inflateSelectedMedias() inflateSelectedMedias()
} }
} }
@Published var mediasImages: [ImageContainer] = [] @Published var mediasImages: [ImageContainer] = []
@Published var embededStatus: Status? @Published var embededStatus: Status?
@Published var visibility: Models.Visibility = .pub
private var uploadTask: Task<Void, Never>? private var uploadTask: Task<Void, Never>?
init(mode: Mode) { init(mode: Mode) {
self.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? { func postStatus() async -> Status? {
guard let client else { return nil } guard let client else { return nil }
do { do {
@ -50,12 +64,14 @@ public class StatusEditorViewModel: ObservableObject {
postStatus = try await client.post(endpoint: Statuses.postStatus(status: statusText.string, postStatus = try await client.post(endpoint: Statuses.postStatus(status: statusText.string,
inReplyTo: mode.replyToStatus?.id, inReplyTo: mode.replyToStatus?.id,
mediaIds: mediasImages.compactMap{ $0.mediaAttachement?.id }, mediaIds: mediasImages.compactMap{ $0.mediaAttachement?.id },
spoilerText: nil)) spoilerText: nil,
visibility: visibility))
case let .edit(status): case let .edit(status):
postStatus = try await client.put(endpoint: Statuses.editStatus(id: status.id, postStatus = try await client.put(endpoint: Statuses.editStatus(id: status.id,
status: statusText.string, status: statusText.string,
mediaIds: mediasImages.compactMap{ $0.mediaAttachement?.id }, mediaIds: mediasImages.compactMap{ $0.mediaAttachement?.id },
spoilerText: nil)) spoilerText: nil,
visibility: visibility))
} }
generator.notificationOccurred(.success) generator.notificationOccurred(.success)
isPosting = false isPosting = false
@ -71,12 +87,15 @@ public class StatusEditorViewModel: ObservableObject {
switch mode { switch mode {
case let .replyTo(status): case let .replyTo(status):
statusText = .init(string: "@\(status.reblog?.account.acct ?? status.account.acct) ") statusText = .init(string: "@\(status.reblog?.account.acct ?? status.account.acct) ")
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
case let .edit(status): case let .edit(status):
statusText = .init(status.content.asSafeAttributedString) statusText = .init(status.content.asSafeAttributedString)
selectedRange = .init(location: 0, length: 0)
case let .quote(status): case let .quote(status):
self.embededStatus = status self.embededStatus = status
if let url = status.reblog?.url ?? status.url { if let url = status.reblog?.url ?? status.url {
statusText = .init(string: "\n\nFrom: @\(status.reblog?.account.acct ?? status.account.acct)\n\(url)") statusText = .init(string: "\n\nFrom: @\(status.reblog?.account.acct ?? status.account.acct)\n\(url)")
selectedRange = .init(location: 0, length: 0)
} }
default: default:
break 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() { func inflateSelectedMedias() {
self.mediasImages = [] self.mediasImages = []
@ -155,15 +180,29 @@ public class StatusEditorViewModel: ObservableObject {
uploadTask?.cancel() uploadTask?.cancel()
let mediasCopy = mediasImages let mediasCopy = mediasImages
uploadTask = Task { uploadTask = Task {
for (index, media) in mediasCopy.enumerated() { 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 { do {
if !Task.isCancelled, if let data = originalContainer.image?.jpegData(compressionQuality: 0.90) {
let data = media.image?.jpegData(compressionQuality: 0.90), let uploadedMedia = try await uploadMedia(data: data)
let uploadedMedia = try await uploadMedia(data: data) { if let index = indexOf(container: newContainer) {
mediasImages[index] = .init(image: nil, mediaAttachement: uploadedMedia, error: nil) mediasImages[index] = .init(image: nil, mediaAttachement: uploadedMedia, error: nil)
} }
}
} catch { } catch {
mediasImages[index] = .init(image: nil, mediaAttachement: nil, error: error) 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? { private func uploadMedia(data: Data) async throws -> MediaAttachement? {
guard let client else { return nil } guard let client else { return nil }
do {
return try await client.mediaUpload(mimeType: "image/jpeg", data: data) return try await client.mediaUpload(mimeType: "image/jpeg", data: data)
} catch {
return nil
}
} }
} }

View file

@ -13,4 +13,17 @@ extension Visibility {
return "at.circle" 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"
}
}
} }