diff --git a/Vernissage/Views/ComposeView.swift b/Vernissage/Views/ComposeView.swift index 52944f4..871e454 100644 --- a/Vernissage/Views/ComposeView.swift +++ b/Vernissage/Views/ComposeView.swift @@ -14,542 +14,28 @@ import EnvironmentKit import WidgetsKit struct ComposeView: View { - @EnvironmentObject var applicationState: ApplicationState @EnvironmentObject var routerPath: RouterPath @EnvironmentObject var client: Client @Environment(\.dismiss) private var dismiss - @StateObject private var textModel: TextModel - - @State private var isKeyboardPresented = false - @State private var isSensitive = false - @State private var spoilerText = String.empty() - @State private var commentsDisabled = false - @State private var place: Place? - - @State private var photosAreAttached = false - @State private var publishDisabled = true - @State private var interactiveDismissDisabled = false - - @State private var photosAreUploading = false - @State private var photosPickerVisible = false - - @State private var selectedItems: [PhotosPickerItem] = [] - @State private var photosAttachment: [PhotoAttachment] = [] - - @State private var visibility = Pixelfed.Statuses.Visibility.pub - @State private var visibilityText: LocalizedStringKey = "compose.title.everyone" - @State private var visibilityImage = "globe.europe.africa" - - @FocusState private var focusedField: FocusField? - enum FocusField: Hashable { - case unknown - case content - case spoilerText - } - - @State private var showSheet: SheetType? - enum SheetType: Identifiable { - case photoDetails(PhotoAttachment) - case placeSelector - - public var id: String { - switch self { - case .photoDetails: - return "photoDetails" - case .placeSelector: - return "placeSelector" - } - } - } - private let statusViewModel: StatusModel? - private let keyboardFontImageSize = 20.0 - private let keyboardFontTextSize = 16.0 - private let autocompleteFontTextSize = 12.0 public init(statusViewModel: StatusModel? = nil) { - _textModel = StateObject(wrappedValue: .init()) self.statusViewModel = statusViewModel } var body: some View { - NavigationStack { - NavigationView { - ZStack(alignment: .bottom) { - self.composeBody() - - if self.isKeyboardPresented { - VStack(alignment: .leading, spacing: 0) { - self.autocompleteToolbar() - self.keyboardToolbar() - } - .transition(.opacity) - } - } - .frame(alignment: .topLeading) - .toolbar { - ToolbarItem(placement: .primaryAction) { - ActionButton(showLoader: false) { - await self.publishStatus() - } label: { - Text("compose.title.publish", comment: "Publish") - } - .disabled(self.publishDisabled) - .buttonStyle(.borderedProminent) - } - - ToolbarItem(placement: .cancellationAction) { - Button(NSLocalizedString("compose.title.cancel", comment: "Cancel"), role: .cancel) { - self.close() - } - } - } - .onAppear { - self.textModel.client = self.client - } - .onChange(of: self.textModel.text) { _ in - self.refreshScreenState() - } - .onChange(of: self.selectedItems) { _ in - Task { - await self.loadPhotos() - } - } - .sheet(item: $showSheet, content: { sheetType in - switch sheetType { - case .photoDetails(let photoAttachment): - PhotoEditorView(photoAttachment: photoAttachment) - case .placeSelector: - PlaceSelectorView(place: $place) - } - }) - .onReceive(keyboardPublisher) { value in - withAnimation { - self.isKeyboardPresented = value - } - } - .photosPicker(isPresented: $photosPickerVisible, - selection: $selectedItems, - maxSelectionCount: self.applicationState.statusMaxMediaAttachments, - matching: .images) - .navigationTitle("compose.navigationBar.title") - .navigationBarTitleDisplayMode(.inline) + NavigationView { + BaseComposeView(statusViewModel: self.statusViewModel) { + dismiss() + } onUpload: { photoAttachment in + await self.upload(photoAttachment) } - .withAppRouteur() - .withOverlayDestinations(overlayDestinations: $routerPath.presentedOverlay) - } - .interactiveDismissDisabled(self.interactiveDismissDisabled) - } - - @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() - - // Information 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() - } - - MarkdownFormattedText(status.content.asMarkdown) - .font(.subheadline) - .environment(\.openURL, OpenURLAction { _ in .handled }) - } - } - .padding(8) - .background(Color.selectedRowColor) - } - } - - @ViewBuilder - private func statusTextView() -> some View { - TextView($textModel.text, getTextView: { textView in - self.textModel.textView = textView - }) - .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 { - TextField("compose.title.writeContentWarning", text: $spoilerText, axis: .vertical) - .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() - Text("compose.title.commentsWillBeDisabled") - .textCase(.uppercase) - .font(.caption2) - .foregroundColor(.dangerColor) - } - .padding(.horizontal, 8) - } - } - - @ViewBuilder - private func visibilityComboView() -> some View { - HStack { - Menu { - Button { - self.visibility = .pub - self.visibilityText = "compose.title.everyone" - self.visibilityImage = "globe.europe.africa" - } label: { - Label("compose.title.everyone", systemImage: "globe.europe.africa") - } - - Button { - self.visibility = .unlisted - self.visibilityText = "compose.title.unlisted" - self.visibilityImage = "lock.open" - } label: { - Label("compose.title.unlisted", systemImage: "lock.open") - } - - Button { - self.visibility = .priv - self.visibilityText = "compose.title.followers" - self.visibilityImage = "lock" - } label: { - Label("compose.title.followers", systemImage: "lock") - } - } 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 { - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - if !textModel.mentionsSuggestions.isEmpty { - ForEach(textModel.mentionsSuggestions, id: \.id) { account in - Button { - textModel.selectMentionSuggestion(account: account) - } label: { - 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)) - .padding(.trailing, 8) - } - Divider() - } - } else { - ForEach(textModel.tagsSuggestions, id: \.url) { tag in - Button { - textModel.selectHashtagSuggestion(tag: tag) - } label: { - Text("#\(tag.name)") - .font(.system(size: self.autocompleteFontTextSize)) - .foregroundColor(self.applicationState.tintColor.color()) - } - Divider() - } - } - } - .padding(.horizontal, 8) - } - .frame(height: 40) - .background(.ultraThinMaterial) - } - } - - @ViewBuilder - private func keyboardToolbar() -> some View { - VStack(spacing: 0) { - Divider() - HStack { - ScrollView(.horizontal) { - HStack(alignment: .center, spacing: 20) { - Button { - hideKeyboard() - self.focusedField = .unknown - self.photosPickerVisible = true - } label: { - Image(systemName: self.photosAreAttached ? "photo.fill.on.rectangle.fill" : "photo.on.rectangle") - } - - Button { - withAnimation(.easeInOut) { - self.isSensitive.toggle() - - if self.isSensitive { - self.focusedField = .spoilerText - } else { - self.focusedField = .content - } - } - } 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") - } - - 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") - } - - Button { - self.textModel.insertAtCursorPosition(content: "#") - } label: { - Image(systemName: "number") - } - - Button { - self.textModel.insertAtCursorPosition(content: "@") - } label: { - Image(systemName: "at") - } - } - } - - Spacer() - - Text("\(self.applicationState.statusMaxCharacters - textModel.text.string.utf16.count)") - .foregroundColor(.lightGrayColor) - .font(.system(size: self.keyboardFontTextSize)) - } - .padding(8) - .font(.system(size: self.keyboardFontImageSize)) - } - .background(Color.keyboardToolbarColor) - } - - private func placeholder() -> LocalizedStringKey { - self.statusViewModel == nil ? "compose.title.attachPhotoFull" : "compose.title.attachPhotoMini" - } - - private func isPublishButtonDisabled() -> Bool { - // Publish always disabled when there is not status text. - if self.textModel.text.string.isEmpty { - 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. - if self.statusViewModel == nil && self.photosAttachment.hasUploadedPhotos() == false { - return true - } - - return false - } - - private func isInteractiveDismissDisabled() -> Bool { - if self.textModel.text.string.isEmpty == false { - return true - } - - if self.photosAreUploading == true { - return true - } - - if self.photosAttachment.hasUploadedPhotos() == true { - return true - } - - return false - } - - private func loadPhotos() async { - do { - self.photosAreUploading = true - self.publishDisabled = self.isPublishButtonDisabled() - self.interactiveDismissDisabled = self.isInteractiveDismissDisabled() - - // We have to create list with existing photos. - var temporaryPhotosAttachment: [PhotoAttachment] = [] - for item in self.selectedItems { - 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 }) { - try await item.loadImage() - } - - // Open again the keyboard. - self.focusedField = .content - - // Upload images which hasn't been uploaded yet. - await self.upload() - - // Change state of the screen. - self.photosAreUploading = false - self.refreshScreenState() - } catch { - self.photosAreUploading = false - self.refreshScreenState() - - ErrorService.shared.handle(error, message: "compose.error.loadingPhotosFailed", showToastr: true) - } - } - - private func refreshScreenState() { - self.photosAreAttached = self.photosAttachment.hasUploadedPhotos() - self.publishDisabled = self.isPublishButtonDisabled() - self.interactiveDismissDisabled = self.isInteractiveDismissDisabled() - } - - private func upload() async { - for photoAttachment in self.photosAttachment { - await self.upload(photoAttachment) + .navigationTitle("compose.navigationBar.title") + .navigationBarTitleDisplayMode(.inline) } + .withOverlayDestinations(overlayDestinations: $routerPath.presentedOverlay) } private func upload(_ photoAttachment: PhotoAttachment) async { @@ -578,44 +64,8 @@ struct ComposeView: View { photoAttachment.uploadedAttachment = mediaAttachment } } catch { - photoAttachment.error = error + photoAttachment.uploadError = error ErrorService.shared.handle(error, message: "compose.error.postingPhotoFailed", showToastr: true) } } - - private func close() { - // Clean tmp folder from file transferred from Photos. - self.photosAttachment.removeTmpFiles() - - // Close the view. - dismiss() - } - - private func publishStatus() async { - do { - let status = self.createStatus() - if let newStatus = try await self.client.statuses?.new(status: status) { - ToastrService.shared.showSuccess("compose.title.statusPublished", imageSystemName: "message.fill") - - let statusModel = StatusModel(status: newStatus) - let commentModel = CommentModel(status: statusModel, showDivider: false) - self.applicationState.newComment = commentModel - - self.close() - } - } catch { - ErrorService.shared.handle(error, message: "compose.error.postingStatusFailed", showToastr: true) - } - } - - private func createStatus() -> Pixelfed.Statuses.Components { - return Pixelfed.Statuses.Components(inReplyToId: self.statusViewModel?.id, - text: self.textModel.text.string, - spoilerText: self.isSensitive ? self.spoilerText : String.empty(), - mediaIds: self.photosAttachment.getUploadedPhotoIds(), - visibility: self.visibility, - sensitive: self.isSensitive, - placeId: self.place?.id, - commentsDisabled: self.commentsDisabled) - } } diff --git a/VernissageShare/ComposeView.swift b/VernissageShare/ComposeView.swift index 042b176..751b654 100644 --- a/VernissageShare/ComposeView.swift +++ b/VernissageShare/ComposeView.swift @@ -14,524 +14,24 @@ import WidgetsKit import ServicesKit struct ComposeView: View { - @EnvironmentObject var applicationState: ApplicationState @EnvironmentObject var client: Client - @StateObject private var textModel: TextModel - - @State private var isKeyboardPresented = false - @State private var isSensitive = false - @State private var spoilerText = "" - @State private var commentsDisabled = false - @State private var place: Place? - - @State private var photosAreAttached = false - @State private var publishDisabled = true - @State private var interactiveDismissDisabled = false - - @State private var photosAreUploading = false - @State private var photosPickerVisible = false - - @State private var attachments: [NSItemProvider] = [] - @State private var selectedItems: [PhotosPickerItem] = [] - @State private var photosAttachment: [PhotoAttachment] = [] - - @State private var visibility = Pixelfed.Statuses.Visibility.pub - @State private var visibilityText: LocalizedStringKey = "compose.title.everyone" - @State private var visibilityImage = "globe.europe.africa" - - @FocusState private var focusedField: FocusField? - enum FocusField: Hashable { - case unknown - case content - case spoilerText - } - - @State private var showSheet: SheetType? - enum SheetType: Identifiable { - case photoDetails(PhotoAttachment) - case placeSelector - - public var id: String { - switch self { - case .photoDetails: - return "photoDetails" - case .placeSelector: - return "placeSelector" - } - } - } - - private let keyboardFontImageSize = 20.0 - private let keyboardFontTextSize = 16.0 - private let autocompleteFontTextSize = 12.0 + private let attachments: [NSItemProvider] public init(attachments: [NSItemProvider]) { self.attachments = attachments - _textModel = StateObject(wrappedValue: .init()) } var body: some View { NavigationView { - ZStack(alignment: .bottom) { - self.composeBody() - - if self.isKeyboardPresented { - VStack(alignment: .leading, spacing: 0) { - self.autocompleteToolbar() - self.keyboardToolbar() - } - .transition(.opacity) - } + BaseComposeView(attachments: self.attachments) { + NotificationCenter.default.post(name: NotificationsName.shareSheetClose, object: nil) + } onUpload: { photoAttachment in + await self.upload(photoAttachment) } - .frame(alignment: .topLeading) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - Task { - await self.publishStatus() - } - } label: { - Text("compose.title.publish", comment: "Publish") - } - .disabled(self.publishDisabled) - .buttonStyle(.borderedProminent) - } - - ToolbarItem(placement: .cancellationAction) { - Button(NSLocalizedString("compose.title.cancel", comment: "Cancel"), role: .cancel) { - self.close() - } - } - } - .onAppear { - self.textModel.client = self.client - Task { - await self.loadPhotos() - } - } - .onChange(of: self.textModel.text) { _ in - self.refreshScreenState() - } - .onChange(of: self.selectedItems) { _ in - Task { - await self.loadPhotos() - } - } - .sheet(item: $showSheet, content: { sheetType in - switch sheetType { - case .photoDetails(let photoAttachment): - PhotoEditorView(photoAttachment: photoAttachment) - case .placeSelector: - PlaceSelectorView(place: $place) - } - }) - .onReceive(keyboardPublisher) { value in - withAnimation { - self.isKeyboardPresented = value - } - } - .photosPicker(isPresented: $photosPickerVisible, - selection: $selectedItems, - maxSelectionCount: 4, - matching: .images) .navigationTitle("compose.navigationBar.title") .navigationBarTitleDisplayMode(.inline) } - .interactiveDismissDisabled(self.interactiveDismissDisabled) - } - - @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() - - // Information about status visibility. - self.visibilityComboView() - - // Text area with new status. - self.statusTextView() - - // Grid with images. - self.imagesGridView() - - 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.attachments = self.attachments.filter({ item in - item != photoAttachment.nsItemProvider - }) - - self.refreshScreenState() - } upload: { - Task { - photoAttachment.error = nil - await self.upload(photoAttachment) - self.refreshScreenState() - } - } - } - } - } - .padding(8) - } - - @ViewBuilder - private func statusTextView() -> some View { - TextView($textModel.text, getTextView: { textView in - self.textModel.textView = textView - }) - .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 { - TextField("compose.title.writeContentWarning", text: $spoilerText, axis: .vertical) - .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() - Text("compose.title.commentsWillBeDisabled") - .textCase(.uppercase) - .font(.caption2) - .foregroundColor(.dangerColor) - } - .padding(.horizontal, 8) - } - } - - @ViewBuilder - private func visibilityComboView() -> some View { - HStack { - Menu { - Button { - self.visibility = .pub - self.visibilityText = "compose.title.everyone" - self.visibilityImage = "globe.europe.africa" - } label: { - Label("compose.title.everyone", systemImage: "globe.europe.africa") - } - - Button { - self.visibility = .unlisted - self.visibilityText = "compose.title.unlisted" - self.visibilityImage = "lock.open" - } label: { - Label("compose.title.unlisted", systemImage: "lock.open") - } - - Button { - self.visibility = .priv - self.visibilityText = "compose.title.followers" - self.visibilityImage = "lock" - } label: { - Label("compose.title.followers", systemImage: "lock") - } - } 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 { - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - if !textModel.mentionsSuggestions.isEmpty { - ForEach(textModel.mentionsSuggestions, id: \.id) { account in - Button { - textModel.selectMentionSuggestion(account: account) - } label: { - 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)) - .padding(.trailing, 8) - } - Divider() - } - } else { - ForEach(textModel.tagsSuggestions, id: \.url) { tag in - Button { - textModel.selectHashtagSuggestion(tag: tag) - } label: { - Text("#\(tag.name)") - .font(.system(size: self.autocompleteFontTextSize)) - .foregroundColor(self.applicationState.tintColor.color()) - } - Divider() - } - } - } - .padding(.horizontal, 8) - } - .frame(height: 40) - .background(.ultraThinMaterial) - } - } - - @ViewBuilder - private func keyboardToolbar() -> some View { - VStack(spacing: 0) { - Divider() - HStack { - ScrollView(.horizontal) { - HStack(alignment: .center, spacing: 20) { - Button { - hideKeyboard() - self.focusedField = .unknown - self.photosPickerVisible = true - } label: { - Image(systemName: self.photosAreAttached ? "photo.fill.on.rectangle.fill" : "photo.on.rectangle") - } - - Button { - withAnimation(.easeInOut) { - self.isSensitive.toggle() - - if self.isSensitive { - self.focusedField = .spoilerText - } else { - self.focusedField = .content - } - } - } 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") - } - - 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") - } - - Button { - self.textModel.insertAtCursorPosition(content: "#") - } label: { - Image(systemName: "number") - } - - Button { - self.textModel.insertAtCursorPosition(content: "@") - } label: { - Image(systemName: "at") - } - } - } - - Spacer() - - Text("\(self.applicationState.statusMaxCharacters - textModel.text.string.utf16.count)") - .foregroundColor(.lightGrayColor) - .font(.system(size: self.keyboardFontTextSize)) - } - .padding(8) - .font(.system(size: self.keyboardFontImageSize)) - } - .background(Color.keyboardToolbarColor) - } - - private func placeholder() -> LocalizedStringKey { - "compose.title.attachPhotoFull" - } - - private func isPublishButtonDisabled() -> Bool { - // Publish always disabled when there is not status text. - if self.textModel.text.string.isEmpty { - 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. - if self.photosAttachment.hasUploadedPhotos() == false { - return true - } - - return false - } - - private func isInteractiveDismissDisabled() -> Bool { - if self.textModel.text.string.isEmpty == false { - return true - } - - if self.photosAreUploading == true { - return true - } - - if self.photosAttachment.hasUploadedPhotos() == true { - return true - } - - return false - } - - private func loadPhotos() async { - self.photosAreUploading = true - self.publishDisabled = self.isPublishButtonDisabled() - self.interactiveDismissDisabled = self.isInteractiveDismissDisabled() - - // We have to create list with existing photos. - var temporaryPhotosAttachment: [PhotoAttachment] = [] - - // Add to collection photos selected on photo picker. - for item in self.selectedItems { - if let photoAttachment = self.photosAttachment.first(where: { $0.photosPickerItem == item }) { - temporaryPhotosAttachment.append(photoAttachment) - continue - } - - temporaryPhotosAttachment.append(PhotoAttachment(photosPickerItem: item)) - } - - // Add to collection photos from share sheet. - for item in self.attachments { - if let photoAttachment = self.photosAttachment.first(where: { $0.nsItemProvider == item }) { - temporaryPhotosAttachment.append(photoAttachment) - continue - } - - temporaryPhotosAttachment.append(PhotoAttachment(nsItemProvider: 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 }) { - do { - try await item.loadImage() - } catch { - ErrorService.shared.handle(error, message: "Cannot load image from external library.") - } - } - - // Open again the keyboard. - self.focusedField = .content - - // Upload images which hasn't been uploaded yet. - await self.upload() - - // Change state of the screen. - self.photosAreUploading = false - self.refreshScreenState() - } - - private func refreshScreenState() { - self.photosAreAttached = self.photosAttachment.hasUploadedPhotos() - self.publishDisabled = self.isPublishButtonDisabled() - self.interactiveDismissDisabled = self.isInteractiveDismissDisabled() - } - - private func upload() async { - for photoAttachment in self.photosAttachment { - await self.upload(photoAttachment) - } } private func upload(_ photoAttachment: PhotoAttachment) async { @@ -559,38 +59,8 @@ struct ComposeView: View { photoAttachment.uploadedAttachment = mediaAttachment } } catch { - photoAttachment.error = error + photoAttachment.uploadError = error ErrorService.shared.handle(error, message: "compose.error.postingPhotoFailed", showToastr: true) } } - - private func close() { - // Clean tmp folder from file transferred from Photos. - self.photosAttachment.removeTmpFiles() - - // Close the view. - NotificationCenter.default.post(name: NotificationsName.shareSheetClose, object: nil) - } - - private func publishStatus() async { - do { - let status = self.createStatus() - if try await self.client.statuses?.new(status: status) != nil { - self.close() - } - } catch { - ErrorService.shared.handle(error, message: "compose.error.postingStatusFailed", showToastr: true) - } - } - - private func createStatus() -> Pixelfed.Statuses.Components { - return Pixelfed.Statuses.Components(inReplyToId: nil, - text: self.textModel.text.string, - spoilerText: self.isSensitive ? self.spoilerText : String.empty(), - mediaIds: self.photosAttachment.getUploadedPhotoIds(), - visibility: self.visibility, - sensitive: self.isSensitive, - placeId: self.place?.id, - commentsDisabled: self.commentsDisabled) - } } diff --git a/WidgetsKit/Sources/WidgetsKit/Models/PhotoAttachment.swift b/WidgetsKit/Sources/WidgetsKit/Models/PhotoAttachment.swift index 9ff640f..9c16a00 100644 --- a/WidgetsKit/Sources/WidgetsKit/Models/PhotoAttachment.swift +++ b/WidgetsKit/Sources/WidgetsKit/Models/PhotoAttachment.swift @@ -29,7 +29,10 @@ public class PhotoAttachment: ObservableObject, Identifiable, Equatable, Hashabl @Published public var uploadedAttachment: UploadedAttachment? /// Error from Pixelfed. - @Published public var error: Error? + @Published public var uploadError: Error? + + /// Error from device. + @Published public var loadError: Error? public init(photosPickerItem: PhotosPickerItem? = nil, nsItemProvider: NSItemProvider? = nil) { self.id = UUID().uuidString diff --git a/WidgetsKit/Sources/WidgetsKit/Views/BaseComposeView.swift b/WidgetsKit/Sources/WidgetsKit/Views/BaseComposeView.swift new file mode 100644 index 0000000..36dcf9d --- /dev/null +++ b/WidgetsKit/Sources/WidgetsKit/Views/BaseComposeView.swift @@ -0,0 +1,607 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the Apache License 2.0. +// + +import Foundation +import SwiftUI +import PhotosUI +import PixelfedKit +import ClientKit +import EnvironmentKit +import ServicesKit + +public struct BaseComposeView: View { + @EnvironmentObject var applicationState: ApplicationState + @EnvironmentObject var client: Client + + @StateObject private var textModel: TextModel + + @State private var isKeyboardPresented = false + @State private var isSensitive = false + @State private var spoilerText = "" + @State private var commentsDisabled = false + @State private var place: Place? + + @State private var photosAreAttached = false + @State private var publishDisabled = true + @State private var interactiveDismissDisabled = false + + @State private var photosAreUploading = false + @State private var photosPickerVisible = false + + @State private var attachments: [NSItemProvider] + @State private var selectedItems: [PhotosPickerItem] = [] + @State private var photosAttachment: [PhotoAttachment] = [] + + @State private var visibility = Pixelfed.Statuses.Visibility.pub + @State private var visibilityText: LocalizedStringKey = "compose.title.everyone" + @State private var visibilityImage = "globe.europe.africa" + + @FocusState private var focusedField: FocusField? + enum FocusField: Hashable { + case unknown + case content + case spoilerText + } + + @State private var showSheet: SheetType? + enum SheetType: Identifiable { + case photoDetails(PhotoAttachment) + case placeSelector + + public var id: String { + switch self { + case .photoDetails: + return "photoDetails" + case .placeSelector: + return "placeSelector" + } + } + } + + private let statusViewModel: StatusModel? + private let keyboardFontImageSize = 20.0 + private let keyboardFontTextSize = 16.0 + private let autocompleteFontTextSize = 12.0 + + private let onClose: () -> Void + private let onUpload: (PhotoAttachment) async -> Void + + public init(statusViewModel: StatusModel? = nil, + attachments: [NSItemProvider] = [], + onClose: @escaping () -> Void, + onUpload: @escaping (PhotoAttachment) async -> Void) { + self.statusViewModel = statusViewModel + self.attachments = attachments + self.onClose = onClose + self.onUpload = onUpload + + _textModel = StateObject(wrappedValue: .init()) + } + + public var body: some View { + ZStack(alignment: .bottom) { + self.composeBody() + + if self.isKeyboardPresented { + VStack(alignment: .leading, spacing: 0) { + self.autocompleteToolbar() + self.keyboardToolbar() + } + .transition(.opacity) + } + } + .frame(alignment: .topLeading) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + Task { + await self.publishStatus() + } + } label: { + Text("compose.title.publish", comment: "Publish") + } + .disabled(self.publishDisabled) + .buttonStyle(.borderedProminent) + } + + ToolbarItem(placement: .cancellationAction) { + Button(NSLocalizedString("compose.title.cancel", comment: "Cancel"), role: .cancel) { + self.close() + } + } + } + .onAppear { + self.textModel.client = self.client + Task { + await self.loadPhotos() + } + } + .onChange(of: self.textModel.text) { _ in + self.refreshScreenState() + } + .onChange(of: self.selectedItems) { _ in + Task { + await self.loadPhotos() + } + } + .sheet(item: $showSheet, content: { sheetType in + switch sheetType { + case .photoDetails(let photoAttachment): + PhotoEditorView(photoAttachment: photoAttachment) + case .placeSelector: + PlaceSelectorView(place: $place) + } + }) + .onReceive(keyboardPublisher) { value in + withAnimation { + self.isKeyboardPresented = value + } + } + .photosPicker(isPresented: $photosPickerVisible, + selection: $selectedItems, + maxSelectionCount: 4, + matching: .images) + .interactiveDismissDisabled(self.interactiveDismissDisabled) + } + + @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() + + // Information 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.attachments = self.attachments.filter({ item in + item != photoAttachment.nsItemProvider + }) + + self.refreshScreenState() + } upload: { + Task { + photoAttachment.uploadError = nil + await self.onUpload(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() + } + + MarkdownFormattedText(status.content.asMarkdown) + .font(.subheadline) + .environment(\.openURL, OpenURLAction { _ in .handled }) + } + } + .padding(8) + .background(Color.selectedRowColor) + } + } + + @ViewBuilder + private func statusTextView() -> some View { + TextView($textModel.text, getTextView: { textView in + self.textModel.textView = textView + }) + .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 { + TextField("compose.title.writeContentWarning", text: $spoilerText, axis: .vertical) + .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() + Text("compose.title.commentsWillBeDisabled") + .textCase(.uppercase) + .font(.caption2) + .foregroundColor(.dangerColor) + } + .padding(.horizontal, 8) + } + } + + @ViewBuilder + private func visibilityComboView() -> some View { + HStack { + Menu { + Button { + self.visibility = .pub + self.visibilityText = "compose.title.everyone" + self.visibilityImage = "globe.europe.africa" + } label: { + Label("compose.title.everyone", systemImage: "globe.europe.africa") + } + + Button { + self.visibility = .unlisted + self.visibilityText = "compose.title.unlisted" + self.visibilityImage = "lock.open" + } label: { + Label("compose.title.unlisted", systemImage: "lock.open") + } + + Button { + self.visibility = .priv + self.visibilityText = "compose.title.followers" + self.visibilityImage = "lock" + } label: { + Label("compose.title.followers", systemImage: "lock") + } + } 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 { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + if !textModel.mentionsSuggestions.isEmpty { + ForEach(textModel.mentionsSuggestions, id: \.id) { account in + Button { + textModel.selectMentionSuggestion(account: account) + } label: { + 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)) + .padding(.trailing, 8) + } + Divider() + } + } else { + ForEach(textModel.tagsSuggestions, id: \.url) { tag in + Button { + textModel.selectHashtagSuggestion(tag: tag) + } label: { + Text("#\(tag.name)") + .font(.system(size: self.autocompleteFontTextSize)) + .foregroundColor(self.applicationState.tintColor.color()) + } + Divider() + } + } + } + .padding(.horizontal, 8) + } + .frame(height: 40) + .background(.ultraThinMaterial) + } + } + + @ViewBuilder + private func keyboardToolbar() -> some View { + VStack(spacing: 0) { + Divider() + HStack { + ScrollView(.horizontal) { + HStack(alignment: .center, spacing: 20) { + Button { + hideKeyboard() + self.focusedField = .unknown + self.photosPickerVisible = true + } label: { + Image(systemName: self.photosAreAttached ? "photo.fill.on.rectangle.fill" : "photo.on.rectangle") + } + + Button { + withAnimation(.easeInOut) { + self.isSensitive.toggle() + + if self.isSensitive { + self.focusedField = .spoilerText + } else { + self.focusedField = .content + } + } + } 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") + } + + 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") + } + + Button { + self.textModel.insertAtCursorPosition(content: "#") + } label: { + Image(systemName: "number") + } + + Button { + self.textModel.insertAtCursorPosition(content: "@") + } label: { + Image(systemName: "at") + } + } + } + + Spacer() + + Text("\(self.applicationState.statusMaxCharacters - textModel.text.string.utf16.count)") + .foregroundColor(.lightGrayColor) + .font(.system(size: self.keyboardFontTextSize)) + } + .padding(8) + .font(.system(size: self.keyboardFontImageSize)) + } + .background(Color.keyboardToolbarColor) + } + + private func placeholder() -> LocalizedStringKey { + self.statusViewModel == nil ? "compose.title.attachPhotoFull" : "compose.title.attachPhotoMini" + } + + private func isPublishButtonDisabled() -> Bool { + // Publish always disabled when there is not status text. + if self.textModel.text.string.isEmpty { + 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. + if self.statusViewModel == nil && self.photosAttachment.hasUploadedPhotos() == false { + return true + } + + return false + } + + private func isInteractiveDismissDisabled() -> Bool { + if self.textModel.text.string.isEmpty == false { + return true + } + + if self.photosAreUploading == true { + return true + } + + if self.photosAttachment.hasUploadedPhotos() == true { + return true + } + + return false + } + + private func loadPhotos() async { + self.photosAreUploading = true + self.publishDisabled = self.isPublishButtonDisabled() + self.interactiveDismissDisabled = self.isInteractiveDismissDisabled() + + // We have to create list with existing photos. + var temporaryPhotosAttachment: [PhotoAttachment] = [] + + // Add to collection photos selected on photo picker. + for item in self.selectedItems { + if let photoAttachment = self.photosAttachment.first(where: { $0.photosPickerItem == item }) { + temporaryPhotosAttachment.append(photoAttachment) + continue + } + + temporaryPhotosAttachment.append(PhotoAttachment(photosPickerItem: item)) + } + + // Add to collection photos from share sheet. + for item in self.attachments { + if let photoAttachment = self.photosAttachment.first(where: { $0.nsItemProvider == item }) { + temporaryPhotosAttachment.append(photoAttachment) + continue + } + + temporaryPhotosAttachment.append(PhotoAttachment(nsItemProvider: item)) + } + + // We can show new list on the screen. + self.photosAttachment = temporaryPhotosAttachment + + // Now we have to get from photos images as JPEG. + for photoAttachment in self.photosAttachment.filter({ $0.photoData == nil }) { + do { + try await photoAttachment.loadImage() + } catch { + photoAttachment.loadError = error + + if Bundle.main.bundlePath.hasSuffix(".appex") { + ErrorService.shared.handle(error, message: "Cannot load image from external library.") + } else { + ErrorService.shared.handle(error, message: "compose.error.loadingPhotosFailed", showToastr: true) + } + } + } + + // Open again the keyboard. + self.focusedField = .content + + // Upload images which hasn't been uploaded yet. + await self.upload() + + // Change state of the screen. + self.photosAreUploading = false + self.refreshScreenState() + } + + private func refreshScreenState() { + self.photosAreAttached = self.photosAttachment.hasUploadedPhotos() + self.publishDisabled = self.isPublishButtonDisabled() + self.interactiveDismissDisabled = self.isInteractiveDismissDisabled() + } + + private func upload() async { + for photoAttachment in self.photosAttachment { + await self.onUpload(photoAttachment) + } + } + + private func close() { + // Clean tmp folder from file transferred from Photos. + self.photosAttachment.removeTmpFiles() + + // Close the view. + self.onClose() + } + + private func publishStatus() async { + do { + let status = self.createStatus() + if try await self.client.statuses?.new(status: status) != nil { + self.close() + } + } catch { + ErrorService.shared.handle(error, message: "compose.error.postingStatusFailed", showToastr: true) + } + } + + private func createStatus() -> Pixelfed.Statuses.Components { + return Pixelfed.Statuses.Components(inReplyToId: self.statusViewModel?.id, + text: self.textModel.text.string, + spoilerText: self.isSensitive ? self.spoilerText : String.empty(), + mediaIds: self.photosAttachment.getUploadedPhotoIds(), + visibility: self.visibility, + sensitive: self.isSensitive, + placeId: self.place?.id, + commentsDisabled: self.commentsDisabled) + } +} diff --git a/WidgetsKit/Sources/WidgetsKit/Views/ImageUploadView.swift b/WidgetsKit/Sources/WidgetsKit/Views/ImageUploadView.swift index f45a53f..2cf5b1b 100644 --- a/WidgetsKit/Sources/WidgetsKit/Views/ImageUploadView.swift +++ b/WidgetsKit/Sources/WidgetsKit/Views/ImageUploadView.swift @@ -22,16 +22,18 @@ public struct ImageUploadView: View { } public var body: some View { - if photoAttachment.error != nil { + if photoAttachment.uploadError != nil || photoAttachment.loadError != nil { Menu { - Button { - HapticService.shared.fireHaptic(of: .buttonPress) - self.upload() - } label: { - Label("compose.title.tryToUpload", systemImage: "exclamationmark.arrow.triangle.2.circlepath") - } + if photoAttachment.uploadError != nil { + Button { + HapticService.shared.fireHaptic(of: .buttonPress) + self.upload() + } label: { + Label("compose.title.tryToUpload", systemImage: "exclamationmark.arrow.triangle.2.circlepath") + } - Divider() + Divider() + } Button(role: .destructive) { HapticService.shared.fireHaptic(of: .buttonPress) @@ -89,9 +91,10 @@ public struct ImageUploadView: View { .aspectRatio(contentMode: .fill) .frame(width: 80, height: 80) } else { - Rectangle() + Image("Blurhash") + .resizable() + .aspectRatio(contentMode: .fill) .frame(width: 80, height: 80) - .background(Color.lightGrayColor) } } }