Impressia/Vernissage/Views/ComposeView/ComposeView.swift

602 lines
22 KiB
Swift
Raw Normal View History

2023-01-06 18:16:08 +01:00
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import SwiftUI
2023-02-14 18:40:08 +01:00
import PhotosUI
2023-02-19 10:32:38 +01:00
import PixelfedKit
import UIKit
2023-01-06 18:16:08 +01:00
struct ComposeView: View {
2023-01-10 11:30:30 +01:00
@EnvironmentObject var applicationState: ApplicationState
2023-02-17 14:47:59 +01:00
@EnvironmentObject var routerPath: RouterPath
2023-02-03 15:16:30 +01:00
@EnvironmentObject var client: Client
2023-02-17 14:47:59 +01:00
2023-01-06 18:16:08 +01:00
@Environment(\.dismiss) private var dismiss
2023-01-10 11:30:30 +01:00
@StateObject private var textModel: TextModel
2023-02-27 14:03:22 +01:00
2023-02-27 16:20:07 +01:00
@State private var isKeyboardPresented = false
2023-02-18 11:47:49 +01:00
@State private var isSensitive = false
@State private var spoilerText = String.empty()
@State private var commentsDisabled = false
2023-02-19 09:41:35 +01:00
@State private var place: Place?
2023-02-18 11:47:49 +01:00
2023-02-19 10:16:01 +01:00
@State private var photosAreAttached = false
2023-02-17 12:21:09 +01:00
@State private var publishDisabled = true
2023-02-18 11:47:49 +01:00
@State private var interactiveDismissDisabled = false
2023-02-14 18:40:08 +01:00
2023-02-17 12:21:09 +01:00
@State private var photosAreUploading = false
2023-02-14 18:40:08 +01:00
@State private var photosPickerVisible = false
2023-02-19 09:41:35 +01:00
2023-02-14 18:40:08 +01:00
@State private var selectedItems: [PhotosPickerItem] = []
2023-02-17 14:47:59 +01:00
@State private var photosAttachment: [PhotoAttachment] = []
2023-02-19 09:41:35 +01:00
2023-02-21 21:06:03 +01:00
@State private var visibility = Pixelfed.Statuses.Visibility.pub
2023-03-13 13:53:36 +01:00
@State private var visibilityText: LocalizedStringKey = "compose.title.everyone"
2023-02-21 21:06:03 +01:00
@State private var visibilityImage = "globe.europe.africa"
2023-01-10 20:38:02 +01:00
@FocusState private var focusedField: FocusField?
2023-02-19 09:41:35 +01:00
enum FocusField: Hashable {
case unknown
case content
case spoilerText
}
@State private var showSheet: SheetType? = nil
enum SheetType: Identifiable {
case photoDetails(PhotoAttachment)
case placeSelector
public var id: String {
switch self {
case .photoDetails:
return "photoDetails"
case .placeSelector:
return "placeSelector"
}
}
}
2023-01-10 11:30:30 +01:00
2023-02-27 16:04:42 +01:00
private let statusViewModel: StatusModel?
private let keyboardFontImageSize = 20.0
private let keyboardFontTextSize = 16.0
2023-02-28 06:28:00 +01:00
private let autocompleteFontTextSize = 12.0
2023-02-27 14:03:22 +01:00
public init(statusViewModel: StatusModel? = nil) {
_textModel = StateObject(wrappedValue: .init())
2023-02-27 14:03:22 +01:00
self.statusViewModel = statusViewModel
}
2023-01-10 11:30:30 +01:00
2023-01-06 18:16:08 +01:00
var body: some View {
2023-02-17 14:47:59 +01:00
NavigationStack {
NavigationView {
2023-02-27 14:03:22 +01:00
ZStack(alignment: .bottom) {
2023-02-27 16:04:42 +01:00
self.composeBody()
2023-02-27 16:20:07 +01:00
if self.isKeyboardPresented {
VStack(alignment: .leading, spacing: 0) {
self.autocompleteToolbar()
self.keyboardToolbar()
}
.transition(.opacity)
2023-01-10 11:30:30 +01:00
}
2023-02-18 11:47:49 +01:00
}
2023-02-17 14:47:59 +01:00
.frame(alignment: .topLeading)
.toolbar {
ToolbarItem(placement: .primaryAction) {
2023-02-18 14:17:18 +01:00
ActionButton(showLoader: false) {
2023-02-18 13:43:22 +01:00
await self.publishStatus()
2023-02-17 14:47:59 +01:00
} label: {
2023-03-13 13:53:36 +01:00
Text("compose.title.publish", comment: "Publish")
2023-02-17 14:47:59 +01:00
}
.disabled(self.publishDisabled)
.buttonStyle(.borderedProminent)
}
ToolbarItem(placement: .cancellationAction) {
2023-03-13 13:53:36 +01:00
Button(NSLocalizedString("compose.title.cancel", comment: "Cancel"), role: .cancel) {
2023-01-10 20:38:02 +01:00
dismiss()
}
2023-01-06 18:16:08 +01:00
}
}
2023-02-27 16:04:42 +01:00
.onAppear {
self.textModel.client = self.client
2023-02-27 16:04:42 +01:00
}
.onChange(of: self.textModel.text) { newValue in
2023-02-27 14:03:22 +01:00
self.refreshScreenState()
}
2023-02-17 14:47:59 +01:00
.onChange(of: self.selectedItems) { selectedItem in
Task {
await self.loadPhotos()
2023-01-06 18:16:08 +01:00
}
}
2023-02-19 09:41:35 +01:00
.sheet(item: $showSheet, content: { sheetType in
switch sheetType {
case .photoDetails(let photoAttachment):
PhotoEditorView(photoAttachment: photoAttachment)
case .placeSelector:
PlaceSelectorView(place: $place)
}
2023-02-18 14:17:18 +01:00
})
2023-02-27 16:20:07 +01:00
.onReceive(keyboardPublisher) { value in
withAnimation {
self.isKeyboardPresented = value
}
}
.photosPicker(isPresented: $photosPickerVisible,
selection: $selectedItems,
maxSelectionCount: self.applicationState.statusMaxMediaAttachments,
matching: .images)
2023-03-13 13:53:36 +01:00
.navigationTitle("compose.navigationBar.title")
2023-02-21 08:36:14 +01:00
.navigationBarTitleDisplayMode(.inline)
2023-01-06 18:16:08 +01:00
}
2023-02-17 14:47:59 +01:00
.withAppRouteur()
.withOverlayDestinations(overlayDestinations: $routerPath.presentedOverlay)
2023-01-06 18:16:08 +01:00
}
2023-02-18 11:47:49 +01:00
.interactiveDismissDisabled(self.interactiveDismissDisabled)
}
2023-02-27 16:04:42 +01:00
@ViewBuilder
private func composeBody() -> some View {
ScrollView {
VStack (alignment: .leading){
// Red content warning.
self.contentWarningView()
// Information that comments are disabled.
self.commentsDisabledView()
// User avatar and name.
self.userAvatarView()
// Incofmation about status visibility.
self.visibilityComboView()
// Text area with new status.
self.statusTextView()
// Grid with images.
self.imagesGridView()
// Status when we are adding new comment.
self.statusModelView()
Spacer()
}
}
}
@ViewBuilder
private func imagesGridView() -> some View {
HStack(alignment: .center) {
LazyVGrid(columns: [GridItem(.adaptive(minimum:80))]) {
ForEach(self.photosAttachment, id: \.id) { photoAttachment in
ImageUploadView(photoAttachment: photoAttachment) {
self.showSheet = .photoDetails(photoAttachment)
} delete: {
self.photosAttachment = self.photosAttachment.filter({ item in
item != photoAttachment
})
self.selectedItems = self.selectedItems.filter({ item in
item != photoAttachment.photosPickerItem
})
self.refreshScreenState()
} upload: {
Task {
photoAttachment.error = nil
await self.upload(photoAttachment)
self.refreshScreenState()
}
}
}
}
}
.padding(8)
}
@ViewBuilder
private func statusModelView() -> some View {
if let status = self.statusViewModel {
HStack (alignment: .top) {
UserAvatar(accountAvatar: status.account.avatar, size: .comment)
VStack (alignment: .leading, spacing: 0) {
HStack (alignment: .top) {
Text(statusViewModel?.account.displayNameWithoutEmojis ?? "")
.foregroundColor(.mainTextColor)
.font(.footnote)
.fontWeight(.bold)
Spacer()
}
2023-03-26 09:40:05 +02:00
MarkdownFormattedText(status.content.asMarkdown)
.font(.subheadline)
2023-02-27 16:04:42 +01:00
.environment(\.openURL, OpenURLAction { url in .handled })
}
}
.padding(8)
.background(Color.selectedRowColor)
}
}
@ViewBuilder
private func statusTextView() -> some View {
TextView($textModel.text, getTextView: { textView in
self.textModel.textView = textView
2023-02-27 16:04:42 +01:00
})
.placeholder(self.placeholder())
.padding(.horizontal, 8)
.focused($focusedField, equals: .content)
.onFirstAppear {
self.focusedField = .content
}
}
@ViewBuilder
private func userAvatarView() -> some View {
if let accountData = applicationState.account {
HStack {
UsernameRow(
accountId: accountData.id,
accountAvatar: accountData.avatar,
accountDisplayName: accountData.displayName,
accountUsername: accountData.username)
Spacer()
}
.padding(.horizontal, 8)
}
}
@ViewBuilder
private func contentWarningView() -> some View {
if self.isSensitive {
2023-03-13 13:53:36 +01:00
TextField("compose.title.writeContentWarning", text: $spoilerText, axis: .vertical)
2023-02-27 16:04:42 +01:00
.padding(8)
.lineLimit(1...2)
.focused($focusedField, equals: .spoilerText)
.keyboardType(.default)
.background(Color.dangerColor.opacity(0.4))
}
}
@ViewBuilder
private func commentsDisabledView() -> some View {
if self.commentsDisabled {
HStack {
Spacer()
2023-03-13 13:53:36 +01:00
Text("compose.title.commentsWillBeDisabled")
2023-02-27 16:04:42 +01:00
.textCase(.uppercase)
.font(.caption2)
.foregroundColor(.dangerColor)
}
.padding(.horizontal, 8)
}
}
@ViewBuilder
private func visibilityComboView() -> some View {
HStack {
Menu {
Button {
self.visibility = .pub
2023-03-13 13:53:36 +01:00
self.visibilityText = "compose.title.everyone"
2023-02-27 16:04:42 +01:00
self.visibilityImage = "globe.europe.africa"
} label: {
2023-03-13 13:53:36 +01:00
Label("compose.title.everyone", systemImage: "globe.europe.africa")
2023-02-27 16:04:42 +01:00
}
Button {
self.visibility = .unlisted
2023-03-13 13:53:36 +01:00
self.visibilityText = "compose.title.unlisted"
2023-02-27 16:04:42 +01:00
self.visibilityImage = "lock.open"
} label: {
2023-03-13 13:53:36 +01:00
Label("compose.title.unlisted", systemImage: "lock.open")
2023-02-27 16:04:42 +01:00
}
Button {
self.visibility = .priv
2023-03-13 13:53:36 +01:00
self.visibilityText = "compose.title.followers"
2023-02-27 16:04:42 +01:00
self.visibilityImage = "lock"
} label: {
2023-03-13 13:53:36 +01:00
Label("compose.title.followers", systemImage: "lock")
2023-02-27 16:04:42 +01:00
}
} label: {
HStack {
Label(self.visibilityText, systemImage: self.visibilityImage)
Image(systemName: "chevron.down")
}
.padding(.vertical, 4)
.padding(.horizontal, 8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.accentColor, lineWidth: 1)
)
}
Spacer()
if let name = self.place?.name, let country = self.place?.country {
Group {
Image(systemName: "mappin.and.ellipse")
Text("\(name), \(country)")
}
.foregroundColor(.lightGrayColor)
.padding(.trailing, 8)
}
}
.font(.footnote)
.padding(.horizontal, 8)
}
@ViewBuilder
private func autocompleteToolbar() -> some View {
if !textModel.mentionsSuggestions.isEmpty || !textModel.tagsSuggestions.isEmpty {
2023-02-27 16:04:42 +01:00
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
if !textModel.mentionsSuggestions.isEmpty {
ForEach(textModel.mentionsSuggestions, id: \.id) { account in
2023-02-27 16:04:42 +01:00
Button {
textModel.selectMentionSuggestion(account: account)
2023-02-27 16:04:42 +01:00
} label: {
2023-02-28 06:28:00 +01:00
HStack (alignment: .center) {
UserAvatar(accountAvatar: account.avatar, size: .comment)
VStack (alignment: .leading) {
Text(account.displayNameWithoutEmojis)
.foregroundColor(.mainTextColor)
Text("@\(account.acct)")
.foregroundColor(.lightGrayColor)
}
.padding(.leading, 8)
}
.font(.system(size: self.autocompleteFontTextSize))
2023-02-27 16:04:42 +01:00
.padding(.trailing, 8)
}
2023-02-27 16:49:38 +01:00
Divider()
2023-02-27 16:04:42 +01:00
}
} else {
ForEach(textModel.tagsSuggestions, id: \.url) { tag in
2023-02-27 16:04:42 +01:00
Button {
textModel.selectHashtagSuggestion(tag: tag)
2023-02-27 16:04:42 +01:00
} label: {
Text("#\(tag.name)")
2023-02-28 06:28:00 +01:00
.font(.system(size: self.autocompleteFontTextSize))
2023-02-27 16:04:42 +01:00
.foregroundColor(self.applicationState.tintColor.color())
}
2023-02-27 16:49:38 +01:00
Divider()
2023-02-27 16:04:42 +01:00
}
}
}
.padding(.horizontal, 8)
}
.frame(height: 40)
.background(.ultraThinMaterial)
}
}
@ViewBuilder
2023-02-27 14:03:22 +01:00
private func keyboardToolbar() -> some View {
VStack(spacing: 0) {
Divider()
HStack(alignment: .center, spacing: 22) {
2023-02-18 11:47:49 +01:00
Button {
hideKeyboard()
self.focusedField = .unknown
self.photosPickerVisible = true
} label: {
2023-02-19 10:16:01 +01:00
Image(systemName: self.photosAreAttached ? "photo.fill.on.rectangle.fill" : "photo.on.rectangle")
2023-02-18 11:47:49 +01:00
}
2023-02-27 14:03:22 +01:00
2023-02-18 11:47:49 +01:00
Button {
withAnimation(.easeInOut) {
self.isSensitive.toggle()
2023-02-18 21:06:04 +01:00
if self.isSensitive {
self.focusedField = .spoilerText
} else {
self.focusedField = .content
}
2023-02-18 11:47:49 +01:00
}
} label: {
Image(systemName: self.isSensitive ? "exclamationmark.square.fill" : "exclamationmark.square")
}
Button {
withAnimation(.easeInOut) {
self.commentsDisabled.toggle()
}
} label: {
Image(systemName: self.commentsDisabled ? "person.2.slash" : "person.2.fill")
}
2023-02-27 14:03:22 +01:00
2023-02-19 09:41:35 +01:00
Button {
if self.place != nil {
withAnimation(.easeInOut) {
self.place = nil
}
} else {
self.showSheet = .placeSelector
}
} label: {
Image(systemName: self.place == nil ? "mappin.square" : "mappin.square.fill")
}
2023-02-18 11:47:49 +01:00
2023-02-23 08:09:02 +01:00
Button {
2023-03-04 14:28:59 +01:00
self.textModel.insertAtCursorPosition(content: "#")
2023-02-23 08:09:02 +01:00
} label: {
Image(systemName: "number")
}
2023-02-27 14:03:22 +01:00
2023-02-23 08:09:02 +01:00
Button {
2023-03-04 14:28:59 +01:00
self.textModel.insertAtCursorPosition(content: "@")
2023-02-23 08:09:02 +01:00
} label: {
Image(systemName: "at")
}
2023-02-18 11:47:49 +01:00
Spacer()
Text("\(self.applicationState.statusMaxCharacters - textModel.text.string.utf16.count)")
2023-02-27 14:03:22 +01:00
.foregroundColor(.lightGrayColor)
2023-02-27 16:04:42 +01:00
.font(.system(size: self.keyboardFontTextSize))
2023-02-18 11:47:49 +01:00
}
2023-02-27 14:03:22 +01:00
.padding(8)
2023-02-27 16:04:42 +01:00
.font(.system(size: self.keyboardFontImageSize))
2023-02-18 11:47:49 +01:00
}
2023-02-27 14:03:22 +01:00
.background(Color.keyboardToolbarColor)
2023-01-06 18:16:08 +01:00
}
2023-01-10 11:30:30 +01:00
2023-03-13 13:53:36 +01:00
private func placeholder() -> LocalizedStringKey {
self.statusViewModel == nil ? "compose.title.attachPhotoFull" : "compose.title.attachPhotoMini"
2023-02-23 08:09:02 +01:00
}
2023-02-17 12:21:09 +01:00
private func isPublishButtonDisabled() -> Bool {
// Publish always disabled when there is not status text.
if self.textModel.text.string.isEmpty {
2023-02-17 12:21:09 +01:00
return true
}
// When application is during uploading photos we cannot send new status.
if self.photosAreUploading == true {
return true
}
// When status is not a comment, then photo is required.
2023-02-17 14:47:59 +01:00
if self.statusViewModel == nil && self.photosAttachment.hasUploadedPhotos() == false {
2023-02-17 12:21:09 +01:00
return true
}
return false
}
2023-02-18 11:47:49 +01:00
private func isInteractiveDismissDisabled() -> Bool {
if self.textModel.text.string.isEmpty == false {
2023-02-18 11:47:49 +01:00
return true
}
if self.photosAreUploading == true {
return true
}
if self.photosAttachment.hasUploadedPhotos() == true {
return true
}
return false
}
2023-02-17 12:21:09 +01:00
private func loadPhotos() async {
do {
self.photosAreUploading = true
self.publishDisabled = self.isPublishButtonDisabled()
2023-02-18 11:47:49 +01:00
self.interactiveDismissDisabled = self.isInteractiveDismissDisabled()
2023-02-17 12:21:09 +01:00
2023-02-22 20:49:08 +01:00
// We have to create list with existing photos.
var temporaryPhotosAttachment: [PhotoAttachment] = []
2023-02-17 12:21:09 +01:00
for item in self.selectedItems {
2023-02-22 20:49:08 +01:00
if let photoAttachment = self.photosAttachment.first(where: { $0.photosPickerItem == item }) {
temporaryPhotosAttachment.append(photoAttachment)
continue
}
temporaryPhotosAttachment.append(PhotoAttachment(photosPickerItem: item))
}
// We can show new list on the screen.
self.photosAttachment = temporaryPhotosAttachment
// Now we have to get from photos images as JPEG.
for item in self.photosAttachment.filter({ $0.photoData == nil }) {
2023-02-27 14:03:22 +01:00
if let data = try await item.photosPickerItem.loadTransferable(type: Data.self) {
item.photoData = data
2023-02-17 12:21:09 +01:00
}
}
2023-02-22 20:49:08 +01:00
// Open again the keyboard.
2023-02-17 12:21:09 +01:00
self.focusedField = .content
2023-02-22 20:49:08 +01:00
// Upload images which hasn't been uploaded yet.
2023-02-17 12:21:09 +01:00
await self.upload()
2023-02-22 20:49:08 +01:00
// Change state of the screen.
2023-02-17 12:21:09 +01:00
self.photosAreUploading = false
2023-02-22 21:17:11 +01:00
self.refreshScreenState()
2023-02-17 12:21:09 +01:00
} catch {
2023-03-13 13:53:36 +01:00
ErrorService.shared.handle(error, message: "compose.error.loadingPhotosFailed", showToastr: true)
2023-02-17 12:21:09 +01:00
}
}
2023-02-22 21:17:11 +01:00
private func refreshScreenState() {
self.photosAreAttached = self.photosAttachment.hasUploadedPhotos()
self.publishDisabled = self.isPublishButtonDisabled()
self.interactiveDismissDisabled = self.isInteractiveDismissDisabled()
}
2023-02-17 12:21:09 +01:00
private func upload() async {
2023-02-21 21:54:10 +01:00
for photoAttachment in self.photosAttachment {
await self.upload(photoAttachment)
}
}
private func upload(_ photoAttachment: PhotoAttachment) async {
do {
// We have to have binary data and image shouldn't be uploaded yet.
2023-02-22 20:49:08 +01:00
guard let photoData = photoAttachment.photoData, photoAttachment.uploadedAttachment == nil else {
return
}
guard let image = UIImage(data: photoData) else {
return
}
2023-03-24 18:44:16 +01:00
guard let data = image.getJpegData() else {
return
}
2023-02-21 21:54:10 +01:00
let fileIndex = String.randomString(length: 8)
if let mediaAttachment = try await self.client.media?.upload(data: data,
2023-02-21 21:54:10 +01:00
fileName: "file-\(fileIndex).jpg",
mimeType: "image/jpeg") {
photoAttachment.uploadedAttachment = mediaAttachment
2023-02-17 12:21:09 +01:00
}
2023-02-21 21:54:10 +01:00
} catch {
photoAttachment.error = error
2023-03-13 13:53:36 +01:00
ErrorService.shared.handle(error, message: "compose.error.postingPhotoFailed", showToastr: true)
2023-02-17 12:21:09 +01:00
}
}
2023-02-28 20:57:04 +01:00
2023-01-10 20:38:02 +01:00
private func publishStatus() async {
do {
2023-02-17 14:47:59 +01:00
let status = self.createStatus()
if let newStatus = try await self.client.statuses?.new(status: status) {
2023-03-13 13:53:36 +01:00
ToastrService.shared.showSuccess("compose.title.statusPublished", imageSystemName: "message.fill")
2023-02-17 12:21:09 +01:00
let statusModel = StatusModel(status: newStatus)
let commentModel = CommentModel(status: statusModel, showDivider: false)
self.applicationState.newComment = commentModel
2023-02-18 13:43:22 +01:00
dismiss()
}
2023-01-10 20:38:02 +01:00
} catch {
2023-03-13 13:53:36 +01:00
ErrorService.shared.handle(error, message: "compose.error.postingStatusFailed", showToastr: true)
2023-01-10 20:38:02 +01:00
}
}
2023-02-17 14:47:59 +01:00
2023-02-19 10:43:37 +01:00
private func createStatus() -> Pixelfed.Statuses.Components {
return Pixelfed.Statuses.Components(inReplyToId: self.statusViewModel?.id,
text: self.textModel.text.string,
2023-02-18 11:47:49 +01:00
spoilerText: self.isSensitive ? self.spoilerText : String.empty(),
mediaIds: self.photosAttachment.getUploadedPhotoIds(),
visibility: self.visibility,
sensitive: self.isSensitive,
2023-02-19 09:41:35 +01:00
placeId: self.place?.id,
2023-02-18 11:47:49 +01:00
commentsDisabled: self.commentsDisabled)
2023-02-17 14:47:59 +01:00
}
2023-01-06 18:16:08 +01:00
}