From afed157f29962e79aa265e4ddd42a9418993995e Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 16 Apr 2023 13:47:06 -0400 Subject: [PATCH] Remove old compose screen code --- .../Sources/ComposeUI/ComposeUIConfig.swift | 15 +- .../AutocompleteMentionsController.swift | 9 +- .../Controllers/ComposeController.swift | 6 +- .../ComposeUI/Views/AvatarImageView.swift | 42 -- .../ComposeUI/Views/CurrentAccountView.swift | 10 +- .../ComposeUI/Views/ReplyStatusView.swift | 12 +- .../TuskerComponents/AvatarImageView.swift | 65 +++ Tusker.xcodeproj/project.pbxproj | 84 ---- Tusker/API/PostService.swift | 136 ------ Tusker/Models/DraftsManager.swift | 77 --- Tusker/Models/OldDraft.swift | 226 --------- Tusker/Scenes/ComposeSceneDelegate.swift | 6 +- Tusker/Scenes/MainSceneDelegate.swift | 5 +- .../Screens/Compose/ComposeAssetPicker.swift | 30 -- .../Compose/ComposeAttachmentImage.swift | 123 ----- .../Compose/ComposeAttachmentRow.swift | 164 ------- .../Compose/ComposeAttachmentsList.swift | 210 --------- .../Compose/ComposeAutocompleteView.swift | 424 ----------------- .../Compose/ComposeAvatarImageView.swift | 64 --- .../Compose/ComposeCurrentAccount.swift | 49 -- .../Compose/ComposeEmojiTextField.swift | 269 ----------- .../Compose/ComposeHostingController.swift | 278 ----------- Tusker/Screens/Compose/ComposePollView.swift | 232 --------- Tusker/Screens/Compose/ComposeReplyView.swift | 100 ---- Tusker/Screens/Compose/ComposeTextView.swift | 137 ------ .../ComposeTextViewCaretScrolling.swift | 60 --- Tusker/Screens/Compose/ComposeToolbar.swift | 149 ------ Tusker/Screens/Compose/ComposeUIState.swift | 80 ---- Tusker/Screens/Compose/ComposeView.swift | 376 --------------- Tusker/Screens/Compose/DraftsView.swift | 145 ------ .../Screens/Compose/MainComposeTextView.swift | 443 ------------------ .../Customize Timelines/EditFilterView.swift | 12 + .../Main/MainTabBarViewController.swift | 7 +- Tusker/Screens/Mute/MuteAccountView.swift | 9 +- Tusker/Screens/Report/ReportView.swift | 28 +- 35 files changed, 144 insertions(+), 3938 deletions(-) delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/AvatarImageView.swift create mode 100644 Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift delete mode 100644 Tusker/API/PostService.swift delete mode 100644 Tusker/Models/DraftsManager.swift delete mode 100644 Tusker/Models/OldDraft.swift delete mode 100644 Tusker/Screens/Compose/ComposeAssetPicker.swift delete mode 100644 Tusker/Screens/Compose/ComposeAttachmentImage.swift delete mode 100644 Tusker/Screens/Compose/ComposeAttachmentRow.swift delete mode 100644 Tusker/Screens/Compose/ComposeAttachmentsList.swift delete mode 100644 Tusker/Screens/Compose/ComposeAutocompleteView.swift delete mode 100644 Tusker/Screens/Compose/ComposeAvatarImageView.swift delete mode 100644 Tusker/Screens/Compose/ComposeCurrentAccount.swift delete mode 100644 Tusker/Screens/Compose/ComposeEmojiTextField.swift delete mode 100644 Tusker/Screens/Compose/ComposeHostingController.swift delete mode 100644 Tusker/Screens/Compose/ComposePollView.swift delete mode 100644 Tusker/Screens/Compose/ComposeReplyView.swift delete mode 100644 Tusker/Screens/Compose/ComposeTextView.swift delete mode 100644 Tusker/Screens/Compose/ComposeTextViewCaretScrolling.swift delete mode 100644 Tusker/Screens/Compose/ComposeToolbar.swift delete mode 100644 Tusker/Screens/Compose/ComposeUIState.swift delete mode 100644 Tusker/Screens/Compose/ComposeView.swift delete mode 100644 Tusker/Screens/Compose/DraftsView.swift delete mode 100644 Tusker/Screens/Compose/MainComposeTextView.swift diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift index f94f62df..0cc3694a 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift @@ -9,13 +9,14 @@ import SwiftUI import Pachyderm import PhotosUI import PencilKit +import TuskerComponents public struct ComposeUIConfig { public var backgroundColor = Color(uiColor: .systemBackground) public var groupedBackgroundColor = Color(uiColor: .systemGroupedBackground) public var groupedCellBackgroundColor = Color(uiColor: .systemBackground) public var fillColor = Color(uiColor: .systemFill) - public var avatarStyle = AvatarStyle.roundRect + public var avatarStyle = AvatarImageView.Style.roundRect public var useTwitterKeyboard = false public var contentType = StatusContentType.plain public var automaticallySaveDrafts = false @@ -31,16 +32,4 @@ public struct ComposeUIConfig { } extension ComposeUIConfig { - public enum AvatarStyle: Equatable { - case roundRect, circle - - var cornerRadiusFraction: CGFloat { - switch self { - case .roundRect: - return 0.1 - case .circle: - return 0.5 - } - } - } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift index 2e63c5fe..0acfc1cd 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AutocompleteMentionsController.swift @@ -8,6 +8,7 @@ import SwiftUI import Combine import Pachyderm +import TuskerComponents class AutocompleteMentionsController: ViewController { @@ -136,13 +137,19 @@ class AutocompleteMentionsController: ViewController { } private struct AutocompleteMentionButton: View { + @EnvironmentObject private var composeController: ComposeController @EnvironmentObject private var controller: AutocompleteMentionsController let account: AnyAccount var body: some View { Button(action: { controller.autocomplete(with: account) }) { HStack(spacing: 4) { - AvatarImageView(url: account.value.avatar, size: 30) + AvatarImageView( + url: account.value.avatar, + size: 30, + style: composeController.config.avatarStyle, + fetchAvatar: composeController.fetchAvatar + ) VStack(alignment: .leading) { controller.composeController.displayNameLabel(account.value, .subheadline, 14) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift index 2088c456..ceb8a227 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -8,9 +8,9 @@ import SwiftUI import Combine import Pachyderm +import TuskerComponents public final class ComposeController: ViewController { - public typealias FetchAvatar = (URL) async -> UIImage? public typealias FetchStatus = (String) -> (any StatusProtocol)? public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView @@ -19,7 +19,7 @@ public final class ComposeController: ViewController { @Published public private(set) var draft: Draft @Published public var config: ComposeUIConfig let mastodonController: ComposeMastodonContext - let fetchAvatar: FetchAvatar + let fetchAvatar: AvatarImageView.FetchAvatar let fetchStatus: FetchStatus let displayNameLabel: DisplayNameLabel let replyContentView: ReplyContentView @@ -68,7 +68,7 @@ public final class ComposeController: ViewController { draft: Draft, config: ComposeUIConfig, mastodonController: ComposeMastodonContext, - fetchAvatar: @escaping FetchAvatar, + fetchAvatar: @escaping AvatarImageView.FetchAvatar, fetchStatus: @escaping FetchStatus, displayNameLabel: @escaping DisplayNameLabel, replyContentView: @escaping ReplyContentView, diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AvatarImageView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AvatarImageView.swift deleted file mode 100644 index 993e81fe..00000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/AvatarImageView.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// AvatarImageView.swift -// ComposeUI -// -// Created by Shadowfacts on 3/4/23. -// - -import SwiftUI - -struct AvatarImageView: View { - let url: URL? - let size: CGFloat - @State private var image: UIImage? - @EnvironmentObject private var controller: ComposeController - - var body: some View { - imageView - .resizable() - .frame(width: size, height: size) - .cornerRadius(controller.config.avatarStyle.cornerRadiusFraction * size) - .task { - if let url { - image = await controller.fetchAvatar(url) - } - } - // tell swiftui that this view has changed (and therefore the task needs to re-run) when the url changes - .id(url) - - } - - private var imageView: Image { - if let image { - return Image(uiImage: image) - } else { - return placeholder - } - } - - private var placeholder: Image { - Image(systemName: controller.config.avatarStyle == .roundRect ? "person.crop.square" : "person.crop.circle") - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift index 87f6b2af..16ef5aed 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/CurrentAccountView.swift @@ -7,6 +7,7 @@ import SwiftUI import Pachyderm +import TuskerComponents struct CurrentAccountView: View { let account: (any AccountProtocol)? @@ -14,8 +15,13 @@ struct CurrentAccountView: View { var body: some View { HStack(alignment: .top) { - AvatarImageView(url: account?.avatar, size: 50) - .accessibilityHidden(true) + AvatarImageView( + url: account?.avatar, + size: 50, + style: controller.config.avatarStyle, + fetchAvatar: controller.fetchAvatar + ) + .accessibilityHidden(true) if let account { VStack(alignment: .leading) { diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift index bf0e3b58..91706952 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ReplyStatusView.swift @@ -7,6 +7,7 @@ import SwiftUI import Pachyderm +import TuskerComponents struct ReplyStatusView: View { let status: any StatusProtocol @@ -75,9 +76,14 @@ struct ReplyStatusView: View { // once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content offset = min(offset, maxOffset) - return AvatarImageView(url: status.account.avatar, size: 50) - .offset(x: 0, y: offset) - .accessibilityHidden(true) + return AvatarImageView( + url: status.account.avatar, + size: 50, + style: controller.config.avatarStyle, + fetchAvatar: controller.fetchAvatar + ) + .offset(x: 0, y: offset) + .accessibilityHidden(true) } } diff --git a/Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift b/Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift new file mode 100644 index 00000000..868c4d07 --- /dev/null +++ b/Packages/TuskerComponents/Sources/TuskerComponents/AvatarImageView.swift @@ -0,0 +1,65 @@ +// +// AvatarImageView.swift +// ComposeUI +// +// Created by Shadowfacts on 3/4/23. +// + +import SwiftUI + +public struct AvatarImageView: View { + public typealias FetchAvatar = (URL) async -> UIImage? + + let url: URL? + let size: CGFloat + let style: Style + let fetchAvatar: FetchAvatar + @State private var image: UIImage? + + public init(url: URL?, size: CGFloat, style: Style, fetchAvatar: @escaping FetchAvatar) { + self.url = url + self.size = size + self.style = style + self.fetchAvatar = fetchAvatar + } + + public var body: some View { + imageView + .resizable() + .frame(width: size, height: size) + .cornerRadius(style.cornerRadiusFraction * size) + .task { + if let url { + image = await fetchAvatar(url) + } + } + // tell swiftui that this view has changed (and therefore the task needs to re-run) when the url changes + .id(url) + + } + + private var imageView: Image { + if let image { + return Image(uiImage: image) + } else { + return placeholder + } + } + + private var placeholder: Image { + Image(systemName: style == .roundRect ? "person.crop.square" : "person.crop.circle") + } + + public enum Style: Equatable { + case roundRect, circle + + var cornerRadiusFraction: CGFloat { + switch self { + case .roundRect: + return 0.1 + case .circle: + return 0.5 + } + } + } +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index f27dd33b..7a33d5a4 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -81,13 +81,7 @@ D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; }; D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; }; D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D621733228F1D5ED004C7DB1 /* ReblogService.swift */; }; - D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */; }; - D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757724EE133700B82A16 /* ComposeAssetPicker.swift */; }; - D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */; }; - D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622759F24F1677200B82A16 /* ComposeHostingController.swift */; }; - D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A524F1C81800B82A16 /* ComposeReplyView.swift */; }; D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */; }; - D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A924F1E01C00B82A16 /* ComposeTextView.swift */; }; D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A53C2635F5590095BD04 /* StatusPollView.swift */; }; D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A5402635FB3C0095BD04 /* PollOptionView.swift */; }; D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A542263634100095BD04 /* PollOptionCheckboxView.swift */; }; @@ -104,7 +98,6 @@ D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */; }; D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */; }; D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */; }; - D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627FF75217E923E00CC0648 /* DraftsManager.swift */; }; D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6285B5221EA708700FE4B39 /* StatusFormat.swift */; }; D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6289E83217B795D0003D1D7 /* LargeImageViewController.xib */; }; D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D2421217AA7E1005076CC /* UserActivityManager.swift */; }; @@ -168,7 +161,6 @@ D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; }; D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */; }; D662AEF0263A3B880082A153 /* PollFinishedTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */; }; - D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D662AEF1263A4BE10082A153 /* ComposePollView.swift */; }; D663626221360B1900C9CBA2 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626121360B1900C9CBA2 /* Preferences.swift */; }; D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626321360D2300C9CBA2 /* AvatarStyle.swift */; }; D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626B21361C6700C9CBA2 /* Account+Preferences.swift */; }; @@ -180,10 +172,6 @@ D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; }; D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; }; D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; }; - D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; }; - D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; }; - D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */; }; - D677284E24ECC01D00C732D3 /* OldDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284D24ECC01D00C732D3 /* OldDraft.swift */; }; D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */; }; D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */; }; D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67B506C250B291200FAECFB /* BlurHashDecode.swift */; }; @@ -248,7 +236,6 @@ D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */; }; D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */; }; D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */; }; - D6A57408255C53EC00674551 /* ComposeTextViewCaretScrolling.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A57407255C53EC00674551 /* ComposeTextViewCaretScrolling.swift */; }; D6A5BB2B23BAEF61003BF21D /* APIMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2A23BAEF61003BF21D /* APIMocks.swift */; }; D6A5BB2D23BBA9C4003BF21D /* MyProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */; }; D6A5BB2F23BBAC97003BF21D /* DelegatingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */; }; @@ -292,9 +279,7 @@ D6BD395B29B64441005FFD2B /* NewComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BD395A29B64441005FFD2B /* NewComposeHostingController.swift */; }; D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; }; D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; }; - D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA248291C6118002F4D01 /* DraftsView.swift */; }; D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; }; - D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; }; D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; }; D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */; }; D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */; }; @@ -309,7 +294,6 @@ D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */; }; D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D862139E62700CB5196 /* LargeImageViewController.swift */; }; D6C94D892139E6EC00CB5196 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D882139E6EC00CB5196 /* AttachmentView.swift */; }; - D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; }; D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; }; D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; }; D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */; }; @@ -319,7 +303,6 @@ D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */; }; D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B59292D684600D528E1 /* AccountListViewController.swift */; }; D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; }; - D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; }; D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */; }; D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */; }; D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD6212518A200E1C4BB /* Assets.xcassets */; }; @@ -331,7 +314,6 @@ D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; }; D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; }; D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; }; - D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; }; D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; }; D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; }; D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; }; @@ -346,7 +328,6 @@ D6E343B0265AAD6B00C4AA01 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6E343AE265AAD6B00C4AA01 /* MainInterface.storyboard */; }; D6E343B4265AAD6B00C4AA01 /* OpenInTusker.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D6E343BA265AAD8C00C4AA01 /* Action.js in Resources */ = {isa = PBXBuildFile; fileRef = D6E343B9265AAD8C00C4AA01 /* Action.js */; }; - D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */; }; D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */; }; D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */; }; D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */; }; @@ -355,7 +336,6 @@ D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */; }; D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */; }; D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */; }; - D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E9CDA7281A427800BBC98E /* PostService.swift */; }; D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */; }; D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; }; D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */; }; @@ -368,7 +348,6 @@ D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A551291F098700F496A8 /* RenameListService.swift */; }; D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A553291F0D9600F496A8 /* DeleteListService.swift */; }; D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */; }; - D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A5582920676800F496A8 /* ComposeToolbar.swift */; }; D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EF21251A2900CF0F2B /* MastodonController.swift */; }; D6FA94E129B52898006AAC51 /* InstanceFeatures in Frameworks */ = {isa = PBXBuildFile; productRef = D6FA94E029B52898006AAC51 /* InstanceFeatures */; }; D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */; }; @@ -496,13 +475,7 @@ D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = ""; }; D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = ""; }; D621733228F1D5ED004C7DB1 /* ReblogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReblogService.swift; sourceTree = ""; }; - D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentsList.swift; sourceTree = ""; }; - D622757724EE133700B82A16 /* ComposeAssetPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAssetPicker.swift; sourceTree = ""; }; - D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentRow.swift; sourceTree = ""; }; - D622759F24F1677200B82A16 /* ComposeHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeHostingController.swift; sourceTree = ""; }; - D62275A524F1C81800B82A16 /* ComposeReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyView.swift; sourceTree = ""; }; D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyContentView.swift; sourceTree = ""; }; - D62275A924F1E01C00B82A16 /* ComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTextView.swift; sourceTree = ""; }; D623A53C2635F5590095BD04 /* StatusPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPollView.swift; sourceTree = ""; }; D623A5402635FB3C0095BD04 /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; }; D623A542263634100095BD04 /* PollOptionCheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionCheckboxView.swift; sourceTree = ""; }; @@ -519,7 +492,6 @@ D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BasicTableViewCell.xib; sourceTree = ""; }; D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTimelineViewController.swift; sourceTree = ""; }; D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListAccountsViewController.swift; sourceTree = ""; }; - D627FF75217E923E00CC0648 /* DraftsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsManager.swift; sourceTree = ""; }; D6285B5221EA708700FE4B39 /* StatusFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFormat.swift; sourceTree = ""; }; D6289E83217B795D0003D1D7 /* LargeImageViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LargeImageViewController.xib; sourceTree = ""; }; D62D2421217AA7E1005076CC /* UserActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityManager.swift; sourceTree = ""; }; @@ -584,7 +556,6 @@ D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusStateResolver.swift; sourceTree = ""; }; D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFinishedTableViewCell.swift; sourceTree = ""; }; D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PollFinishedTableViewCell.xib; sourceTree = ""; }; - D662AEF1263A4BE10082A153 /* ComposePollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposePollView.swift; sourceTree = ""; }; D663626121360B1900C9CBA2 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; D663626321360D2300C9CBA2 /* AvatarStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStyle.swift; sourceTree = ""; }; D663626B21361C6700C9CBA2 /* Account+Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Preferences.swift"; sourceTree = ""; }; @@ -596,10 +567,6 @@ D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = ""; }; D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Tusker-Bridging-Header.h"; sourceTree = ""; }; D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Pachyderm; path = Packages/Pachyderm; sourceTree = ""; }; - D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; - D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = ""; }; - D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = ""; }; - D677284D24ECC01D00C732D3 /* OldDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OldDraft.swift; sourceTree = ""; }; D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKDrawing+Render.swift"; sourceTree = ""; }; D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountAvatarView.swift; sourceTree = ""; }; D67B506C250B291200FAECFB /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; }; @@ -665,7 +632,6 @@ D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastAccountSwitcherViewController.swift; sourceTree = ""; }; D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FastAccountSwitcherViewController.xib; sourceTree = ""; }; D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastSwitchingAccountView.swift; sourceTree = ""; }; - D6A57407255C53EC00674551 /* ComposeTextViewCaretScrolling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTextViewCaretScrolling.swift; sourceTree = ""; }; D6A5BB2A23BAEF61003BF21D /* APIMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIMocks.swift; sourceTree = ""; }; D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileTests.swift; sourceTree = ""; }; D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelegatingResponse.swift; sourceTree = ""; }; @@ -710,9 +676,7 @@ D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = ""; }; D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = ""; }; D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = ""; }; - D6BEA248291C6118002F4D01 /* DraftsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsView.swift; sourceTree = ""; }; D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = ""; }; - D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = ""; }; D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = ""; }; D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = ""; }; D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = ""; }; @@ -727,7 +691,6 @@ D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreViewController.swift; sourceTree = ""; }; D6C94D862139E62700CB5196 /* LargeImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageViewController.swift; sourceTree = ""; }; D6C94D882139E6EC00CB5196 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = ""; }; - D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = ""; }; D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = ""; }; D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = ""; }; D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUniqueTests.swift; sourceTree = ""; }; @@ -737,7 +700,6 @@ D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListCollectionViewController.swift; sourceTree = ""; }; D6D12B59292D684600D528E1 /* AccountListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListViewController.swift; sourceTree = ""; }; D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = ""; }; - D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = ""; }; D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = ""; }; D6D4DDCC212518A000E1C4BB /* Tusker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tusker.app; sourceTree = BUILT_PRODUCTS_DIR; }; D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -756,7 +718,6 @@ D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = ""; }; D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = ""; }; - D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = ""; }; D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = ""; }; D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = ""; }; D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = ""; }; @@ -773,7 +734,6 @@ D6E343B1265AAD6B00C4AA01 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D6E343B5265AAD6B00C4AA01 /* OpenInTusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenInTusker.entitlements; sourceTree = ""; }; D6E343B9265AAD8C00C4AA01 /* Action.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = Action.js; sourceTree = ""; }; - D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAutocompleteView.swift; sourceTree = ""; }; D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcher.swift; sourceTree = ""; }; D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcherTests.swift; sourceTree = ""; }; D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiImageView.swift; sourceTree = ""; }; @@ -783,7 +743,6 @@ D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkCardCollectionViewCell.swift; sourceTree = ""; }; D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TrendingLinkCardCollectionViewCell.xib; sourceTree = ""; }; D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitNavigationController.swift; sourceTree = ""; }; - D6E9CDA7281A427800BBC98E /* PostService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostService.swift; sourceTree = ""; }; D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableTextField.swift; sourceTree = ""; }; D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = ""; }; D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISceneSession+MastodonController.swift"; sourceTree = ""; }; @@ -796,7 +755,6 @@ D6F6A551291F098700F496A8 /* RenameListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameListService.swift; sourceTree = ""; }; D6F6A553291F0D9600F496A8 /* DeleteListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteListService.swift; sourceTree = ""; }; D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteAccountView.swift; sourceTree = ""; }; - D6F6A5582920676800F496A8 /* ComposeToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbar.swift; sourceTree = ""; }; D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = ""; }; D6FA94DF29B52891006AAC51 /* InstanceFeatures */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = InstanceFeatures; path = Packages/InstanceFeatures; sourceTree = ""; }; D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitchingContainerViewController.swift; sourceTree = ""; }; @@ -881,8 +839,6 @@ D6285B5221EA708700FE4B39 /* StatusFormat.swift */, D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */, D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */, - D677284D24ECC01D00C732D3 /* OldDraft.swift */, - D627FF75217E923E00CC0648 /* DraftsManager.swift */, D61F75AE293AF50C00C0B37F /* EditedFilter.swift */, D65B4B532971F71D00DABDFB /* EditedReport.swift */, D600891A29848289005B4D00 /* PinnedTimeline.swift */, @@ -1132,25 +1088,7 @@ isa = PBXGroup; children = ( D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */, - D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */, - D622759F24F1677200B82A16 /* ComposeHostingController.swift */, - D677284724ECBCB100C732D3 /* ComposeView.swift */, - D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */, - D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */, - D6A57407255C53EC00674551 /* ComposeTextViewCaretScrolling.swift */, - D62275A924F1E01C00B82A16 /* ComposeTextView.swift */, - D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */, - D662AEF1263A4BE10082A153 /* ComposePollView.swift */, - D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */, - D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */, - D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */, - D622757724EE133700B82A16 /* ComposeAssetPicker.swift */, - D62275A524F1C81800B82A16 /* ComposeReplyView.swift */, D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */, - D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */, - D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */, - D6F6A5582920676800F496A8 /* ComposeToolbar.swift */, - D6BEA248291C6118002F4D01 /* DraftsView.swift */, D6BD395A29B64441005FFD2B /* NewComposeHostingController.swift */, ); path = Compose; @@ -1693,7 +1631,6 @@ isa = PBXGroup; children = ( D6F953EF21251A2900CF0F2B /* MastodonController.swift */, - D6E9CDA7281A427800BBC98E /* PostService.swift */, D61ABEFD28F1C92600B29151 /* FavoriteService.swift */, D621733228F1D5ED004C7DB1 /* ReblogService.swift */, D6F6A54F291F058600F496A8 /* CreateListService.swift */, @@ -1982,7 +1919,6 @@ D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */, D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */, D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */, - D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */, D60E2F292442372B005F8713 /* AccountMO.swift in Sources */, D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */, D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */, @@ -2009,13 +1945,11 @@ D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */, D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */, D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */, - D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */, 0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */, D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */, D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */, D63CC7122911F57C000E19DE /* StatusBarTappableViewController.swift in Sources */, D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */, - D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */, D6B22A0F2560D52D004D82EF /* TabbedPageViewController.swift in Sources */, D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */, D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */, @@ -2035,7 +1969,6 @@ D620483623D38075008A63EF /* ContentTextView.swift in Sources */, D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */, D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */, - D6A57408255C53EC00674551 /* ComposeTextViewCaretScrolling.swift in Sources */, D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */, D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */, D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */, @@ -2055,8 +1988,6 @@ D60E2F272442372B005F8713 /* StatusMO.swift in Sources */, D61F758A2932E1FC00C0B37F /* SwipeActionsPrefsView.swift in Sources */, D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */, - D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */, - D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */, D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */, D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */, D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */, @@ -2075,16 +2006,13 @@ D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */, D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */, D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */, - D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */, 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */, - D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */, D61F75AD293AF39000C0B37F /* Filter+Helpers.swift in Sources */, D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */, D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */, D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */, D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */, 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */, - D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */, D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */, D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */, D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */, @@ -2111,7 +2039,6 @@ D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */, D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */, D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */, - D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */, D65B4B5E2973040D00DABDFB /* ReportAddStatusView.swift in Sources */, D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */, D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */, @@ -2130,7 +2057,6 @@ D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */, D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */, D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */, - D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */, D61F75B7293C119700C0B37F /* Filterer.swift in Sources */, D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */, D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */, @@ -2142,8 +2068,6 @@ D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */, D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */, D659F36229541065002D944A /* TTTView.swift in Sources */, - D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */, - D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */, D65B4B6229771A3F00DABDFB /* FetchStatusService.swift in Sources */, D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */, 0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */, @@ -2152,7 +2076,6 @@ D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */, D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */, D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */, - D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */, D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */, D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */, D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */, @@ -2180,7 +2103,6 @@ D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */, D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */, D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */, - D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */, D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */, D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */, D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */, @@ -2205,7 +2127,6 @@ D60089192981FEBA005B4D00 /* ConfettiView.swift in Sources */, D61F759929384D4D00C0B37F /* CustomizeTimelinesView.swift in Sources */, D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */, - D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */, 04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */, D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */, D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */, @@ -2235,9 +2156,7 @@ D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */, D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */, D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */, - D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */, D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */, - D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */, 04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */, D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */, D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */, @@ -2259,11 +2178,8 @@ D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */, D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */, D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */, - D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */, D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */, - D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */, D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */, - D677284E24ECC01D00C732D3 /* OldDraft.swift in Sources */, D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */, D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */, D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */, diff --git a/Tusker/API/PostService.swift b/Tusker/API/PostService.swift deleted file mode 100644 index 72ef7281..00000000 --- a/Tusker/API/PostService.swift +++ /dev/null @@ -1,136 +0,0 @@ -// -// PostService.swift -// Tusker -// -// Created by Shadowfacts on 4/27/22. -// Copyright © 2022 Shadowfacts. All rights reserved. -// - -import Foundation -import Pachyderm -import UniformTypeIdentifiers - -class PostService: ObservableObject { - private let mastodonController: MastodonController - private let draft: OldDraft - let totalSteps: Int - - @Published var currentStep = 1 - - init(mastodonController: MastodonController, draft: OldDraft) { - self.mastodonController = mastodonController - self.draft = draft - // 2 steps (request data, then upload) for each attachment - self.totalSteps = 2 + (draft.attachments.count * 2) - } - - @MainActor - func post() async throws { - guard draft.hasContent else { - return - } - - // save before posting, so if a crash occurs during network request, the status won't be lost - OldDraftsManager.save() - - let uploadedAttachments = try await uploadAttachments() - - let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : nil - let sensitive = contentWarning != nil - - let request = Client.createStatus( - text: textForPosting(), - contentType: Preferences.shared.statusContentType, - inReplyTo: draft.inReplyToID, - media: uploadedAttachments, - sensitive: sensitive, - spoilerText: contentWarning, - visibility: draft.visibility, - language: nil, - pollOptions: draft.poll?.options.map(\.text), - pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration), - pollMultiple: draft.poll?.multiple, - localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil - ) - do { - let (_, _) = try await mastodonController.run(request) - currentStep += 1 - - OldDraftsManager.shared.remove(self.draft) - } catch let error as Client.Error { - throw Error.posting(error) - } - } - - private func uploadAttachments() async throws -> [Attachment] { - var attachments: [Attachment] = [] - attachments.reserveCapacity(draft.attachments.count) - for (index, attachment) in draft.attachments.enumerated() { - let data: Data - let utType: UTType - do { - (data, utType) = try await getData(for: attachment) - currentStep += 1 - } catch let error as CompositionAttachmentData.Error { - throw Error.attachmentData(index: index, cause: error) - } - do { - let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription) - attachments.append(uploaded) - currentStep += 1 - } catch let error as Client.Error { - throw Error.attachmentUpload(index: index, cause: error) - } - } - return attachments - } - - private func getData(for attachment: CompositionAttachment) async throws -> (Data, UTType) { - return try await withCheckedThrowingContinuation { continuation in - attachment.data.getData(features: mastodonController.instanceFeatures) { result in - switch result { - case let .success(res): - continuation.resume(returning: res) - case let .failure(error): - continuation.resume(throwing: error) - } - } - } - } - - private func uploadAttachment(data: Data, utType: UTType, description: String?) async throws -> Attachment { - let formAttachment = FormAttachment(mimeType: utType.preferredMIMEType!, data: data, fileName: "file.\(utType.preferredFilenameExtension!)") - let req = Client.upload(attachment: formAttachment, description: description) - return try await mastodonController.run(req).0 - } - - private func textForPosting() -> String { - var text = draft.text - // when using dictation, iOS sometimes leaves a U+FFFC OBJECT REPLACEMENT CHARACTER behind in the text, - // which we want to strip out before actually posting the status - text = text.replacingOccurrences(of: "\u{fffc}", with: "") - - if draft.localOnly && mastodonController.instanceFeatures.needsLocalOnlyEmojiHack { - text += " 👁" - } - - return text - } - - enum Error: Swift.Error, LocalizedError { - case attachmentData(index: Int, cause: CompositionAttachmentData.Error) - case attachmentUpload(index: Int, cause: Client.Error) - case posting(Client.Error) - - var localizedDescription: String { - switch self { - case let .attachmentData(index: index, cause: cause): - return "Attachment \(index + 1): \(cause.localizedDescription)" - case let .attachmentUpload(index: index, cause: cause): - return "Attachment \(index + 1): \(cause.localizedDescription)" - case let .posting(error): - return error.localizedDescription - } - } - } -} diff --git a/Tusker/Models/DraftsManager.swift b/Tusker/Models/DraftsManager.swift deleted file mode 100644 index 723d8a9e..00000000 --- a/Tusker/Models/DraftsManager.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// OldDraftsManager.swift -// Tusker -// -// Created by Shadowfacts on 10/22/18. -// Copyright © 2018 Shadowfacts. All rights reserved. -// - -import Foundation - -class OldDraftsManager: Codable, ObservableObject { - - private(set) static var shared: OldDraftsManager = load() - - private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - private static var archiveURL = OldDraftsManager.documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") - - static func save() { - DispatchQueue.global(qos: .utility).async { - let encoder = PropertyListEncoder() - let data = try? encoder.encode(shared) - try? data?.write(to: archiveURL, options: .noFileProtection) - } - } - - static func load() -> OldDraftsManager { - let decoder = PropertyListDecoder() - if let data = try? Data(contentsOf: archiveURL), - let OldDraftsManager = try? decoder.decode(OldDraftsManager.self, from: data) { - return OldDraftsManager - } - return OldDraftsManager() - } - - private init() {} - - required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - if let dict = try? container.decode([UUID: OldDraft].self, forKey: .drafts) { - self.drafts = dict - } else if let array = try? container.decode([OldDraft].self, forKey: .drafts) { - self.drafts = array.reduce(into: [:], { partialResult, draft in - partialResult[draft.id] = draft - }) - } else { - throw DecodingError.dataCorruptedError(forKey: .drafts, in: container, debugDescription: "expected drafts to be a dict or array of drafts") - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(drafts, forKey: .drafts) - } - - @Published private var drafts: [UUID: OldDraft] = [:] - var sorted: [OldDraft] { - return drafts.values.sorted(by: { $0.lastModified > $1.lastModified }) - } - - func add(_ draft: OldDraft) { - drafts[draft.id] = draft - } - - func remove(_ draft: OldDraft) { - drafts.removeValue(forKey: draft.id) - } - - func getBy(id: UUID) -> OldDraft? { - return drafts[id] - } - - enum CodingKeys: String, CodingKey { - case drafts - } - -} diff --git a/Tusker/Models/OldDraft.swift b/Tusker/Models/OldDraft.swift deleted file mode 100644 index 24ca5808..00000000 --- a/Tusker/Models/OldDraft.swift +++ /dev/null @@ -1,226 +0,0 @@ -// -// OldDraft.swift -// Tusker -// -// Created by Shadowfacts on 8/18/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import Foundation -import Pachyderm - -class OldDraft: Codable, ObservableObject { - let id: UUID - var lastModified: Date - - @Published var accountID: String - @Published var text: String - @Published var contentWarningEnabled: Bool - @Published var contentWarning: String - @Published var attachments: [CompositionAttachment] - @Published var inReplyToID: String? - @Published var visibility: Visibility - @Published var poll: Poll? - @Published var localOnly: Bool - - var initialText: String - - var hasContent: Bool { - (!text.isEmpty && text != initialText) || - (contentWarningEnabled && !contentWarning.isEmpty) || - attachments.count > 0 || - poll?.hasContent == true - } - - init(accountID: String) { - self.id = UUID() - self.lastModified = Date() - - self.accountID = accountID - self.text = "" - self.contentWarningEnabled = false - self.contentWarning = "" - self.attachments = [] - self.inReplyToID = nil - self.visibility = Preferences.shared.defaultPostVisibility - self.poll = nil - self.localOnly = false - - self.initialText = "" - } - - required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - self.id = try container.decode(UUID.self, forKey: .id) - self.lastModified = try container.decode(Date.self, forKey: .lastModified) - - self.accountID = try container.decode(String.self, forKey: .accountID) - self.text = try container.decode(String.self, forKey: .text) - self.contentWarningEnabled = try container.decode(Bool.self, forKey: .contentWarningEnabled) - self.contentWarning = try container.decode(String.self, forKey: .contentWarning) - self.attachments = try container.decode([CompositionAttachment].self, forKey: .attachments) - self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID) - self.visibility = try container.decode(Visibility.self, forKey: .visibility) - self.poll = try container.decode(Poll?.self, forKey: .poll) - self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false - - self.initialText = try container.decode(String.self, forKey: .initialText) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(id, forKey: .id) - try container.encode(lastModified, forKey: .lastModified) - - try container.encode(accountID, forKey: .accountID) - try container.encode(text, forKey: .text) - try container.encode(contentWarningEnabled, forKey: .contentWarningEnabled) - try container.encode(contentWarning, forKey: .contentWarning) - try container.encode(attachments, forKey: .attachments) - try container.encode(inReplyToID, forKey: .inReplyToID) - try container.encode(visibility, forKey: .visibility) - try container.encode(poll, forKey: .poll) - try container.encode(localOnly, forKey: .localOnly) - - try container.encode(initialText, forKey: .initialText) - } -} - -extension OldDraft: Equatable { - static func ==(lhs: OldDraft, rhs: OldDraft) -> Bool { - return lhs.id == rhs.id - } -} - -extension OldDraft: Identifiable {} - -extension OldDraft { - enum CodingKeys: String, CodingKey { - case id - case lastModified - - case accountID - case text - case contentWarningEnabled - case contentWarning - case attachments - case inReplyToID - case visibility - case poll - case localOnly - - case initialText - } -} - -extension OldDraft { - class Poll: Codable, ObservableObject { - @Published var options: [Option] - @Published var multiple: Bool - @Published var duration: TimeInterval - - var hasContent: Bool { - options.contains { !$0.text.isEmpty } - } - - init() { - self.options = [Option(""), Option("")] - self.multiple = false - self.duration = 24 * 60 * 60 // 1 day - } - - required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.options = try container.decode([Option].self, forKey: .options) - self.multiple = try container.decode(Bool.self, forKey: .multiple) - self.duration = try container.decode(TimeInterval.self, forKey: .duration) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(options, forKey: .options) - try container.encode(multiple, forKey: .multiple) - try container.encode(duration, forKey: .duration) - } - - private enum CodingKeys: String, CodingKey { - case options - case multiple - case duration - } - - class Option: Identifiable, Codable, ObservableObject { - let id = UUID() - @Published var text: String - - init(_ text: String) { - self.text = text - } - - required init(from decoder: Decoder) throws { - self.text = try decoder.singleValueContainer().decode(String.self) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(text) - } - } - } -} - -extension MastodonController { - - func createOldDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil) -> OldDraft { - var acctsToMention = [String]() - - var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility - var localOnly = false - var contentWarning = "" - - if let inReplyToID = inReplyToID, - let inReplyTo = persistentContainer.status(for: inReplyToID) { - acctsToMention.append(inReplyTo.account.acct) - acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct)) - visibility = min(visibility, inReplyTo.visibility) - localOnly = instanceFeatures.localOnlyPosts && inReplyTo.localOnly - - if !inReplyTo.spoilerText.isEmpty { - switch Preferences.shared.contentWarningCopyMode { - case .doNotCopy: - break - case .asIs: - contentWarning = inReplyTo.spoilerText - case .prependRe: - if inReplyTo.spoilerText.lowercased().starts(with: "re:") { - contentWarning = inReplyTo.spoilerText - } else { - contentWarning = "re: \(inReplyTo.spoilerText)" - } - } - } - } - if let mentioningAcct = mentioningAcct { - acctsToMention.append(mentioningAcct) - } - if let ownAccount = self.account { - acctsToMention.removeAll(where: { $0 == ownAccount.acct }) - } - acctsToMention = acctsToMention.uniques() - - let draft = OldDraft(accountID: accountInfo!.id) - draft.inReplyToID = inReplyToID - draft.text = acctsToMention.map { "@\($0) " }.joined() - draft.initialText = draft.text - draft.visibility = visibility - draft.localOnly = localOnly - draft.contentWarning = contentWarning - draft.contentWarningEnabled = !contentWarning.isEmpty - - OldDraftsManager.shared.add(draft) - return draft - } - -} diff --git a/Tusker/Scenes/ComposeSceneDelegate.swift b/Tusker/Scenes/ComposeSceneDelegate.swift index 7eb558eb..79c6fcae 100644 --- a/Tusker/Scenes/ComposeSceneDelegate.swift +++ b/Tusker/Scenes/ComposeSceneDelegate.swift @@ -77,12 +77,12 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg } func sceneWillResignActive(_ scene: UIScene) { - OldDraftsManager.save() + DraftsManager.save() if let window = window, let nav = window.rootViewController as? UINavigationController, - let compose = nav.topViewController as? ComposeHostingController { - scene.userActivity = UserActivityManager.editDraftActivity(id: compose.draft.id, accountID: scene.session.mastodonController!.accountInfo!.id) + let compose = nav.topViewController as? NewComposeHostingController { + scene.userActivity = UserActivityManager.editDraftActivity(id: compose.controller.draft.id, accountID: scene.session.mastodonController!.accountInfo!.id) } } diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift index 685874f2..61739176 100644 --- a/Tusker/Scenes/MainSceneDelegate.swift +++ b/Tusker/Scenes/MainSceneDelegate.swift @@ -12,6 +12,7 @@ import MessageUI import CoreData import Duckable import UserAccounts +import ComposeUI class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate { @@ -87,7 +88,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). Preferences.save() - OldDraftsManager.save() + DraftsManager.save() } func sceneDidBecomeActive(_ scene: UIScene) { @@ -100,7 +101,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate // This may occur due to temporary interruptions (ex. an incoming phone call). Preferences.save() - OldDraftsManager.save() + DraftsManager.save() } func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { diff --git a/Tusker/Screens/Compose/ComposeAssetPicker.swift b/Tusker/Screens/Compose/ComposeAssetPicker.swift deleted file mode 100644 index 4a0cf43b..00000000 --- a/Tusker/Screens/Compose/ComposeAssetPicker.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// ComposeAssetPicker.swift -// Tusker -// -// Created by Shadowfacts on 8/19/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI -import ComposeUI - -struct ComposeAssetPicker: UIViewControllerRepresentable { - typealias UIViewControllerType = AssetPickerViewController - - @ObservedObject var draft: OldDraft - let delegate: AssetPickerViewControllerDelegate? - - @EnvironmentObject var mastodonController: MastodonController - - func makeUIViewController(context: Context) -> AssetPickerViewController { - let vc = AssetPickerViewController() - vc.assetPickerDelegate = delegate - vc.preferredContentSize = CGSize(width: 400, height: 600) - return vc - } - - func updateUIViewController(_ uiViewController: AssetPickerViewController, context: Context) { - } - -} diff --git a/Tusker/Screens/Compose/ComposeAttachmentImage.swift b/Tusker/Screens/Compose/ComposeAttachmentImage.swift deleted file mode 100644 index dd439d68..00000000 --- a/Tusker/Screens/Compose/ComposeAttachmentImage.swift +++ /dev/null @@ -1,123 +0,0 @@ -// -// ComposeAttachmentImage.swift -// Tusker -// -// Created by Shadowfacts on 11/10/21. -// Copyright © 2021 Shadowfacts. All rights reserved. -// - -import SwiftUI -import Photos -import TuskerComponents - -struct ComposeAttachmentImage: View { - let attachment: CompositionAttachment - let fullSize: Bool - - @State private var gifData: Data? = nil - @State private var image: UIImage? = nil - @State private var imageContentMode: ContentMode = .fill - @State private var imageBackgroundColor: Color = .black - - @Environment(\.colorScheme) private var colorScheme: ColorScheme - - var body: some View { - if let gifData { - GIFViewWrapper(gifData: gifData) - } else if let image { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: imageContentMode) - .background(imageBackgroundColor) - } else { - Image(systemName: placeholderImageName) - .onAppear(perform: self.loadImage) - } - } - - private var placeholderImageName: String { - switch colorScheme { - case .light: - return "photo" - case .dark: - return "photo.fill" - @unknown default: - return "photo" - } - } - - private func loadImage() { - switch attachment.data { - case let .image(image): - self.image = image - case let .asset(asset): - let size: CGSize - if fullSize { - size = PHImageManagerMaximumSize - } else { - // currently only used as thumbnail in ComposeAttachmentRow - size = CGSize(width: 80, height: 80) - } - let isGIF = PHAssetResource.assetResources(for: asset).contains(where: { $0.uniformTypeIdentifier == UTType.gif.identifier }) - if isGIF { - PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in - if typeIdentifier == UTType.gif.identifier { - self.gifData = data - } else if let data { - let image = UIImage(data: data) - DispatchQueue.main.async { - self.image = image - } - } - } - } else { - PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in - DispatchQueue.main.async { - self.image = image - } - } - } - case let .video(url): - let asset = AVURLAsset(url: url) - let imageGenerator = AVAssetImageGenerator(asset: asset) - if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) { - self.image = UIImage(cgImage: cgImage) - } - case let .drawing(drawing): - image = drawing.imageInLightMode(from: drawing.bounds) - imageContentMode = .fit - imageBackgroundColor = .white - case let .gif(data): - self.gifData = data - } - } -} - -private struct GIFViewWrapper: UIViewRepresentable { - typealias UIViewType = GIFImageView - - @State private var controller: GIFController - - init(gifData: Data) { - self._controller = State(wrappedValue: GIFController(gifData: gifData)) - } - - func makeUIView(context: Context) -> GIFImageView { - let view = GIFImageView() - controller.attach(to: view) - controller.startAnimating() - view.contentMode = .scaleAspectFit - view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - view.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - return view - } - - func updateUIView(_ uiView: GIFImageView, context: Context) { - } -} - -struct ComposeAttachmentImage_Previews: PreviewProvider { - static var previews: some View { - ComposeAttachmentImage(attachment: CompositionAttachment(data: .image(UIImage())), fullSize: false) - } -} diff --git a/Tusker/Screens/Compose/ComposeAttachmentRow.swift b/Tusker/Screens/Compose/ComposeAttachmentRow.swift deleted file mode 100644 index 5839d760..00000000 --- a/Tusker/Screens/Compose/ComposeAttachmentRow.swift +++ /dev/null @@ -1,164 +0,0 @@ -// -// ComposeAttachmentRow.swift -// Tusker -// -// Created by Shadowfacts on 8/19/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI -import Photos -import AVFoundation -import Vision - -struct ComposeAttachmentRow: View { - @ObservedObject var draft: OldDraft - @ObservedObject var attachment: CompositionAttachment - - @EnvironmentObject var mastodonController: MastodonController - @EnvironmentObject var uiState: ComposeUIState - @State private var mode: Mode = .allowEntry - @State private var isShowingTextRecognitionFailedAlert = false - @State private var textRecognitionErrorMessage: String? = nil - - var body: some View { - HStack(alignment: .center, spacing: 4) { - ComposeAttachmentImage(attachment: attachment, fullSize: false) - .frame(width: 80, height: 80) - .cornerRadius(8) - .contextMenu { - if case .drawing(_) = attachment.data { - Button(action: self.editDrawing) { - Label("Edit Drawing", systemImage: "hand.draw") - } - } else if attachment.data.type == .image { - Button(action: self.recognizeText) { - Label("Recognize Text", systemImage: "doc.text.viewfinder") - } - } - - Button(role: .destructive, action: self.removeAttachment) { - Label("Delete", systemImage: "trash") - } - } previewIfAvailable: { - ComposeAttachmentImage(attachment: attachment, fullSize: true) - } - - switch mode { - case .allowEntry: - ComposeTextView(text: $attachment.attachmentDescription, placeholder: Text("Describe for the visually impaired…"), minHeight: 80) - .backgroundColor(.clear) - - case .recognizingText: - ProgressView() - } - - - // todo: find a way to make this button not activated when the list row is selected, see FB8595628 -// Button(action: self.removeAttachment) { -// Image(systemName: "xmark.circle.fill") -// .foregroundColor(.blue) -// } - } - .onReceive(attachment.$attachmentDescription) { (newDesc) in - if newDesc.isEmpty { - uiState.attachmentsMissingDescriptions.insert(attachment.id) - } else { - uiState.attachmentsMissingDescriptions.remove(attachment.id) - } - } - .alert(isPresented: $isShowingTextRecognitionFailedAlert) { - Alert( - title: Text("Text Recognition Failed"), - message: Text(self.textRecognitionErrorMessage ?? ""), - dismissButton: .default(Text("OK")) - ) - } - } - - private func removeAttachment() { - withAnimation { - draft.attachments.removeAll { $0.id == attachment.id } - } - } - - private func editDrawing() { - uiState.composeDrawingMode = .edit(id: attachment.id) - uiState.delegate?.presentComposeDrawing() - } - - private func recognizeText() { - mode = .recognizingText - - DispatchQueue.global(qos: .userInitiated).async { - self.attachment.data.getData(features: mastodonController.instanceFeatures, skipAllConversion: true) { (result) in - let data: Data - do { - try data = result.get().0 - } catch { - DispatchQueue.main.async { - self.mode = .allowEntry - self.isShowingTextRecognitionFailedAlert = true - self.textRecognitionErrorMessage = error.localizedDescription - } - return - } - let handler = VNImageRequestHandler(data: data, options: [:]) - let request = VNRecognizeTextRequest { (request, error) in - DispatchQueue.main.async { - if let results = request.results as? [VNRecognizedTextObservation] { - var text = "" - for observation in results { - let result = observation.topCandidates(1).first! - text.append(result.string) - text.append("\n") - } - self.attachment.attachmentDescription = text - } - - self.mode = .allowEntry - } - } - request.recognitionLevel = .accurate - request.usesLanguageCorrection = true - DispatchQueue.global(qos: .userInitiated).async { - do { - try handler.perform([request]) - } catch { - // The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for. - guard (error as NSError).code != 1 else { return } - DispatchQueue.main.async { - self.mode = .allowEntry - self.isShowingTextRecognitionFailedAlert = true - self.textRecognitionErrorMessage = error.localizedDescription - } - } - } - } - } - } -} - -extension ComposeAttachmentRow { - enum Mode { - case allowEntry, recognizingText - } -} - -private extension View { - @available(iOS, obsoleted: 16.0) - @ViewBuilder - func contextMenu(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View { - if #available(iOS 16.0, *) { - self.contextMenu(menuItems: menuItems, preview: preview) - } else { - self.contextMenu(menuItems: menuItems) - } - } -} - -//struct ComposeAttachmentRow_Previews: PreviewProvider { -// static var previews: some View { -// ComposeAttachmentRow() -// } -//} diff --git a/Tusker/Screens/Compose/ComposeAttachmentsList.swift b/Tusker/Screens/Compose/ComposeAttachmentsList.swift deleted file mode 100644 index 011e2c92..00000000 --- a/Tusker/Screens/Compose/ComposeAttachmentsList.swift +++ /dev/null @@ -1,210 +0,0 @@ -// -// ComposeAttachmentsList.swift -// Tusker -// -// Created by Shadowfacts on 8/19/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI - -struct ComposeAttachmentsList: View { - private let cellHeight: CGFloat = 80 - private let cellPadding: CGFloat = 12 - - @ObservedObject var draft: OldDraft - - @EnvironmentObject var mastodonController: MastodonController - @EnvironmentObject var uiState: ComposeUIState - @State var isShowingAssetPickerPopover = false - @State var isShowingCreateDrawing = false - - @Environment(\.colorScheme) var colorScheme: ColorScheme - @Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass? - - var body: some View { - Group { - ForEach(draft.attachments) { (attachment) in - ComposeAttachmentRow( - draft: draft, - attachment: attachment - ) - .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) - .onDrag { NSItemProvider(object: attachment) } - } - .onMove(perform: self.moveAttachments) - .onDelete(perform: self.deleteAttachments) - .conditionally(canAddAttachment) { - $0.onInsert(of: CompositionAttachment.readableTypeIdentifiersForItemProvider, perform: self.insertAttachments) - } - - Button(action: self.addAttachment) { - Label("Add photo or video", systemImage: addButtonImageName) - } - .disabled(!canAddAttachment) - .foregroundColor(.accentColor) - .frame(height: cellHeight / 2) - .sheetOrPopover(isPresented: $isShowingAssetPickerPopover, content: self.assetPickerPopover) - .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) - - Button(action: self.createDrawing) { - Label("Draw something", systemImage: "hand.draw") - } - .disabled(!canAddAttachment) - .foregroundColor(.accentColor) - .frame(height: cellHeight / 2) - .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) - - Button(action: self.togglePoll) { - Label(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal") - } - .disabled(!canAddPoll) - .foregroundColor(.accentColor) - .frame(height: cellHeight / 2) - .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) - } - .onAppear(perform: self.didAppear) - } - - private var addButtonImageName: String { - switch colorScheme { - case .dark: - return "photo.fill" - case .light: - return "photo" - @unknown default: - return "photo" - } - } - - private var canAddAttachment: Bool { - if mastodonController.instanceFeatures.mastodonAttachmentRestrictions { - return draft.attachments.count < 4 && draft.attachments.allSatisfy { $0.data.type == .image } && draft.poll == nil - } else { - return true - } - } - - private var canAddPoll: Bool { - if mastodonController.instanceFeatures.pollsAndAttachments { - return true - } else { - return draft.attachments.isEmpty - } - } - - private func didAppear() { - if #available(iOS 16.0, *) { - // these appearance proxy hacks are no longer necessary - } else { - let proxy = UITableView.appearance(whenContainedInInstancesOf: [ComposeHostingController.self]) - // enable drag and drop to reorder on iPhone - proxy.dragInteractionEnabled = true - proxy.isScrollEnabled = false - } - } - - private func assetPickerPopover() -> some View { - ComposeAssetPicker(draft: draft, delegate: uiState.delegate?.assetPickerDelegate) - .onDisappear { - // on iPadOS 16, this is necessary to dismiss the popover when collapsing from regular -> compact size class - // otherwise, the popover isn't visible but it's still "presented", so the sheet can't be shown - self.isShowingAssetPickerPopover = false - } - // on iPadOS 16, this is necessary to show the dark color in the popover arrow - .background(Color(.appBackground)) - .environment(\.colorScheme, .dark) - .edgesIgnoringSafeArea(.bottom) - .withSheetDetentsIfAvailable() - } - - private func addAttachment() { -// if #available(iOS 16.0, *) { -// isShowingAssetPickerPopover = true -// } else if horizontalSizeClass == .regular { -// isShowingAssetPickerPopover = true -// } else { - uiState.delegate?.presentAssetPickerSheet() -// } - } - - private func moveAttachments(from source: IndexSet, to destination: Int) { - draft.attachments.move(fromOffsets: source, toOffset: destination) - } - - private func deleteAttachments(at indices: IndexSet) { - draft.attachments.remove(atOffsets: indices) - } - - private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) { - for provider in itemProviders where provider.canLoadObject(ofClass: CompositionAttachment.self) { - guard canAddAttachment else { break } - - provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in - guard let attachment = object as? CompositionAttachment else { return } - DispatchQueue.main.async { - self.draft.attachments.insert(attachment, at: offset) - } - } - } - } - - private func createDrawing() { - uiState.composeDrawingMode = .createNew - uiState.delegate?.presentComposeDrawing() - } - - private func togglePoll() { - UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil) - - withAnimation { - draft.poll = draft.poll == nil ? OldDraft.Poll() : nil - } - } -} - -fileprivate extension View { - @available(iOS, obsoleted: 16.0) - @ViewBuilder - func sheetOrPopover(isPresented: Binding, @ViewBuilder content: @escaping () -> some View) -> some View { - if #available(iOS 16.0, *) { - self.modifier(SheetOrPopover(isPresented: isPresented, view: content)) - } else { - self.popover(isPresented: isPresented, content: content) - } - } - - @available(iOS, obsoleted: 16.0) - @ViewBuilder - func withSheetDetentsIfAvailable() -> some View { - if #available(iOS 16.0, *) { - self - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } else { - self - } - } -} - -@available(iOS 16.0, *) -struct SheetOrPopover: ViewModifier { - @Binding var isPresented: Bool - @ViewBuilder let view: () -> V - - @Environment(\.horizontalSizeClass) var sizeClass - - func body(content: Content) -> some View { - if sizeClass == .compact { - content.sheet(isPresented: $isPresented, content: view) - } else { - content.popover(isPresented: $isPresented, content: view) - } - } -} - -//struct ComposeAttachmentsList_Previews: PreviewProvider { -// static var previews: some View { -// ComposeAttachmentsList() -// } -//} diff --git a/Tusker/Screens/Compose/ComposeAutocompleteView.swift b/Tusker/Screens/Compose/ComposeAutocompleteView.swift deleted file mode 100644 index 94a5a902..00000000 --- a/Tusker/Screens/Compose/ComposeAutocompleteView.swift +++ /dev/null @@ -1,424 +0,0 @@ -// -// ComposeAutocompleteView.swift -// Tusker -// -// Created by Shadowfacts on 10/10/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI -import CoreData -import Pachyderm - -struct ComposeAutocompleteView: View { - let autocompleteState: ComposeUIState.AutocompleteState - - @Environment(\.colorScheme) var colorScheme: ColorScheme - - private var backgroundColor: Color { - Color(white: colorScheme == .light ? 0.98 : 0.15) - } - - private var borderColor: Color { - Color(white: colorScheme == .light ? 0.85 : 0.25) - } - - var body: some View { - suggestionsView - .background(backgroundColor) - .overlay(borderColor.frame(height: 0.5), alignment: .top) - } - - @ViewBuilder - private var suggestionsView: some View { - switch autocompleteState { - case .mention(_): - ComposeAutocompleteMentionsView() - case .emoji(_): - ComposeAutocompleteEmojisView() - case .hashtag(_): - ComposeAutocompleteHashtagsView() - } - } -} - -struct ComposeAutocompleteMentionsView: View { - @EnvironmentObject private var mastodonController: MastodonController - @EnvironmentObject private var uiState: ComposeUIState - @ObservedObject private var preferences = Preferences.shared - - // can't use AccountProtocol because of associated type requirements - @State private var accounts: [AnyAccount] = [] - - @State private var searchRequest: URLSessionTask? - - var body: some View { - ScrollView(.horizontal) { - // can't use LazyHStack because changing the contents of the ForEach causes the ScrollView to hang - HStack(spacing: 8) { - ForEach(accounts, id: \.value.id) { (account) in - Button { - uiState.currentInput?.autocomplete(with: "@\(account.value.acct)") - } label: { - HStack(spacing: 4) { - ComposeAvatarImageView(url: account.value.avatar) - .frame(width: 30, height: 30) - .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 30) - - VStack(alignment: .leading) { - AccountDisplayNameLabel(account: account.value, textStyle: .subheadline, emojiSize: 14) - .foregroundColor(Color(UIColor.label)) - - Text(verbatim: "@\(account.value.acct)") - .font(.caption) - .foregroundColor(Color(UIColor.label)) - } - } - } - .frame(height: 30) - .padding(.vertical, 8) - } - .animation(.linear(duration: 0.1), value: accounts) - - Spacer() - } - .padding(.horizontal, 8) - } - .onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged) - .onDisappear { - searchRequest?.cancel() - } - } - - private func queryChanged(_ state: ComposeUIState.AutocompleteState?) { - guard case let .mention(query) = state, - !query.isEmpty else { - accounts = [] - return - } - - let localSearchWorkItem = DispatchWorkItem { - // todo: there's got to be something more efficient than this :/ - let wildcardedQuery = query.map { "*\($0)" }.joined() + "*" - let request: NSFetchRequest = AccountMO.fetchRequest() - request.predicate = NSPredicate(format: "displayName LIKE %@ OR acct LIKE %@", wildcardedQuery, wildcardedQuery) - - if let results = try? mastodonController.persistentContainer.viewContext.fetch(request) { - loadAccounts(results.map { .init(value: $0) }, query: query) - } - } - - // we only want to search locally if the search API call takes more than .25sec or it fails - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: localSearchWorkItem) - - if let oldRequest = searchRequest { - oldRequest.cancel() - } - - let apiRequest = Client.searchForAccount(query: query) - searchRequest = mastodonController.run(apiRequest) { (response) in - guard case let .success(accounts, _) = response else { return } - - localSearchWorkItem.cancel() - - // dispatch back to the main thread because loadAccounts uses CoreData - DispatchQueue.main.async { - // if the query has changed, don't bother loading the now-outdated results - if case .mention(query) = uiState.autocompleteState { - self.loadAccounts(accounts.map { .init(value: $0) }, query: query) - } - } - } - } - - private func loadAccounts(_ accounts: [AnyAccount], query: String) { - // when sorting account suggestions, ignore the domain component of the acct unless the user is typing it themself - let ignoreDomain = !query.contains("@") - - self.accounts = - accounts.map { (account) -> (AnyAccount, (matched: Bool, score: Int)) in - let fuzzyStr = ignoreDomain ? String(account.value.acct.split(separator: "@").first!) : account.value.acct - let res = (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr)) - return res - } - .filter(\.1.matched) - .map { (account, res) -> (AnyAccount, Int) in - // give higher weight to accounts that the user follows or is followed by - var score = res.score - if let relationship = mastodonController.persistentContainer.relationship(forAccount: account.value.id) { - if relationship.following { - score += 3 - } - if relationship.followedBy { - score += 2 - } - } - return (account, score) - } - .sorted { $0.1 > $1.1 } - .map(\.0) - } - - private struct AnyAccount: Equatable { - let value: any AccountProtocol - - static func ==(lhs: AnyAccount, rhs: AnyAccount) -> Bool { - return lhs.value.id == rhs.value.id - } - } -} - -struct ComposeAutocompleteEmojisView: View { - @EnvironmentObject private var mastodonController: MastodonController - @EnvironmentObject private var uiState: ComposeUIState - - @State var expanded = false - @State private var emojis: [Emoji] = [] - @ScaledMetric private var emojiSize = 30 - - private var emojisBySection: [String: [Emoji]] { - var values: [String: [Emoji]] = [:] - for emoji in emojis { - let key = emoji.category ?? "" - if !values.keys.contains(key) { - values[key] = [emoji] - } else { - values[key]!.append(emoji) - } - } - return values - } - - var body: some View { - // When exapnded, the toggle button should be at the top. When collapsed, it should be centered. - HStack(alignment: expanded ? .top : .center, spacing: 0) { - if case let .emoji(query) = uiState.autocompleteState { - emojiList(query: query) - .transition(.move(edge: .bottom)) - .onReceive(uiState.$autocompleteState, perform: queryChanged) - .onAppear { - if uiState.shouldEmojiAutocompletionBeginExpanded { - expanded = true - uiState.shouldEmojiAutocompletionBeginExpanded = false - } - } - } else { - // when the autocomplete view is animating out, the autocomplete state is nil - // add a spacer so the expand button remains on the right - Spacer() - } - - toggleExpandedButton - .padding(.trailing, 8) - .padding(.top, expanded ? 8 : 0) - } - } - - @ViewBuilder - private func emojiList(query: String) -> some View { - if expanded { - verticalGrid - .frame(height: 150) - } else { - horizontalScrollView - } - } - - private var verticalGrid: some View { - ScrollView { - LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) { - ForEach(emojisBySection.keys.sorted(), id: \.self) { section in - Section { - ForEach(emojisBySection[section]!, id: \.shortcode) { emoji in - Button { - uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):") - } label: { - CustomEmojiImageView(emoji: emoji) - .frame(height: emojiSize) - } - .accessibilityLabel(emoji.shortcode) - } - } header: { - if !section.isEmpty { - VStack(alignment: .leading, spacing: 2) { - Text(section) - .font(.caption) - - Rectangle() - .foregroundColor(Color(.separator)) - .frame(height: 0.5) - } - .padding(.top, 4) - } - } - } - } - .padding(.all, 8) - } - .frame(maxWidth: .infinity) - } - - private var horizontalScrollView: some View { - ScrollView(.horizontal) { - HStack(spacing: 8) { - ForEach(emojis, id: \.shortcode) { (emoji) in - Button { - uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):") - } label: { - HStack(spacing: 4) { - CustomEmojiImageView(emoji: emoji) - .frame(height: emojiSize) - Text(verbatim: ":\(emoji.shortcode):") - .foregroundColor(Color(UIColor.label)) - } - } - .accessibilityLabel(emoji.shortcode) - .frame(height: emojiSize) - } - .animation(.linear(duration: 0.2), value: emojis) - - Spacer(minLength: emojiSize) - } - .padding(.horizontal, 8) - .frame(height: emojiSize + 16) - } - } - - private var toggleExpandedButton: some View { - Button { - withAnimation { - expanded.toggle() - } - } label: { - Image(systemName: "chevron.down") - .resizable() - .aspectRatio(contentMode: .fit) - .rotationEffect(expanded ? .zero : .degrees(180)) - } - .accessibilityLabel(expanded ? "Collapse" : "Expand") - .frame(width: 20, height: 20) - } - - private func queryChanged(_ autocompleteState: ComposeUIState.AutocompleteState?) { - guard case let .emoji(query) = autocompleteState else { - emojis = [] - return - } - - mastodonController.getCustomEmojis { (emojis) in - var emojis = emojis - if !query.isEmpty { - emojis = - emojis.map { (emoji) -> (Emoji, (matched: Bool, score: Int)) in - (emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode)) - } - .filter(\.1.matched) - .sorted { $0.1.score > $1.1.score } - .map(\.0) - } - var shortcodes = Set() - self.emojis = [] - for emoji in emojis where !shortcodes.contains(emoji.shortcode) { - self.emojis.append(emoji) - shortcodes.insert(emoji.shortcode) - } - } - } -} - -struct ComposeAutocompleteHashtagsView: View { - @EnvironmentObject private var mastodonController: MastodonController - @EnvironmentObject private var uiState: ComposeUIState - - @State private var hashtags: [Hashtag] = [] - @State private var trendingRequest: URLSessionTask? - @State private var searchRequest: URLSessionTask? - - var body: some View { - ScrollView(.horizontal) { - HStack(spacing: 8) { - ForEach(hashtags, id: \.name) { (hashtag) in - Button { - uiState.currentInput?.autocomplete(with: "#\(hashtag.name)") - } label: { - Text(verbatim: "#\(hashtag.name)") - .foregroundColor(Color(UIColor.label)) - } - .frame(height: 30) - .padding(.vertical, 8) - } - .animation(.linear(duration: 0.1), value: hashtags) - - Spacer() - } - .padding(.horizontal, 8) - } - .onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged) - .onDisappear { - trendingRequest?.cancel() - } - } - - private func queryChanged(_ autocompleteState: ComposeUIState.AutocompleteState?) { - guard case let .hashtag(query) = autocompleteState, - !query.isEmpty else { - hashtags = [] - return - } - - let onlySavedTagsWorkItem = DispatchWorkItem { - self.updateHashtags(searchResults: [], trendingTags: [], query: query) - } - - // we only want to do the local-only search if the trends API call takes more than .25sec or it fails - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: onlySavedTagsWorkItem) - - var trendingTags: [Hashtag] = [] - var searchedTags: [Hashtag] = [] - - let group = DispatchGroup() - - group.enter() - trendingRequest = mastodonController.run(Client.getTrendingHashtags()) { (response) in - defer { group.leave() } - guard case let .success(trends, _) = response else { return } - trendingTags = trends - } - - group.enter() - searchRequest = mastodonController.run(Client.search(query: "#\(query)", types: [.hashtags])) { (response) in - defer { group.leave() } - guard case let .success(results, _) = response else { return } - searchedTags = results.hashtags - } - - group.notify(queue: .main) { - onlySavedTagsWorkItem.cancel() - - // if the query has changed, don't bother loading the now-outdated results - if case .hashtag(query) = self.uiState.autocompleteState { - self.updateHashtags(searchResults: searchedTags, trendingTags: trendingTags, query: query) - } - } - } - - private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], query: String) { - let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!) - let savedTags = ((try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? []) - .map { Hashtag(name: $0.name, url: $0.url) } - - hashtags = (searchResults + savedTags + trendingTags) - .map { (tag) -> (Hashtag, (matched: Bool, score: Int)) in - return (tag, FuzzyMatcher.match(pattern: query, str: tag.name)) - } - .filter(\.1.matched) - .sorted { $0.1.score > $1.1.score } - .map(\.0) - } -} - -struct ComposeAutocompleteView_Previews: PreviewProvider { - static var previews: some View { - ComposeAutocompleteView(autocompleteState: .mention("shadowfacts")) - } -} diff --git a/Tusker/Screens/Compose/ComposeAvatarImageView.swift b/Tusker/Screens/Compose/ComposeAvatarImageView.swift deleted file mode 100644 index eceb9c3a..00000000 --- a/Tusker/Screens/Compose/ComposeAvatarImageView.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// ComposeAvatarImageView.swift -// Tusker -// -// Created by Shadowfacts on 8/18/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI - -struct ComposeAvatarImageView: View { - let url: URL? - @State var request: ImageCache.Request? = nil - @State var avatarImage: UIImage? = nil - @ObservedObject var preferences = Preferences.shared - - var body: some View { - image - .resizable() - .conditionally(url != nil) { - $0.onAppear(perform: self.loadImage) - } - .onDisappear(perform: self.cancelRequest) - } - - private var image: Image { - if let avatarImage = avatarImage { - return Image(uiImage: avatarImage).renderingMode(.original) - } else { - return placeholderImage - } - } - - private var placeholderImage: Image { - let imageName: String - switch preferences.avatarStyle { - case .circle: - imageName = "person.crop.circle" - case .roundRect: - imageName = "person.crop.square" - } - return Image(systemName: imageName) - } - - private func loadImage() { - guard let url = url else { return } - request = ImageCache.avatars.get(url) { (_, image) in - DispatchQueue.main.async { - self.request = nil - self.avatarImage = image - } - } - } - - private func cancelRequest() { - request?.cancel() - } -} - -struct ComposeAvatarImageView_Previews: PreviewProvider { - static var previews: some View { - ComposeAvatarImageView(url: URL(string: "https://social.shadowfacts.net/media/4b481afc591a8f3d11d0f5732e5cb320422dec72d7f223ebb5f35d5d0e821a9c.png")!) - } -} diff --git a/Tusker/Screens/Compose/ComposeCurrentAccount.swift b/Tusker/Screens/Compose/ComposeCurrentAccount.swift deleted file mode 100644 index 878c6139..00000000 --- a/Tusker/Screens/Compose/ComposeCurrentAccount.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// ComposeCurrentAccount.swift -// Tusker -// -// Created by Shadowfacts on 8/18/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI -import Pachyderm - -struct ComposeCurrentAccount: View { - @EnvironmentObject var mastodonController: MastodonController - @ObservedObject private var preferences = Preferences.shared - - var account: Account? { - mastodonController.account - } - - var body: some View { - HStack(alignment: .top) { - ComposeAvatarImageView(url: account?.avatar) - .frame(width: 50, height: 50) - .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50) - .accessibilityHidden(true) - - if let id = account?.id, - let account = mastodonController.persistentContainer.account(for: id) { - VStack(alignment: .leading) { - AccountDisplayNameLabel(account: account, textStyle: .title2, emojiSize: 24) - .lineLimit(1) - - Text(verbatim: "@\(account.acct)") - .font(.body.weight(.light)) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - - Spacer() - } - } -} - -//struct ComposeCurrentAccount_Previews: PreviewProvider { -// static var previews: some View { -// ComposeCurrentAccount(account: ) -// } -//} diff --git a/Tusker/Screens/Compose/ComposeEmojiTextField.swift b/Tusker/Screens/Compose/ComposeEmojiTextField.swift deleted file mode 100644 index cfaceb8d..00000000 --- a/Tusker/Screens/Compose/ComposeEmojiTextField.swift +++ /dev/null @@ -1,269 +0,0 @@ -// -// ComposeContentWarningTextField.swift -// Tusker -// -// Created by Shadowfacts on 10/12/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI - -struct ComposeEmojiTextField: UIViewRepresentable { - typealias UIViewType = UITextField - - @EnvironmentObject private var uiState: ComposeUIState - - @Binding var text: String - let placeholder: String - let maxLength: Int? - let becomeFirstResponder: Binding? - let focusNextView: Binding? - private var didChange: ((String) -> Void)? = nil - private var didEndEditing: (() -> Void)? = nil - private var backgroundColor: UIColor? = nil - - init(text: Binding, placeholder: String, maxLength: Int? = nil, becomeFirstResponder: Binding? = nil, focusNextView: Binding? = nil) { - self._text = text - self.placeholder = placeholder - self.maxLength = maxLength - self.becomeFirstResponder = becomeFirstResponder - self.focusNextView = focusNextView - self.didChange = nil - self.didEndEditing = nil - } - - mutating func didChange(_ didChange: @escaping (String) -> Void) -> Self { - self.didChange = didChange - return self - } - - mutating func didEndEditing(_ didEndEditing: @escaping () -> Void) -> Self { - self.didEndEditing = didEndEditing - return self - } - - mutating func backgroundColor(_ color: UIColor) -> Self { - self.backgroundColor = color - return self - } - - func makeUIView(context: Context) -> UITextField { - let view = UITextField() - - view.placeholder = placeholder - view.borderStyle = .roundedRect - view.font = .preferredFont(forTextStyle: .body) - view.adjustsFontForContentSizeCategory = true - view.backgroundColor = backgroundColor - - view.delegate = context.coordinator - view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged) - view.addTarget(context.coordinator, action: #selector(Coordinator.returnKeyPressed), for: .primaryActionTriggered) - - // otherwise when the text gets too wide it starts expanding the ComposeView - view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - - context.coordinator.textField = view - context.coordinator.uiState = uiState - context.coordinator.text = $text - - return view - } - - func updateUIView(_ uiView: UITextField, context: Context) { - if context.coordinator.skipSettingTextOnNextUpdate { - context.coordinator.skipSettingTextOnNextUpdate = false - } else { - uiView.text = text - } - context.coordinator.maxLength = maxLength - context.coordinator.didChange = didChange - context.coordinator.didEndEditing = didEndEditing - context.coordinator.focusNextView = focusNextView - - if becomeFirstResponder?.wrappedValue == true { - DispatchQueue.main.async { - uiView.becomeFirstResponder() - becomeFirstResponder?.wrappedValue = false - } - } - } - - func makeCoordinator() -> Coordinator { - return Coordinator() - } - - class Coordinator: NSObject, UITextFieldDelegate, ComposeInput { - weak var textField: UITextField? - var text: Binding! - // break retained cycle through ComposeUIState.currentInput - unowned var uiState: ComposeUIState! - var maxLength: Int? - var didChange: ((String) -> Void)? - var didEndEditing: (() -> Void)? - var focusNextView: Binding? - - var skipSettingTextOnNextUpdate = false - - var toolbarElements: [ComposeUIState.ToolbarElement] { - [.emojiPicker] - } - - @objc func didChange(_ textField: UITextField) { - text.wrappedValue = textField.text ?? "" - didChange?(text.wrappedValue) - } - - @objc func returnKeyPressed() { - focusNextView?.wrappedValue = true - } - - func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - if let maxLength { - return ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string).count <= maxLength - } else { - return true - } - } - - func textFieldDidBeginEditing(_ textField: UITextField) { - uiState.currentInput = self - updateAutocompleteState(textField: textField) - } - - func textFieldDidEndEditing(_ textField: UITextField) { - uiState.currentInput = nil - updateAutocompleteState(textField: textField) - didEndEditing?() - } - - func textFieldDidChangeSelection(_ textField: UITextField) { - // see MainComposeTextView.Coordinator.textViewDidChangeSelection(_:) - skipSettingTextOnNextUpdate = true - self.updateAutocompleteState(textField: textField) - } - - func textField(_ textField: UITextField, editMenuForCharactersIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? { - var actions = suggestedActions - if range.length == 0 { - actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in - self?.uiState.shouldEmojiAutocompletionBeginExpanded = true - self?.beginAutocompletingEmoji() - })) - } - return UIMenu(children: actions) - } - - func beginAutocompletingEmoji() { - textField?.insertText(":") - } - - func applyFormat(_ format: StatusFormat) { - } - - func autocomplete(with string: String) { - guard let textField = textField, - let text = textField.text, - let selectedRange = textField.selectedTextRange, - let lastWordStartIndex = findAutocompleteLastWord(textField: textField) else { - return - } - - let distanceToEnd = textField.offset(from: selectedRange.start, to: textField.endOfDocument) - - let selectedRangeStartUTF16 = textField.offset(from: textField.beginningOfDocument, to: selectedRange.start) - let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16) - - let insertSpace: Bool - if distanceToEnd > 0 { - let charAfterCursor = text[characterBeforeCursorIndex] - insertSpace = charAfterCursor != " " && charAfterCursor != "\n" - } else { - insertSpace = true - } - let string = insertSpace ? string + " " : string - - textField.text!.replaceSubrange(lastWordStartIndex.. text.startIndex { - let c = text[text.index(before: lastWordStartIndex)] - if isPermittedForAutocomplete(c) || c == ":" { - uiState.autocompleteState = nil - return - } - } - - let selectedRangeStartUTF16 = textField.offset(from: textField.beginningOfDocument, to: selectedRange.start) - let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16) - - if lastWordStartIndex >= text.startIndex { - let lastWord = text[lastWordStartIndex.. Bool { - return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_" - } - - private func findAutocompleteLastWord(textField: UITextField) -> String.Index? { - guard textField.isFirstResponder, - let selectedRange = textField.selectedTextRange, - selectedRange.isEmpty, - let text = textField.text, - !text.isEmpty else { - return nil - } - - let selectedRangeStartUTF16 = textField.offset(from: textField.beginningOfDocument, to: selectedRange.start) - let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16) - - guard cursorIndex != text.startIndex else { - return nil - } - - var lastWordStartIndex = text.index(before: cursorIndex) - while true { - let c = text[lastWordStartIndex] - - if !isPermittedForAutocomplete(c) { - break - } - - if lastWordStartIndex > text.startIndex { - lastWordStartIndex = text.index(before: lastWordStartIndex) - } else { - break - } - } - - return lastWordStartIndex - } - } - -} diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift deleted file mode 100644 index 9a38cdc8..00000000 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ /dev/null @@ -1,278 +0,0 @@ -// -// ComposeHostingController.swift -// Tusker -// -// Created by Shadowfacts on 8/22/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI -import Combine -import Pachyderm -import PencilKit -import Duckable - -protocol ComposeHostingControllerDelegate: AnyObject { - func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool -} - -class ComposeHostingController: UIHostingController, DuckableViewController { - - weak var delegate: ComposeHostingControllerDelegate? - weak var duckableDelegate: DuckableViewControllerDelegate? - - let mastodonController: MastodonController - - let uiState: ComposeUIState - - var draft: OldDraft { uiState.draft } - - private var cancellables = [AnyCancellable]() - - init(draft: OldDraft? = nil, mastodonController: MastodonController) { - self.mastodonController = mastodonController - let realDraft = draft ?? OldDraft(accountID: mastodonController.accountInfo!.id) - OldDraftsManager.shared.add(realDraft) - - self.uiState = ComposeUIState(draft: realDraft) - - let wrapper = Wrapper( - mastodonController: mastodonController, - uiState: uiState - ) - super.init(rootView: wrapper) - - self.uiState.delegate = self - pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self) - userActivity = UserActivityManager.newPostActivity(accountID: mastodonController.accountInfo!.id) - - updateNavigationTitle(draft: uiState.draft) - - self.uiState.$draft - .flatMap(\.objectWillChange) - .debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility)) - .sink { - OldDraftsManager.save() - } - .store(in: &cancellables) - - self.uiState.$draft - .sink { [unowned self] draft in - self.updateNavigationTitle(draft: draft) - } - .store(in: &cancellables) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func updateNavigationTitle(draft: OldDraft) { - if let id = draft.inReplyToID, - let status = mastodonController.persistentContainer.status(for: id) { - navigationItem.title = "Reply to @\(status.account.acct)" - } else { - navigationItem.title = "New Post" - } - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - if !draft.hasContent { - OldDraftsManager.shared.remove(draft) - } - OldDraftsManager.save() - } - - override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool { - guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false } - if mastodonController.instanceFeatures.mastodonAttachmentRestrictions { - guard draft.attachments.allSatisfy({ $0.data.type == .image }) else { return false } - // todo: if providers are videos, this technically allows invalid video/image combinations - return itemProviders.count + draft.attachments.count <= 4 - } else { - return true - } - } - - override func paste(itemProviders: [NSItemProvider]) { - for provider in itemProviders where provider.canLoadObject(ofClass: CompositionAttachment.self) { - provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in - guard let attachment = object as? CompositionAttachment else { return } - DispatchQueue.main.async { - self.draft.attachments.append(attachment) - } - } - } - } - - override func accessibilityPerformEscape() -> Bool { - dismissCompose(mode: .cancel) - return true - } - - // MARK: Duckable - - func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) { - withAnimation(.linear(duration: duration).delay(delay)) { - uiState.isDucking = true - } - } - - func duckableViewControllerDidFinishAnimatingDuck() { - uiState.isDucking = false - } - - // MARK: Interaction - - @objc func cwButtonPressed() { - draft.contentWarningEnabled = !draft.contentWarningEnabled - } - - @objc func formatButtonPressed(_ sender: UIBarButtonItem) { - let format = StatusFormat.allCases[sender.tag] - uiState.currentInput?.applyFormat(format) - } - - @objc func emojiPickerButtonPressed() { - guard uiState.autocompleteState == nil else { - return - } - uiState.shouldEmojiAutocompletionBeginExpanded = true - uiState.currentInput?.beginAutocompletingEmoji() - } - - @objc func draftsButtonPresed() { - uiState.isShowingDraftsList = true - } - -} - -extension ComposeHostingController { - struct Wrapper: View { - let mastodonController: MastodonController - @ObservedObject var uiState: ComposeUIState - var draft: OldDraft { - uiState.draft - } - - var body: some View { - ComposeView() - .environmentObject(mastodonController) - .environmentObject(uiState) - .environmentObject(draft) - } - } -} - -extension ComposeHostingController: ComposeUIStateDelegate { - var assetPickerDelegate: AssetPickerViewControllerDelegate? { self } - - func dismissCompose(mode: ComposeUIState.DismissMode) { - let dismissed = delegate?.dismissCompose(mode: mode) ?? false - if !dismissed { - self.dismiss(animated: true) - self.duckableDelegate?.duckableViewControllerWillDismiss(animated: true) - } - } - - func presentAssetPickerSheet() { - let picker = AssetPickerViewController() - picker.assetPickerDelegate = self - picker.modalPresentationStyle = .pageSheet - picker.overrideUserInterfaceStyle = .dark - let sheet = picker.sheetPresentationController! - sheet.detents = [.medium(), .large()] - sheet.prefersEdgeAttachedInCompactHeight = true - self.present(picker, animated: true) - } - - func presentComposeDrawing() { - let drawing: PKDrawing - - if case let .edit(id) = uiState.composeDrawingMode, - let attachment = draft.attachments.first(where: { $0.id == id }), - case let .drawing(existingDrawing) = attachment.data { - drawing = existingDrawing - } else { - drawing = PKDrawing() - } - - present(ComposeDrawingNavigationController(editing: drawing, delegate: self), animated: true) - } - - func selectDraft(_ draft: OldDraft) { - if self.draft.hasContent { - OldDraftsManager.save() - } else { - OldDraftsManager.shared.remove(self.draft) - } - uiState.draft = draft - uiState.isShowingDraftsList = false - } -} - -extension ComposeHostingController: AssetPickerViewControllerDelegate { - func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool { - if mastodonController.instanceFeatures.mastodonAttachmentRestrictions { - if (type == .video && draft.attachments.count > 0) || - draft.attachments.contains(where: { $0.data.type == .video }) || - assetPicker.currentCollectionSelectedAssets.contains(where: { $0.type == .video }) { - return false - } - return draft.attachments.count + assetPicker.currentCollectionSelectedAssets.count < 4 - } else { - return true - } - } - - func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData]) { - let attachments = attachments.map { - CompositionAttachment(data: $0) - } - withAnimation { - draft.attachments.append(contentsOf: attachments) - } - } -} - -// superseded by duckable stuff -@available(iOS, obsoleted: 16.0) -extension ComposeHostingController: UIAdaptivePresentationControllerDelegate { - func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { - return Preferences.shared.automaticallySaveDrafts || !draft.hasContent - } - - func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: self, for: nil) - } - - func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { - uiState.isShowingSaveDraftSheet = true - } - - func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { - OldDraftsManager.save() - } -} - -extension ComposeHostingController: ComposeDrawingViewControllerDelegate { - func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController) { - dismiss(animated: true) - } - - func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing) { - switch uiState.composeDrawingMode { - case nil, .createNew: - let attachment = CompositionAttachment(data: .drawing(drawing)) - draft.attachments.append(attachment) - - case let .edit(id): - let existing = draft.attachments.first { $0.id == id } - existing?.data = .drawing(drawing) - } - - dismiss(animated: true) - } -} diff --git a/Tusker/Screens/Compose/ComposePollView.swift b/Tusker/Screens/Compose/ComposePollView.swift deleted file mode 100644 index 073b29e6..00000000 --- a/Tusker/Screens/Compose/ComposePollView.swift +++ /dev/null @@ -1,232 +0,0 @@ -// -// ComposePollView.swift -// Tusker -// -// Created by Shadowfacts on 4/28/21. -// Copyright © 2021 Shadowfacts. All rights reserved. -// - -import SwiftUI -import TuskerComponents - -struct ComposePollView: View { - private static let formatter: DateComponentsFormatter = { - let f = DateComponentsFormatter() - f.maximumUnitCount = 1 - f.unitsStyle = .full - f.allowedUnits = [.weekOfMonth, .day, .hour, .minute] - return f - }() - - @ObservedObject var draft: OldDraft - @ObservedObject var poll: OldDraft.Poll - - @EnvironmentObject var mastodonController: MastodonController - @Environment(\.colorScheme) var colorScheme: ColorScheme - - @State private var duration: Duration - - init(draft: OldDraft, poll: OldDraft.Poll) { - self.draft = draft - self.poll = poll - - self._duration = State(initialValue: .fromTimeInterval(poll.duration) ?? .oneDay) - } - - private var canAddOption: Bool { - if let pollConfig = mastodonController.instance?.pollsConfiguration { - return poll.options.count < pollConfig.maxOptions - } else { - return true - } - } - - var body: some View { - VStack { - HStack { - Text("Poll") - .font(.headline) - - Spacer() - - Button(action: self.removePoll) { - Image(systemName: "xmark") - .imageScale(.small) - .padding(4) - } - .accessibilityLabel("Remove poll") - .buttonStyle(.plain) - .accentColor(buttonForegroundColor) - .background(Circle().foregroundColor(buttonBackgroundColor)) - .hoverEffect() - } - - List { - ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in - ComposePollOption(poll: poll, option: e.element, optionIndex: e.offset) - .frame(height: 36) - .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } - .onMove { indices, newIndex in - poll.options.move(fromOffsets: indices, toOffset: newIndex) - } - } - .listStyle(.plain) - .frame(height: 44 * CGFloat(poll.options.count)) - - Button(action: self.addOption) { - Label { - Text("Add Option") - } icon: { - Image(systemName: "plus") - .foregroundColor(.accentColor) - } - } - .buttonStyle(.borderless) - .disabled(!canAddOption) - - HStack { - MenuPicker(selection: $poll.multiple, options: [ - .init(value: true, title: "Allow multiple"), - .init(value: false, title: "Single choice"), - ]) - .frame(maxWidth: .infinity) - - MenuPicker(selection: $duration, options: Duration.allCases.map { - .init(value: $0, title: ComposePollView.formatter.string(from: $0.timeInterval)!) - }) - .frame(maxWidth: .infinity) - } - } - .padding(8) - .background( - backgroundColor - .cornerRadius(10) - ) - .onChange(of: duration, perform: { (value) in - poll.duration = value.timeInterval - }) - } - - private var backgroundColor: Color { - // in light mode, .secondarySystemBackground has a blue-ish hue, which we don't want - colorScheme == .dark ? Color.appFill : Color(white: 0.95) - } - - private var buttonBackgroundColor: Color { - Color(white: colorScheme == .dark ? 0.1 : 0.8) - } - - private var buttonForegroundColor: Color { - Color(UIColor.label) - } - - private func removePoll() { - withAnimation { - self.draft.poll = nil - } - } - - private func addOption() { - poll.options.append(OldDraft.Poll.Option("")) - } -} - -extension ComposePollView { - enum Duration: Hashable, Equatable, CaseIterable { - case fiveMinutes, thirtyMinutes, oneHour, sixHours, oneDay, threeDays, sevenDays - - static func fromTimeInterval(_ ti: TimeInterval) -> Duration? { - for it in allCases where it.timeInterval == ti { - return it - } - return nil - } - - var timeInterval: TimeInterval { - switch self { - case .fiveMinutes: - return 5 * 60 - case .thirtyMinutes: - return 30 * 60 - case .oneHour: - return 60 * 60 - case .sixHours: - return 6 * 60 * 60 - case .oneDay: - return 24 * 60 * 60 - case .threeDays: - return 3 * 24 * 60 * 60 - case .sevenDays: - return 7 * 24 * 60 * 60 - } - } - } -} - -struct ComposePollOption: View { - @ObservedObject var poll: OldDraft.Poll - @ObservedObject var option: OldDraft.Poll.Option - let optionIndex: Int - - @EnvironmentObject private var mastodonController: MastodonController - - var body: some View { - HStack(spacing: 4) { - Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, borderWidth: 2) - .animation(.default, value: poll.multiple) - - textField - - Button(action: self.removeOption) { - Image(systemName: "minus.circle.fill") - } - .buttonStyle(.plain) - .foregroundColor(poll.options.count == 1 ? .gray : .red) - .disabled(poll.options.count == 1) - .hoverEffect() - } - } - - private var textField: some View { - var field = ComposeEmojiTextField(text: $option.text, placeholder: "Option \(optionIndex + 1)", maxLength: mastodonController.instance?.pollsConfiguration?.maxCharactersPerOption) - return field.backgroundColor(.appBackground) - } - - private func removeOption() { - poll.options.remove(at: optionIndex) - } - - struct Checkbox: View { - private let radiusFraction: CGFloat - private let size: CGFloat = 20 - private let innerSize: CGFloat - - init(radiusFraction: CGFloat, borderWidth: CGFloat) { - self.radiusFraction = radiusFraction - self.innerSize = self.size - 2 * borderWidth - } - - var body: some View { - ZStack { - Rectangle() - .foregroundColor(.gray) - .frame(width: size, height: size) - .cornerRadius(radiusFraction * size) - - Rectangle() - .foregroundColor(Color(UIColor.appBackground)) - .frame(width: innerSize, height: innerSize) - .cornerRadius(radiusFraction * innerSize) - } - } - } -} - -//struct ComposePollView_Previews: PreviewProvider { -// static var previews: some View { -// ComposePollView() -// } -//} diff --git a/Tusker/Screens/Compose/ComposeReplyView.swift b/Tusker/Screens/Compose/ComposeReplyView.swift deleted file mode 100644 index 48d95247..00000000 --- a/Tusker/Screens/Compose/ComposeReplyView.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// ComposeReplyView.swift -// Tusker -// -// Created by Shadowfacts on 8/22/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI - -struct ComposeReplyView: View { - let status: StatusMO - let rowTopInset: CGFloat - let globalFrameOutsideList: CGRect - - @State private var displayNameHeight: CGFloat? - @State private var contentHeight: CGFloat? - - @EnvironmentObject private var mastodonController: MastodonController - @ObservedObject private var preferences = Preferences.shared - - private let horizSpacing: CGFloat = 8 - - var body: some View { - HStack(alignment: .top, spacing: horizSpacing) { - GeometryReader(content: self.replyAvatarImage) - .frame(width: 50) - - VStack(alignment: .leading, spacing: 0) { - HStack { - AccountDisplayNameLabel(account: status.account, textStyle: .body, emojiSize: 17) - .lineLimit(1) - .layoutPriority(1) - - Text(verbatim: "@\(status.account.acct)") - .font(.system(size: 17, weight: .light)) - .foregroundColor(.secondary) - .lineLimit(1) - - Spacer() - } - .background(GeometryReader { proxy in - Color.clear - .preference(key: DisplayNameHeightPrefKey.self, value: proxy.size.height) - .onPreferenceChange(DisplayNameHeightPrefKey.self) { newValue in - displayNameHeight = newValue - } - }) - - ComposeReplyContentView(status: status, mastodonController: mastodonController) { newHeight in - // otherwise, with long in-reply-to statuses, the main content text view position seems not to update - // and it ends up partially behind the header - DispatchQueue.main.async { - contentHeight = newHeight - } - } - .frame(height: contentHeight ?? 0) - } - } - .frame(minHeight: 50, alignment: .top) - } - - private func replyAvatarImage(geometry: GeometryProxy) -> some View { - // using a coordinate space declared outside of the List doesn't work, so we do the math ourselves - let globalFrame = geometry.frame(in: .global) - let scrollOffset = -(globalFrame.minY - globalFrameOutsideList.minY) - - // add rowTopInset so that the image is always at least rowTopInset away from the top - var offset = scrollOffset + rowTopInset - - // offset can never be less than 0 (i.e., above the top of the in-reply-to content) - offset = max(offset, 0) - - // subtract 50, because we care about where the bottom of the view is but the offset is relative to the top of the view - let maxOffset = max((contentHeight ?? 0) + (displayNameHeight ?? 0) - 50, 0) - - // once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content - offset = min(offset, maxOffset) - - return ComposeAvatarImageView(url: status.account.avatar) - .frame(width: 50, height: 50) - .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50) - .offset(x: 0, y: offset) - .accessibilityHidden(true) - } - -} - -private struct DisplayNameHeightPrefKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = nextValue() - } -} - -//struct ComposeReplyView_Previews: PreviewProvider { -// static var previews: some View { -// ComposeReplyView() -// } -//} diff --git a/Tusker/Screens/Compose/ComposeTextView.swift b/Tusker/Screens/Compose/ComposeTextView.swift deleted file mode 100644 index b5ab6d59..00000000 --- a/Tusker/Screens/Compose/ComposeTextView.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// ComposeTextView.swift -// Tusker -// -// Created by Shadowfacts on 8/18/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI - -struct ComposeTextView: View { - @Binding private var text: String - private let placeholder: Text? - private let minHeight: CGFloat - - private var heightDidChange: ((CGFloat) -> Void)? - private var backgroundColor = UIColor.secondarySystemBackground - - @State private var height: CGFloat? - - init(text: Binding, placeholder: Text?, minHeight: CGFloat = 150) { - self._text = text - self.placeholder = placeholder - self.minHeight = minHeight - } - - var body: some View { - ZStack(alignment: .topLeading) { - Color(backgroundColor) - - if text.isEmpty, let placeholder = placeholder { - placeholder - .font(.body) - .foregroundColor(.secondary) - .offset(x: 4, y: 8) - } - - WrappedTextView( - text: $text, - textDidChange: self.textDidChange, - font: .preferredFont(forTextStyle: .body) - ) - .frame(height: height ?? minHeight) - } - } - - private func textDidChange(textView: UITextView) { - height = max(minHeight, textView.contentSize.height) - heightDidChange?(height!) - } - - func heightDidChange(_ callback: @escaping (CGFloat) -> Void) -> Self { - var copy = self - copy.heightDidChange = callback - return copy - } - - func backgroundColor(_ color: UIColor) -> Self { - var copy = self - copy.backgroundColor = color - return copy - } -} - -struct WrappedTextView: UIViewRepresentable { - typealias UIViewType = UITextView - - @Binding var text: String - var textDidChange: ((UITextView) -> Void)? - var font = UIFont.systemFont(ofSize: 20) - - @Environment(\.isEnabled) private var isEnabled: Bool - - func makeUIView(context: Context) -> UITextView { - let textView = UITextView() - textView.delegate = context.coordinator - textView.isEditable = true - textView.backgroundColor = .clear - textView.font = font - textView.adjustsFontForContentSizeCategory = true - textView.textContainer.lineBreakMode = .byWordWrapping - return textView - } - - func updateUIView(_ uiView: UITextView, context: Context) { - uiView.text = text - uiView.isEditable = isEnabled - context.coordinator.textView = uiView - context.coordinator.text = $text - context.coordinator.didChange = textDidChange - // wait until the next runloop iteration so that SwiftUI view updates have finished and - // the text view knows its new content size - DispatchQueue.main.async { - self.textDidChange?(uiView) - } - } - - func makeCoordinator() -> Coordinator { - return Coordinator(text: $text, didChange: textDidChange) - } - - class Coordinator: NSObject, UITextViewDelegate, ComposeTextViewCaretScrolling { - weak var textView: UITextView? - var text: Binding - var didChange: ((UITextView) -> Void)? - var caretScrollPositionAnimator: UIViewPropertyAnimator? - - init(text: Binding, didChange: ((UITextView) -> Void)?) { - self.text = text - self.didChange = didChange - - super.init() - - NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil) - } - - @objc private func keyboardDidShow() { - guard let textView, - textView.isFirstResponder else { return } - ensureCursorVisible(textView: textView) - } - - func textViewDidChange(_ textView: UITextView) { - text.wrappedValue = textView.text - didChange?(textView) - - ensureCursorVisible(textView: textView) - } - } -} - - -//struct ComposeTextView_Previews: PreviewProvider { -// static var previews: some View { -// ComposeTextView() -// } -//} diff --git a/Tusker/Screens/Compose/ComposeTextViewCaretScrolling.swift b/Tusker/Screens/Compose/ComposeTextViewCaretScrolling.swift deleted file mode 100644 index b2a68561..00000000 --- a/Tusker/Screens/Compose/ComposeTextViewCaretScrolling.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// ComposeTextViewCaretScrolling.swift -// Tusker -// -// Created by Shadowfacts on 11/11/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import UIKit - -protocol ComposeTextViewCaretScrolling: AnyObject { - var caretScrollPositionAnimator: UIViewPropertyAnimator? { get set } -} - -extension ComposeTextViewCaretScrolling { - func ensureCursorVisible(textView: UITextView) { - guard textView.isFirstResponder, - let range = textView.selectedTextRange, - let scrollView = findParentScrollView(of: textView) else { - return - } - - // We use a UIViewProperty animator to change the scroll view position so that we can store the currently - // running one on the Coordinator. This allows us to cancel the running one, preventing multiple animations - // from attempting to change the scroll view offset simultaneously, causing it to jitter around. This can - // happen if the user is pressing return and quickly creating many new lines. - - if let existing = caretScrollPositionAnimator { - existing.stopAnimation(true) - } - - let cursorRect = textView.caretRect(for: range.start) - var rectToMakeVisible = textView.convert(cursorRect, to: scrollView) - - // expand the rect to be three times the cursor height centered on the cursor so that there's - // some space between the bottom of the line of text being edited and the top of the keyboard - rectToMakeVisible.origin.y -= cursorRect.height - rectToMakeVisible.size.height *= 3 - - let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) { - scrollView.scrollRectToVisible(rectToMakeVisible, animated: false) - } - self.caretScrollPositionAnimator = animator - animator.startAnimation() - } - - private func findParentScrollView(of view: UIView) -> UIScrollView? { - var current: UIView = view - while let superview = current.superview { - if let scrollView = superview as? UIScrollView, - scrollView.isScrollEnabled { - return scrollView - } else { - current = superview - } - } - - return nil - } -} diff --git a/Tusker/Screens/Compose/ComposeToolbar.swift b/Tusker/Screens/Compose/ComposeToolbar.swift deleted file mode 100644 index 62132a75..00000000 --- a/Tusker/Screens/Compose/ComposeToolbar.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// ComposeToolbar.swift -// Tusker -// -// Created by Shadowfacts on 11/12/22. -// Copyright © 2022 Shadowfacts. All rights reserved. -// - -import SwiftUI -import Pachyderm -import TuskerComponents - -struct ComposeToolbar: View { - static let height: CGFloat = 44 - private static let visibilityOptions: [MenuPicker.Option] = Visibility.allCases.map { vis in - .init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)") - } - - @ObservedObject var draft: OldDraft - - @EnvironmentObject private var uiState: ComposeUIState - @EnvironmentObject private var mastodonController: MastodonController - @ObservedObject private var preferences = Preferences.shared - @ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22 - @State private var minWidth: CGFloat? - @State private var realWidth: CGFloat? - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 0) { - Button("CW") { - draft.contentWarningEnabled.toggle() - } - .accessibilityLabel(draft.contentWarningEnabled ? "Remove content warning" : "Add content warning") - .padding(5) - .hoverEffect() - - MenuPicker(selection: $draft.visibility, options: Self.visibilityOptions, buttonStyle: .iconOnly) -// // the button has a bunch of extra space by default, but combined with what we add it's too much - .padding(.horizontal, -8) - - if mastodonController.instanceFeatures.localOnlyPosts { - MenuPicker(selection: $draft.localOnly, options: [ - .init(value: true, title: "Local-only", subtitle: "Only \(mastodonController.accountInfo!.instanceURL.host!)", image: UIImage(named: "link.broken")), - .init(value: false, title: "Federated", image: UIImage(systemName: "link")) - ], buttonStyle: .iconOnly) - .padding(.horizontal, -8) - } - - if let currentInput = uiState.currentInput, currentInput.toolbarElements.contains(.emojiPicker) { - Button(action: self.emojiPickerButtonPressed) { - Label("Insert custom emoji", systemImage: "face.smiling") - } - .labelStyle(.iconOnly) - .font(.system(size: imageSize)) - .padding(5) - .hoverEffect() - .transition(.opacity.animation(.linear(duration: 0.2))) - } - - if let currentInput = uiState.currentInput, - currentInput.toolbarElements.contains(.formattingButtons), - preferences.statusContentType != .plain { - Spacer() - - ForEach(StatusFormat.allCases, id: \.rawValue) { format in - Button(action: self.formatAction(format)) { - if let imageName = format.imageName { - Image(systemName: imageName) - .font(.system(size: imageSize)) - } else if let (str, attrs) = format.title { - let container = try! AttributeContainer(attrs, including: \.uiKit) - Text(AttributedString(str, attributes: container)) - } - } - .accessibilityLabel(format.accessibilityLabel) - .padding(5) - .hoverEffect() - .transition(.opacity.animation(.linear(duration: 0.2))) - } - } - - Spacer() - } - .padding(.horizontal, 16) - .frame(minWidth: minWidth) - .background(GeometryReader { proxy in - Color.clear - .preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width) - .onPreferenceChange(ToolbarWidthPrefKey.self) { width in - realWidth = width - } - }) - } - .scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0) - .frame(height: Self.height) - .frame(maxWidth: .infinity) - .background(.regularMaterial, ignoresSafeAreaEdges: .bottom) - .overlay(alignment: .top) { - Divider() - } - .background(GeometryReader { proxy in - Color.clear - .preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width) - .onPreferenceChange(ToolbarWidthPrefKey.self) { width in - minWidth = width - } - }) - } - - private func emojiPickerButtonPressed() { - guard uiState.autocompleteState == nil else { - return - } - uiState.shouldEmojiAutocompletionBeginExpanded = true - uiState.currentInput?.beginAutocompletingEmoji() - } - - private func formatAction(_ format: StatusFormat) -> () -> Void { - { - uiState.currentInput?.applyFormat(format) - } - } -} - -private struct ToolbarWidthPrefKey: PreferenceKey { - static var defaultValue: CGFloat? = nil - static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { - value = nextValue() - } -} - -private extension View { - @available(iOS, obsoleted: 16.0) - @ViewBuilder - func scrollDisabledIfAvailable(_ disabled: Bool) -> some View { - if #available(iOS 16.0, *) { - self.scrollDisabled(disabled) - } else { - self - } - } -} - -struct ComposeToolbar_Previews: PreviewProvider { - static var previews: some View { - ComposeToolbar(draft: OldDraft(accountID: "")) - } -} diff --git a/Tusker/Screens/Compose/ComposeUIState.swift b/Tusker/Screens/Compose/ComposeUIState.swift deleted file mode 100644 index 46e93f7f..00000000 --- a/Tusker/Screens/Compose/ComposeUIState.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// ComposeUIState.swift -// Tusker -// -// Created by Shadowfacts on 8/24/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI - -protocol ComposeUIStateDelegate: AnyObject { - var assetPickerDelegate: AssetPickerViewControllerDelegate? { get } - - func dismissCompose(mode: ComposeUIState.DismissMode) - // @available(iOS, obsoleted: 16.0) - func presentAssetPickerSheet() - func presentComposeDrawing() - func selectDraft(_ draft: OldDraft) - func paste(itemProviders: [NSItemProvider]) -} - -class ComposeUIState: ObservableObject { - - weak var delegate: ComposeUIStateDelegate? - - @Published var draft: OldDraft - @Published var isShowingSaveDraftSheet = false - @Published var isShowingDraftsList = false - @Published var attachmentsMissingDescriptions = Set() - @Published var autocompleteState: AutocompleteState? = nil - @Published var isDucking = false - - var composeDrawingMode: ComposeDrawingMode? - - var shouldEmojiAutocompletionBeginExpanded = false - @Published var currentInput: ComposeInput? - - init(draft: OldDraft) { - self.draft = draft - } - -} - -extension ComposeUIState { - enum ComposeDrawingMode { - case createNew - case edit(id: UUID) - } -} - -extension ComposeUIState { - enum AutocompleteState: Equatable { - case mention(String) - case emoji(String) - case hashtag(String) - } -} - -extension ComposeUIState { - enum DismissMode { - case cancel, post - } -} - -protocol ComposeInput: AnyObject { - var toolbarElements: [ComposeUIState.ToolbarElement] { get } - - func autocomplete(with string: String) - - func applyFormat(_ format: StatusFormat) - - func beginAutocompletingEmoji() -} - -extension ComposeUIState { - enum ToolbarElement { - case emojiPicker - case formattingButtons - } -} diff --git a/Tusker/Screens/Compose/ComposeView.swift b/Tusker/Screens/Compose/ComposeView.swift deleted file mode 100644 index dfc811f0..00000000 --- a/Tusker/Screens/Compose/ComposeView.swift +++ /dev/null @@ -1,376 +0,0 @@ -// -// ComposeView.swift -// Tusker -// -// Created by Shadowfacts on 8/18/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI -import Pachyderm -import Combine -import ComposeUI - -@propertyWrapper struct OptionalStateObject: DynamicProperty { - private class Republisher: ObservableObject { - var cancellable: AnyCancellable? - var wrapped: T? { - didSet { - cancellable?.cancel() - cancellable = wrapped?.objectWillChange - .receive(on: RunLoop.main) - .sink { [unowned self] _ in - self.objectWillChange.send() - } - } - } - } - - @StateObject private var republisher = Republisher() - @State private var object: T? - var wrappedValue: T? { - get { - object - } - nonmutating set { - object = newValue - } - } - - func update() { - republisher.wrapped = wrappedValue - } -} - -struct ComposeView: View { - @EnvironmentObject var mastodonController: MastodonController - @EnvironmentObject var uiState: ComposeUIState - @EnvironmentObject var draft: OldDraft - - @State private var globalFrameOutsideList: CGRect = .zero - @State private var contentWarningBecomeFirstResponder = false - @State private var mainComposeTextViewBecomeFirstResponder = false - @StateObject private var keyboardReader = KeyboardReader() - - @OptionalStateObject private var poster: PostService? - @State private var isShowingPostErrorAlert = false - @State private var postError: PostService.Error? - private var isPosting: Bool { - poster != nil - } - - private let stackPadding: CGFloat = 8 - - private var charactersRemaining: Int { - let limit = mastodonController.instanceFeatures.maxStatusChars - let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0 - return limit - (cwCount + CharacterCounter.count(text: draft.text, for: mastodonController.instanceFeatures)) - } - - private var requiresAttachmentDescriptions: Bool { - guard Preferences.shared.requireAttachmentDescriptions else { return false } - let attachmentIds = draft.attachments.map(\.id) - return attachmentIds.contains { uiState.attachmentsMissingDescriptions.contains($0) } - } - - private var validAttachmentCombination: Bool { - if !mastodonController.instanceFeatures.mastodonAttachmentRestrictions { - return true - } else if draft.attachments.contains(where: { $0.data.type == .video }) && draft.attachments.count > 1 { - return false - } else if draft.attachments.count > 4 { - return false - } - return true - } - - private var postButtonEnabled: Bool { - draft.hasContent - && charactersRemaining >= 0 - && !isPosting - && !requiresAttachmentDescriptions - && validAttachmentCombination - && (draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty }) - } - - var body: some View { - ZStack(alignment: .top) { - // just using .background doesn't work; for some reason it gets inset immediately after the software keyboard is dismissed - Color.appBackground - .edgesIgnoringSafeArea(.all) - - mainList - .scrollDismissesKeyboardInteractivelyIfAvailable() - - if let poster = poster { - // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149 - WrappedProgressView(value: poster.currentStep, total: poster.totalSteps) - } - } - .safeAreaInset(edge: .bottom, spacing: 0) { - if !uiState.isDucking { - VStack(spacing: 0) { - autocompleteSuggestions - .transition(.move(edge: .bottom)) - .animation(.default, value: uiState.autocompleteState) - - ComposeToolbar(draft: draft) - } - // on iPadOS15, the toolbar ends up below the keyboard's toolbar without this - .padding(.bottom, keyboardInset) - .transition(.move(edge: .bottom)) - } - } - .background(GeometryReader { proxy in - Color.clear - .preference(key: GlobalFrameOutsideListPrefKey.self, value: proxy.frame(in: .global)) - .onPreferenceChange(GlobalFrameOutsideListPrefKey.self) { frame in - globalFrameOutsideList = frame - } - }) - .sheet(isPresented: $uiState.isShowingDraftsList) { - DraftsRepresentable(currentDraft: draft, mastodonController: mastodonController) - } - .actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet) - .alert(isPresented: $isShowingPostErrorAlert) { - Alert( - title: Text("Error Posting Status"), - message: Text(postError?.localizedDescription ?? ""), - dismissButton: .default(Text("OK")) - ) - } - .toolbar { - ToolbarItem(placement: .cancellationAction) { cancelButton } - ToolbarItem(placement: .confirmationAction) { postButton } - } - } - - @available(iOS, obsoleted: 16.0) - private var keyboardInset: CGFloat { - if #unavailable(iOS 16.0), - UIDevice.current.userInterfaceIdiom == .pad, - keyboardReader.isVisible { - return 44 - } else { - return 0 - } - } - - @ViewBuilder - private var autocompleteSuggestions: some View { - if let state = uiState.autocompleteState { - ComposeAutocompleteView(autocompleteState: state) - } - } - - private var mainList: some View { - List { - if let id = draft.inReplyToID, - let status = mastodonController.persistentContainer.status(for: id) { - ComposeReplyView( - status: status, - rowTopInset: 8, - globalFrameOutsideList: globalFrameOutsideList - ) - .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8)) - .listRowSeparator(.hidden) - .listRowBackground(Color.appBackground) - } - - header - .listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8)) - .listRowSeparator(.hidden) - .listRowBackground(Color.appBackground) - - if uiState.draft.contentWarningEnabled { - ComposeEmojiTextField( - text: $uiState.draft.contentWarning, - placeholder: "Write your warning here", - becomeFirstResponder: $contentWarningBecomeFirstResponder, - focusNextView: $mainComposeTextViewBecomeFirstResponder - ) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) - .listRowSeparator(.hidden) - .listRowBackground(Color.appBackground) - } - - MainComposeTextView( - draft: draft, - becomeFirstResponder: $mainComposeTextViewBecomeFirstResponder - ) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) - .listRowSeparator(.hidden) - .listRowBackground(Color.appBackground) - - if let poll = draft.poll { - ComposePollView(draft: draft, poll: poll) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) - .listRowSeparator(.hidden) - .listRowBackground(Color.appBackground) - } - - ComposeAttachmentsList( - draft: draft - ) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8)) - .listRowBackground(Color.appBackground) - } - .animation(.default, value: draft.poll?.options.count) - .scrollDismissesKeyboardInteractivelyIfAvailable() - .listStyle(.plain) - .disabled(isPosting) - .onChange(of: draft.contentWarningEnabled) { newValue in - if newValue { - contentWarningBecomeFirstResponder = true - } - } - } - - private var header: some View { - HStack(alignment: .top) { - ComposeCurrentAccount() - .accessibilitySortPriority(1) - - Spacer() - - Text(verbatim: charactersRemaining.description) - .foregroundColor(charactersRemaining < 0 ? .red : .secondary) - .font(Font.body.monospacedDigit()) - .accessibility(label: Text(charactersRemaining < 0 ? "\(-charactersRemaining) characters too many" : "\(charactersRemaining) characters remaining")) - // this should come first, so VO users can back to it from the main compose text view - .accessibilitySortPriority(0) - }.frame(height: 50) - } - - private var cancelButton: some View { - Button(action: self.cancel) { - Text("Cancel") - // otherwise all Buttons in the nav bar are made semibold - .font(.system(size: 17, weight: .regular)) - } - } - - @ViewBuilder - private var postButton: some View { - if draft.hasContent { - Button { - Task { - await self.postStatus() - } - } label: { - Text("Post") - } - .keyboardShortcut(.return, modifiers: .command) - .disabled(!postButtonEnabled) - } else { - Button { - uiState.isShowingDraftsList = true - } label: { - Text("Drafts") - } - } - } - - private func cancel() { - if Preferences.shared.automaticallySaveDrafts { - // draft is already stored in drafts manager, drafts manager is saved by ComposeHostingController.viewWillDisappear - uiState.delegate?.dismissCompose(mode: .cancel) - } else { - // if the draft doesn't have content, it doesn't need to be saved - if draft.hasContent { - uiState.isShowingSaveDraftSheet = true - } else { - OldDraftsManager.shared.remove(draft) - uiState.delegate?.dismissCompose(mode: .cancel) - } - } - } - - private func saveAndCloseSheet() -> ActionSheet { - ActionSheet(title: Text("Do you want to save the current post as a draft?"), buttons: [ - .default(Text("Save Draft"), action: { - // draft is already stored in drafts manager, drafts manager is saved by ComposeHostingController.viewWillDisappear - uiState.isShowingSaveDraftSheet = false - uiState.delegate?.dismissCompose(mode: .cancel) - }), - .destructive(Text("Delete Draft"), action: { - OldDraftsManager.shared.remove(draft) - uiState.isShowingSaveDraftSheet = false - uiState.delegate?.dismissCompose(mode: .cancel) - }), - .cancel(), - ]) - } - - private func postStatus() async { - guard !isPosting, - draft.hasContent else { - return - } - - let poster = PostService(mastodonController: mastodonController, draft: draft) - self.poster = poster - - do { - try await poster.post() - - // wait .25 seconds so the user can see the progress bar has completed - try? await Task.sleep(nanoseconds: 250_000_000) - - uiState.delegate?.dismissCompose(mode: .post) - - } catch let error as PostService.Error { - self.isShowingPostErrorAlert = true - self.postError = error - } catch { - fatalError("Unreachable") - } - - self.poster = nil - } -} - -extension View { - @available(iOS, obsoleted: 16.0) - @ViewBuilder - func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View { - if #available(iOS 16.0, *) { - self.scrollDismissesKeyboard(.interactively) - } else { - self - } - } -} - -private struct GlobalFrameOutsideListPrefKey: PreferenceKey { - static var defaultValue: CGRect = .zero - static func reduce(value: inout CGRect, nextValue: () -> CGRect) { - value = nextValue() - } -} - -@available(iOS, obsoleted: 16.0) -private class KeyboardReader: ObservableObject { - @Published var isVisible = false - - init() { - NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(willHide), name: UIResponder.keyboardWillHideNotification, object: nil) - } - - @objc func willShow(_ notification: Foundation.Notification) { - // when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible" - let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect - isVisible = endFrame.height > 72 - } - - @objc func willHide() { - isVisible = false - } -} - -//struct ComposeView_Previews: PreviewProvider { -// static var previews: some View { -// ComposeView() -// } -//} diff --git a/Tusker/Screens/Compose/DraftsView.swift b/Tusker/Screens/Compose/DraftsView.swift deleted file mode 100644 index 97dc58b1..00000000 --- a/Tusker/Screens/Compose/DraftsView.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// DraftsView.swift -// Tusker -// -// Created by Shadowfacts on 11/9/22. -// Copyright © 2022 Shadowfacts. All rights reserved. -// - -import SwiftUI - -@available(iOS, obsoleted: 16.0) -struct DraftsRepresentable: UIViewControllerRepresentable { - typealias UIViewControllerType = UIHostingController - - let currentDraft: OldDraft - let mastodonController: MastodonController - - func makeUIViewController(context: Context) -> UIHostingController { - return UIHostingController(rootView: DraftsView(currentDraft: currentDraft, mastodonController: mastodonController)) - } - - func updateUIViewController(_ uiViewController: UIHostingController, context: Context) { - } -} - -struct DraftsView: View { - let currentDraft: OldDraft - // don't pass this in via the environment b/c it crashes on macOS (at least, in Designed for iPad mode) since the environment doesn't get propagated through the modal popup window or something - let mastodonController: MastodonController - @EnvironmentObject var uiState: ComposeUIState - @StateObject private var draftsManager = OldDraftsManager.shared - @State private var draftForDifferentReply: OldDraft? - - private var visibleDrafts: [OldDraft] { - draftsManager.sorted.filter { - $0.accountID == mastodonController.accountInfo!.id && $0.id != currentDraft.id - } - } - - var body: some View { -// NavigationView { -// List { -// ForEach(visibleDrafts) { draft in -// Button { -// maybeSelectDraft(draft) -// } label: { -// DraftView(draft: draft) -// } -// .contextMenu { -// Button(role: .destructive) { -// OldDraftsManager.remove(draft) -// } label: { -// Label("Delete Draft", systemImage: "trash") -// } -// } -// .onDrag { -// let activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: mastodonController.accountInfo!.id) -// activity.displaysAuxiliaryScene = true -// return NSItemProvider(object: activity) -// } -// } -// .onDelete { indices in -// indices -// .map { visibleDrafts[$0] } -// .forEach { OldDraftsManager.remove($0) } -// } -// .appGroupedListRowBackground() -// } -// .listStyle(.plain) -// .appGroupedListBackground(container: DraftsRepresentable.UIViewControllerType.self) -// .navigationTitle(Text("Drafts")) -// .navigationBarTitleDisplayMode(.inline) -// .toolbar { -// ToolbarItem(placement: .cancellationAction) { -// Button("Cancel") { -// uiState.isShowingDraftsList = false -// } -// } -// } -// } -// .alertWithData("Different Reply", data: $draftForDifferentReply) { draft in -// Button("Cancel", role: .cancel) { -// draftForDifferentReply = nil -// } -// Button("Restore Draft") { -// uiState.delegate?.selectDraft(draft) -// } -// } message: { draft in -// Text("The selected draft is a reply to a different post, do you wish to use it?") -// } - Text("drafts") - } - - private func maybeSelectDraft(_ draft: OldDraft) { - if draft.inReplyToID != currentDraft.inReplyToID, - currentDraft.hasContent { - draftForDifferentReply = draft - } else { - uiState.delegate?.selectDraft(draft) - } - } -} - -struct DraftView: View { - @ObservedObject private var draft: OldDraft - - init(draft: OldDraft) { - self._draft = ObservedObject(wrappedValue: draft) - } - - var body: some View { - HStack { - VStack(alignment: .leading) { - if draft.contentWarningEnabled { - Text(draft.contentWarning) - .font(.body.bold()) - .foregroundColor(.secondary) - } - - Text(draft.text) - .font(.body) - - HStack(spacing: 8) { - ForEach(draft.attachments) { attachment in - ComposeAttachmentImage(attachment: attachment, fullSize: false) - .frame(width: 50, height: 50) - .cornerRadius(5) - } - } - } - - Spacer() - - Text(draft.lastModified.timeAgoString()) - .font(.body) - .foregroundColor(.secondary) - } - } -} - -//struct DraftsView_Previews: PreviewProvider { -// static var previews: some View { -// DraftsView(currentDraft: Draft(accountID: "")) -// } -//} diff --git a/Tusker/Screens/Compose/MainComposeTextView.swift b/Tusker/Screens/Compose/MainComposeTextView.swift deleted file mode 100644 index f7d29904..00000000 --- a/Tusker/Screens/Compose/MainComposeTextView.swift +++ /dev/null @@ -1,443 +0,0 @@ -// -// MainComposeTextView.swift -// Tusker -// -// Created by Shadowfacts on 8/29/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import SwiftUI -import Pachyderm - -struct MainComposeTextView: View, PlaceholderViewProvider { - @ObservedObject var draft: OldDraft - @State private var placeholder: PlaceholderView = Self.placeholderView() - - let minHeight: CGFloat = 150 - @State private var height: CGFloat? - @Binding var becomeFirstResponder: Bool - @State private var hasFirstAppeared = false - @ScaledMetric private var fontSize = 20 - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - ZStack(alignment: .topLeading) { - colorScheme == .dark ? Color.appFill : Color(uiColor: .secondarySystemBackground) - - if draft.text.isEmpty { - placeholder - .font(.system(size: fontSize)) - .foregroundColor(.secondary) - .offset(x: 4, y: 8) - .accessibilityHidden(true) - } - - MainComposeWrappedTextView( - text: $draft.text, - visibility: draft.visibility, - becomeFirstResponder: $becomeFirstResponder - ) { (textView) in - self.height = max(textView.contentSize.height, minHeight) - } - } - .frame(height: height ?? minHeight) - .onAppear { - if !hasFirstAppeared { - hasFirstAppeared = true - becomeFirstResponder = true - } - } - } - - @ViewBuilder - static func placeholderView() -> some View { - let components = Calendar.current.dateComponents([.month, .day], from: Date()) - if components.month == 3 && components.day == 14, - Date().formatted(date: .numeric, time: .omitted).starts(with: "3") { - Text("Happy π day!") - } else if components.month == 4 && components.day == 1 { - Text("April Fool's!").rotationEffect(.radians(.pi), anchor: .center) - } else if components.month == 9 && components.day == 5 { - // https://weirder.earth/@noracodes/109276419847254552 - // https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990 - Text("This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing.").italic() - } else if components.month == 9 && components.day == 21 { - Text("Do you remember?") - } else if components.month == 10 && components.day == 31 { - if .random() { - Text("Post something spooky!") - } else { - Text("Any questions?") - } - } else { - Text("What's on your mind?") - } - } -} - -// exists to provide access to the type alias since the @State property needs it to be explicit -private protocol PlaceholderViewProvider { - associatedtype PlaceholderView: View - @ViewBuilder - static func placeholderView() -> PlaceholderView -} - -struct MainComposeWrappedTextView: UIViewRepresentable { - typealias UIViewType = UITextView - - @Binding var text: String - let visibility: Pachyderm.Visibility - @Binding var becomeFirstResponder: Bool - var textDidChange: (UITextView) -> Void - - @EnvironmentObject var uiState: ComposeUIState - @EnvironmentObject var mastodonController: MastodonController - @ObservedObject var preferences = Preferences.shared - @Environment(\.isEnabled) var isEnabled: Bool - - func makeUIView(context: Context) -> UITextView { - let textView = WrappedTextView(uiState: uiState) - textView.delegate = context.coordinator - textView.isEditable = true - textView.backgroundColor = .clear - textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 20)) - textView.adjustsFontForContentSizeCategory = true - textView.textContainer.lineBreakMode = .byWordWrapping - context.coordinator.textView = textView - return textView - } - - func updateUIView(_ uiView: UITextView, context: Context) { - if context.coordinator.skipSettingTextOnNextUpdate { - context.coordinator.skipSettingTextOnNextUpdate = false - } else { - context.coordinator.skipNextAutocompleteUpdate = true - uiView.text = text - } - - uiView.isEditable = isEnabled - uiView.keyboardType = preferences.useTwitterKeyboard ? .twitter : .default - - context.coordinator.text = $text - context.coordinator.didChange = textDidChange - context.coordinator.uiState = uiState - - // wait until the next runloop iteration so that SwiftUI view updates have finished and - // the text view knows its new content size - DispatchQueue.main.async { - self.textDidChange(uiView) - - if becomeFirstResponder { - // calling becomeFirstResponder during the SwiftUI update causes a crash on iOS 13 - uiView.becomeFirstResponder() - // can't update @State vars during the SwiftUI update - becomeFirstResponder = false - } - } - } - - func makeCoordinator() -> Coordinator { - return Coordinator(text: $text, uiState: uiState, didChange: textDidChange) - } - - class WrappedTextView: UITextView { - private let formattingActions = [#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))] - unowned var uiState: ComposeUIState - - init(uiState: ComposeUIState) { - self.uiState = uiState - super.init(frame: .zero, textContainer: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { - if formattingActions.contains(action) { - return Preferences.shared.statusContentType != .plain - } - - return super.canPerformAction(action, withSender: sender) - } - - override func toggleBoldface(_ sender: Any?) { - (delegate as! Coordinator).applyFormat(.bold) - } - - override func toggleItalics(_ sender: Any?) { - (delegate as! Coordinator).applyFormat(.italics) - } - - override func validate(_ command: UICommand) { - super.validate(command) - - if formattingActions.contains(command.action), - Preferences.shared.statusContentType != .plain { - command.attributes.remove(.disabled) - } - } - - override func paste(_ sender: Any?) { - // we deliberately exclude the other CompositionAttachment readable type identifiers, because that's too overzealous with the conversion - // and things like URLs end up pasting as attachments - if UIPasteboard.general.contains(pasteboardTypes: UIImage.readableTypeIdentifiersForItemProvider) { - uiState.delegate?.paste(itemProviders: UIPasteboard.general.itemProviders) - } else { - super.paste(sender) - } - } - - } - - class Coordinator: NSObject, UITextViewDelegate, ComposeInput, ComposeTextViewCaretScrolling { - weak var textView: UITextView? - var text: Binding - var didChange: (UITextView) -> Void - // break retained cycle through ComposeUIState.currentInput - unowned var uiState: ComposeUIState - var caretScrollPositionAnimator: UIViewPropertyAnimator? - - var skipSettingTextOnNextUpdate = false - var skipNextAutocompleteUpdate = false - - var toolbarElements: [ComposeUIState.ToolbarElement] { - [.emojiPicker, .formattingButtons] - } - - init(text: Binding, uiState: ComposeUIState, didChange: @escaping (UITextView) -> Void) { - self.text = text - self.didChange = didChange - self.uiState = uiState - - super.init() - - NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil) - } - - @objc private func keyboardDidShow() { - guard let textView, - textView.isFirstResponder else { return } - ensureCursorVisible(textView: textView) - } - - func textViewDidChange(_ textView: UITextView) { - text.wrappedValue = textView.text - didChange(textView) - - ensureCursorVisible(textView: textView) - } - - func applyFormat(_ format: StatusFormat) { - guard let textView = textView, - textView.isFirstResponder, - let insertionResult = format.insertionResult else { - return - } - - let currentSelectedRange = textView.selectedRange - if currentSelectedRange.length == 0 { - textView.insertText(insertionResult.prefix + insertionResult.suffix) - textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: 0) - } else { - let start = textView.text.index(textView.text.startIndex, offsetBy: currentSelectedRange.lowerBound) - let end = textView.text.index(textView.text.startIndex, offsetBy: currentSelectedRange.upperBound) - let selectedText = textView.text[start.. UIMenu? { - var actions = suggestedActions - if Preferences.shared.statusContentType != .plain, - let index = suggestedActions.firstIndex(where: { ($0 as? UIMenu)?.identifier.rawValue == "com.apple.menu.format" }) { - if range.length > 0 { - let formatMenu = suggestedActions[index] as! UIMenu - let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in - var image: UIImage? - if let imageName = fmt.imageName { - image = UIImage(systemName: imageName) - } - return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in - self?.applyFormat(fmt) - } - }) - actions[index] = newFormatMenu - } else { - actions.remove(at: index) - } - } - if range.length == 0 { - actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in - self?.uiState.shouldEmojiAutocompletionBeginExpanded = true - self?.beginAutocompletingEmoji() - })) - } - return UIMenu(children: actions) - } - - func beginAutocompletingEmoji() { - guard let textView = textView else { - return - } - var insertSpace = false - if let text = textView.text, - textView.selectedRange.upperBound > 0 { - let characterBeforeCursorIndex = text.utf16.index(before: text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound)) - insertSpace = !text[characterBeforeCursorIndex].isWhitespace - } - textView.insertText((insertSpace ? " " : "") + ":") - } - - func autocomplete(with string: String) { - guard let textView = textView, - let text = textView.text, - let (lastWordStartIndex, _) = findAutocompleteLastWord() else { - return - } - - let distanceToEnd = text.utf16.count - textView.selectedRange.upperBound - - let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound) - - let insertSpace: Bool - if distanceToEnd > 0 { - let charAfterCursor = text[characterBeforeCursorIndex] - insertSpace = charAfterCursor != " " && charAfterCursor != "\n" - } else { - insertSpace = true - } - let string = insertSpace ? string + " " : string - - textView.text.replaceSubrange(lastWordStartIndex.. text.startIndex { - // if the character before the "word" beginning is a valid part of a "word", - // we aren't able to autocomplete - let c = text[text.index(before: lastWordStartIndex)] - if isPermittedForAutocomplete(c) || triggerChars.contains(c) { - uiState.autocompleteState = nil - return - } - } - - let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound) - - if lastWordStartIndex >= text.startIndex { - let lastWord = text[lastWordStartIndex.. Bool { - return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_" - } - - private func findAutocompleteLastWord() -> (index: String.Index, foundFirstAtSign: Bool)? { - guard let textView = textView, - textView.isFirstResponder, - textView.selectedRange.length == 0, - textView.selectedRange.upperBound > 0, - let text = textView.text, - text.count > 0 else { - return nil - } - - let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound) - - var lastWordStartIndex = text.index(before: characterBeforeCursorIndex) - var foundFirstAtSign = false - while true { - let c = text[lastWordStartIndex] - - if !isPermittedForAutocomplete(c) { - if foundFirstAtSign { - if c != "@" { - // move the index forward by 1, so that the first char of the substring is the 1st @ instead of whatever comes before it - lastWordStartIndex = text.index(after: lastWordStartIndex) - } - break - } else { - if c == "@" { - foundFirstAtSign = true - } else if c != "." { - // periods are allowed for domain names in mentions - break - } - } - } - - if lastWordStartIndex > text.startIndex { - lastWordStartIndex = text.index(before: lastWordStartIndex) - } else { - break - } - } - - return (lastWordStartIndex, foundFirstAtSign) - } - } -} diff --git a/Tusker/Screens/Customize Timelines/EditFilterView.swift b/Tusker/Screens/Customize Timelines/EditFilterView.swift index 26cdb64f..cd2e5820 100644 --- a/Tusker/Screens/Customize Timelines/EditFilterView.swift +++ b/Tusker/Screens/Customize Timelines/EditFilterView.swift @@ -215,6 +215,18 @@ private struct FilterContextToggleStyle: ToggleStyle { } } +private extension View { + @available(iOS, obsoleted: 16.0) + @ViewBuilder + func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View { + if #available(iOS 16.0, *) { + self.scrollDismissesKeyboard(.interactively) + } else { + self + } + } +} + //struct EditFilterView_Previews: PreviewProvider { // static var previews: some View { // EditFilterView() diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift index eefdfc3b..f989444e 100644 --- a/Tusker/Screens/Main/MainTabBarViewController.swift +++ b/Tusker/Screens/Main/MainTabBarViewController.swift @@ -224,7 +224,7 @@ extension MainTabBarViewController { case .notifications: return NotificationsPageViewController(mastodonController: mastodonController) case .compose: - return ComposeHostingController(mastodonController: mastodonController) + return NewComposeHostingController(draft: nil, mastodonController: mastodonController) case .explore: return ExploreViewController(mastodonController: mastodonController) case .myProfile: @@ -274,8 +274,9 @@ extension MainTabBarViewController: StateRestorableViewController { func stateRestorationActivity() -> NSUserActivity? { var activity: NSUserActivity? if let presentedNav = presentedViewController as? UINavigationController, - let compose = presentedNav.viewControllers.first as? ComposeHostingController { - activity = UserActivityManager.editDraftActivity(id: compose.draft.id, accountID: compose.draft.accountID) + let compose = presentedNav.viewControllers.first as? NewComposeHostingController { + let draft = compose.controller.draft + activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: draft.accountID) } else if let vc = (selectedViewController as! UINavigationController).topViewController as? StateRestorableViewController { activity = vc.stateRestorationActivity() } diff --git a/Tusker/Screens/Mute/MuteAccountView.swift b/Tusker/Screens/Mute/MuteAccountView.swift index e6903b97..954f1711 100644 --- a/Tusker/Screens/Mute/MuteAccountView.swift +++ b/Tusker/Screens/Mute/MuteAccountView.swift @@ -57,9 +57,12 @@ struct MuteAccountView: View { Form { Section { HStack { - ComposeAvatarImageView(url: account.avatar) - .frame(width: 50, height: 50) - .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50) + AvatarImageView( + url: account.avatar, + size: 50, + style: Preferences.shared.avatarStyle == .circle ? .circle : .roundRect, + fetchAvatar: { await ImageCache.avatars.get($0).1 } + ) VStack(alignment: .leading) { AccountDisplayNameLabel(account: account, textStyle: .headline, emojiSize: 17) diff --git a/Tusker/Screens/Report/ReportView.swift b/Tusker/Screens/Report/ReportView.swift index 5b85a79c..14b5ede9 100644 --- a/Tusker/Screens/Report/ReportView.swift +++ b/Tusker/Screens/Report/ReportView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import TuskerComponents struct ReportView: View { @@ -46,10 +47,13 @@ struct ReportView: View { Form { Section { HStack { - ComposeAvatarImageView(url: account.avatar) - .frame(width: 50, height: 50) - .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50) - + AvatarImageView( + url: account.avatar, + size: 50, + style: Preferences.shared.avatarStyle == .circle ? .circle : .roundRect, + fetchAvatar: { await ImageCache.avatars.get($0).1 } + ) + VStack(alignment: .leading) { AccountDisplayNameLabel(account: account, textStyle: .headline, emojiSize: 17) Text("@\(account.acct)") @@ -97,9 +101,19 @@ struct ReportView: View { .appGroupedListRowBackground() Section { - ComposeTextView(text: $report.comment, placeholder: Text("Add any additional comments")) - .backgroundColor(.clear) - .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) + ZStack(alignment: .topLeading) { + if report.comment.isEmpty { + Text("Add any additional comments") + .offset(x: 4, y: 8) + .foregroundColor(.secondary) + } + + TextEditor(text: $report.comment) + .background(.clear) + .frame(minHeight: 100) + } + .font(.body) + .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) } .appGroupedListRowBackground()