mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-29 19:51:08 +00:00
Better status editor
This commit is contained in:
parent
99dc57a023
commit
03e5a960d2
6 changed files with 156 additions and 38 deletions
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue