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",
"state" : {
"branch" : "main",
"revision" : "26b2930e82bb379a4abf0fcba408c0a09fbbb407"
"revision" : "8a52d16dc428780c8bcad6c0c9301a31704bcc1a"
}
}
],

View file

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

View file

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

View file

@ -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,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) {
Image(uiImage: image)
Image(uiImage: container.image!)
.resizable()
.blur(radius: 20 )
.aspectRatio(contentMode: .fill)
.frame(width: 150, height: 150)
.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()
}
}
}
private func makeLazyImage(url: URL?) -> some View {
LazyImage(url: url) { state in
@ -147,13 +176,49 @@ public struct StatusEditorView: View {
}
private var accessoryView: some View {
HStack {
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
}
.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 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() {
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 !Task.isCancelled,
let data = media.image?.jpegData(compressionQuality: 0.90),
let uploadedMedia = try await uploadMedia(data: data) {
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)
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
}
}
}

View file

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