Remove old compose screen code

This commit is contained in:
Shadowfacts 2023-04-16 13:47:06 -04:00
parent 6b4223a9d6
commit afed157f29
35 changed files with 144 additions and 3938 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@
import SwiftUI
import Pachyderm
import TuskerComponents
struct CurrentAccountView: View {
let account: (any AccountProtocol)?
@ -14,7 +15,12 @@ struct CurrentAccountView: View {
var body: some View {
HStack(alignment: .top) {
AvatarImageView(url: account?.avatar, size: 50)
AvatarImageView(
url: account?.avatar,
size: 50,
style: controller.config.avatarStyle,
fetchAvatar: controller.fetchAvatar
)
.accessibilityHidden(true)
if let account {

View File

@ -7,6 +7,7 @@
import SwiftUI
import Pachyderm
import TuskerComponents
struct ReplyStatusView: View {
let status: any StatusProtocol
@ -75,7 +76,12 @@ 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)
return AvatarImageView(
url: status.account.avatar,
size: 50,
style: controller.config.avatarStyle,
fetchAvatar: controller.fetchAvatar
)
.offset(x: 0, y: offset)
.accessibilityHidden(true)
}

View File

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

View File

@ -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 = "<group>"; };
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
D621733228F1D5ED004C7DB1 /* ReblogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReblogService.swift; sourceTree = "<group>"; };
D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentsList.swift; sourceTree = "<group>"; };
D622757724EE133700B82A16 /* ComposeAssetPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAssetPicker.swift; sourceTree = "<group>"; };
D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentRow.swift; sourceTree = "<group>"; };
D622759F24F1677200B82A16 /* ComposeHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeHostingController.swift; sourceTree = "<group>"; };
D62275A524F1C81800B82A16 /* ComposeReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyView.swift; sourceTree = "<group>"; };
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyContentView.swift; sourceTree = "<group>"; };
D62275A924F1E01C00B82A16 /* ComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTextView.swift; sourceTree = "<group>"; };
D623A53C2635F5590095BD04 /* StatusPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPollView.swift; sourceTree = "<group>"; };
D623A5402635FB3C0095BD04 /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = "<group>"; };
D623A542263634100095BD04 /* PollOptionCheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionCheckboxView.swift; sourceTree = "<group>"; };
@ -519,7 +492,6 @@
D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BasicTableViewCell.xib; sourceTree = "<group>"; };
D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTimelineViewController.swift; sourceTree = "<group>"; };
D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListAccountsViewController.swift; sourceTree = "<group>"; };
D627FF75217E923E00CC0648 /* DraftsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsManager.swift; sourceTree = "<group>"; };
D6285B5221EA708700FE4B39 /* StatusFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFormat.swift; sourceTree = "<group>"; };
D6289E83217B795D0003D1D7 /* LargeImageViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LargeImageViewController.xib; sourceTree = "<group>"; };
D62D2421217AA7E1005076CC /* UserActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityManager.swift; sourceTree = "<group>"; };
@ -584,7 +556,6 @@
D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusStateResolver.swift; sourceTree = "<group>"; };
D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFinishedTableViewCell.swift; sourceTree = "<group>"; };
D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PollFinishedTableViewCell.xib; sourceTree = "<group>"; };
D662AEF1263A4BE10082A153 /* ComposePollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposePollView.swift; sourceTree = "<group>"; };
D663626121360B1900C9CBA2 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
D663626321360D2300C9CBA2 /* AvatarStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStyle.swift; sourceTree = "<group>"; };
D663626B21361C6700C9CBA2 /* Account+Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Preferences.swift"; sourceTree = "<group>"; };
@ -596,10 +567,6 @@
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = "<group>"; };
D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Tusker-Bridging-Header.h"; sourceTree = "<group>"; };
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Pachyderm; path = Packages/Pachyderm; sourceTree = "<group>"; };
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; };
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = "<group>"; };
D677284D24ECC01D00C732D3 /* OldDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OldDraft.swift; sourceTree = "<group>"; };
D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKDrawing+Render.swift"; sourceTree = "<group>"; };
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountAvatarView.swift; sourceTree = "<group>"; };
D67B506C250B291200FAECFB /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; };
@ -665,7 +632,6 @@
D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastAccountSwitcherViewController.swift; sourceTree = "<group>"; };
D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FastAccountSwitcherViewController.xib; sourceTree = "<group>"; };
D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastSwitchingAccountView.swift; sourceTree = "<group>"; };
D6A57407255C53EC00674551 /* ComposeTextViewCaretScrolling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTextViewCaretScrolling.swift; sourceTree = "<group>"; };
D6A5BB2A23BAEF61003BF21D /* APIMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIMocks.swift; sourceTree = "<group>"; };
D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileTests.swift; sourceTree = "<group>"; };
D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelegatingResponse.swift; sourceTree = "<group>"; };
@ -710,9 +676,7 @@
D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = "<group>"; };
D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = "<group>"; };
D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = "<group>"; };
D6BEA248291C6118002F4D01 /* DraftsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsView.swift; sourceTree = "<group>"; };
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; };
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = "<group>"; };
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; };
D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = "<group>"; };
D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = "<group>"; };
@ -727,7 +691,6 @@
D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreViewController.swift; sourceTree = "<group>"; };
D6C94D862139E62700CB5196 /* LargeImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageViewController.swift; sourceTree = "<group>"; };
D6C94D882139E6EC00CB5196 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = "<group>"; };
D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = "<group>"; };
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUniqueTests.swift; sourceTree = "<group>"; };
@ -737,7 +700,6 @@
D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListCollectionViewController.swift; sourceTree = "<group>"; };
D6D12B59292D684600D528E1 /* AccountListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListViewController.swift; sourceTree = "<group>"; };
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = "<group>"; };
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = "<group>"; };
D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
@ -756,7 +718,6 @@
D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; };
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; };
D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; };
D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = "<group>"; };
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; };
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = "<group>"; };
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = "<group>"; };
@ -773,7 +734,6 @@
D6E343B1265AAD6B00C4AA01 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D6E343B5265AAD6B00C4AA01 /* OpenInTusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenInTusker.entitlements; sourceTree = "<group>"; };
D6E343B9265AAD8C00C4AA01 /* Action.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = Action.js; sourceTree = "<group>"; };
D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAutocompleteView.swift; sourceTree = "<group>"; };
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcher.swift; sourceTree = "<group>"; };
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcherTests.swift; sourceTree = "<group>"; };
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiImageView.swift; sourceTree = "<group>"; };
@ -783,7 +743,6 @@
D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkCardCollectionViewCell.swift; sourceTree = "<group>"; };
D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TrendingLinkCardCollectionViewCell.xib; sourceTree = "<group>"; };
D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitNavigationController.swift; sourceTree = "<group>"; };
D6E9CDA7281A427800BBC98E /* PostService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostService.swift; sourceTree = "<group>"; };
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableTextField.swift; sourceTree = "<group>"; };
D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = "<group>"; };
D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISceneSession+MastodonController.swift"; sourceTree = "<group>"; };
@ -796,7 +755,6 @@
D6F6A551291F098700F496A8 /* RenameListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameListService.swift; sourceTree = "<group>"; };
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteListService.swift; sourceTree = "<group>"; };
D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteAccountView.swift; sourceTree = "<group>"; };
D6F6A5582920676800F496A8 /* ComposeToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbar.swift; sourceTree = "<group>"; };
D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = "<group>"; };
D6FA94DF29B52891006AAC51 /* InstanceFeatures */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = InstanceFeatures; path = Packages/InstanceFeatures; sourceTree = "<group>"; };
D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitchingContainerViewController.swift; sourceTree = "<group>"; };
@ -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 */,

View File

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

View File

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

View File

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

View File

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

View File

@ -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? {

View File

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

View File

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

View File

@ -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<M: View, P: View>(@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()
// }
//}

View File

@ -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<Bool>, @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<V: View>: 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()
// }
//}

View File

@ -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> = 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<String>()
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"))
}
}

View File

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

View File

@ -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: )
// }
//}

View File

@ -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<Bool>?
let focusNextView: Binding<Bool>?
private var didChange: ((String) -> Void)? = nil
private var didEndEditing: (() -> Void)? = nil
private var backgroundColor: UIColor? = nil
init(text: Binding<String>, placeholder: String, maxLength: Int? = nil, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = 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<String>!
// break retained cycle through ComposeUIState.currentInput
unowned var uiState: ComposeUIState!
var maxLength: Int?
var didChange: ((String) -> Void)?
var didEndEditing: (() -> Void)?
var focusNextView: Binding<Bool>?
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..<characterBeforeCursorIndex, with: string)
self.didChange(textField)
self.updateAutocompleteState(textField: textField)
// keep the cursor at the same position in the text, immediately after what was inserted
// if we inserted a space, move the cursor 1 farther so it's immediately after the pre-existing space
let insertSpaceOffset = insertSpace ? 0 : 1
let newCursorPosition = textField.position(from: textField.endOfDocument, offset: -distanceToEnd + insertSpaceOffset)!
textField.selectedTextRange = textField.textRange(from: newCursorPosition, to: newCursorPosition)
}
private func updateAutocompleteState(textField: UITextField) {
guard let selectedRange = textField.selectedTextRange,
let text = textField.text,
let lastWordStartIndex = findAutocompleteLastWord(textField: textField) else {
uiState.autocompleteState = nil
return
}
if 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..<cursorIndex]
let exceptFirst = lastWord[lastWord.index(after: lastWord.startIndex)...]
if lastWord.first == ":" {
uiState.autocompleteState = .emoji(String(exceptFirst))
} else {
uiState.autocompleteState = nil
}
} else {
uiState.autocompleteState = nil
}
}
private func isPermittedForAutocomplete(_ c: Character) -> 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
}
}
}

View File

@ -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<ComposeHostingController.Wrapper>, 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)
}
}

View File

@ -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()
// }
//}

View File

@ -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()
// }
//}

View File

@ -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<String>, 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<String>
var didChange: ((UITextView) -> Void)?
var caretScrollPositionAnimator: UIViewPropertyAnimator?
init(text: Binding<String>, 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()
// }
//}

View File

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

View File

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

View File

@ -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<UUID>()
@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
}
}

View File

@ -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<T: ObservableObject>: 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()
// }
//}

View File

@ -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<DraftsView>
let currentDraft: OldDraft
let mastodonController: MastodonController
func makeUIViewController(context: Context) -> UIHostingController<DraftsView> {
return UIHostingController(rootView: DraftsView(currentDraft: currentDraft, mastodonController: mastodonController))
}
func updateUIViewController(_ uiViewController: UIHostingController<DraftsView>, 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: ""))
// }
//}

View File

@ -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<String>
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<String>, 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..<end]
textView.insertText(String(insertionResult.prefix + selectedText + insertionResult.suffix))
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.prefix.utf16.count, length: currentSelectedRange.length)
}
}
func textViewDidBeginEditing(_ textView: UITextView) {
uiState.currentInput = self
updateAutocompleteState()
}
func textViewDidEndEditing(_ textView: UITextView) {
uiState.currentInput = nil
updateAutocompleteState()
}
func textViewDidChangeSelection(_ textView: UITextView) {
// Setting the text view's text causes it to move the cursor to the end (though only
// when the text contains an emoji :/), so skip setting the text on the next SwiftUI update
// that's triggered by setting the autocomplete state.
skipSettingTextOnNextUpdate = true
self.updateAutocompleteState()
}
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> 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..<characterBeforeCursorIndex, with: string)
self.textViewDidChange(textView)
self.updateAutocompleteState()
// keep the cursor at the same position in the text, immediately after what was inserted
// if we inserted a space, move the cursor 1 farther so it's immediately after the pre-existing space
let insertSpaceOffset = insertSpace ? 0 : 1
textView.selectedRange = NSRange(location: textView.text.utf16.count - distanceToEnd + insertSpaceOffset, length: 0)
}
private func updateAutocompleteState() {
guard !skipNextAutocompleteUpdate else {
skipNextAutocompleteUpdate = false
return
}
guard let textView = textView,
let text = textView.text,
let (lastWordStartIndex, foundFirstAtSign) = findAutocompleteLastWord() else {
uiState.autocompleteState = nil
return
}
let triggerChars: [Character] = ["@", ":", "#"]
if 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..<characterBeforeCursorIndex]
let exceptFirst = lastWord[lastWord.index(after: lastWord.startIndex)...]
// periods are only allowed in mentions in the domain part
if lastWord.contains(".") {
if lastWord.first == "@" && foundFirstAtSign {
uiState.autocompleteState = .mention(String(exceptFirst))
} else {
uiState.autocompleteState = nil
}
return
}
switch lastWord.first {
case "@":
uiState.autocompleteState = .mention(String(exceptFirst))
case ":":
uiState.autocompleteState = .emoji(String(exceptFirst))
case "#":
uiState.autocompleteState = .hashtag(String(exceptFirst))
default:
uiState.autocompleteState = nil
}
} else {
uiState.autocompleteState = nil
}
}
private func isPermittedForAutocomplete(_ c: Character) -> 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)
}
}
}

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@
//
import SwiftUI
import TuskerComponents
struct ReportView: View {
@ -46,9 +47,12 @@ 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)
@ -97,8 +101,18 @@ struct ReportView: View {
.appGroupedListRowBackground()
Section {
ComposeTextView(text: $report.comment, placeholder: Text("Add any additional comments"))
.backgroundColor(.clear)
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()