Compare commits

...

98 Commits

Author SHA1 Message Date
fc75b50d7f Fix Compose toolbar not showing on iOS 15 2025-02-26 00:20:02 -05:00
cbfae8c2c7 Remove unused code 2025-02-26 00:19:55 -05:00
c5cc959014 Cache images for MenuPickers, minor perf optimization when opening compose screen 2025-02-26 00:12:34 -05:00
dc7eaf5ada Use input accessory view for compose toolbar 2025-02-25 23:41:39 -05:00
964cef0ff5 Author attribution on status cards
Closes #542
2025-02-25 13:13:40 -05:00
8021868599 Use status context menus for post-like links
Closes #557
2025-02-23 00:48:16 -05:00
8d6c63d5e9 Fix compose main text view sizing on iOS 15 2025-02-23 00:28:28 -05:00
bfee9ea124 Fix poll button animation being weird on iOS 15 2025-02-23 00:15:35 -05:00
1d193dec0f Toast after posting status
Closes #561
2025-02-11 00:34:32 -05:00
33c641cd85 visionOS: Workaround for Compose screen not being opened
See #564
2025-02-10 10:22:56 -05:00
2fc889a5f9 Tweak push notification grouping
Closes #559
2025-02-08 13:42:51 -05:00
a28798bf52 Remove MatchedGeometryPresentation 2025-02-08 13:28:19 -05:00
988b9b4793 Hide compose attachment menu when there are no actions 2025-02-07 12:13:39 -05:00
8c67828185 Bump build number and update changelog 2025-02-07 12:11:21 -05:00
2e3891c468 Fix compiling for Catalyst and visionOS 2025-02-07 12:11:17 -05:00
fc458fc5f3 Remove now-unused feature flag 2025-02-07 11:51:51 -05:00
16dabd5d9f Don't use context menu on attachment cell, since it blocks reordering 2025-02-07 11:44:11 -05:00
558675842a Remove unused ComposeUIConfig properties 2025-02-07 11:17:50 -05:00
a7049d224c Tweak toolbar button spacing 2025-02-07 00:45:36 -05:00
56a408355d Remove old EmojiTextField code 2025-02-07 00:43:44 -05:00
1d81510899 Remove old compose UI 2025-02-07 00:40:05 -05:00
1be3cb77b6 Re-add more missing stuff 2025-02-07 00:38:54 -05:00
042110ec5e Bump build number 2025-02-06 23:36:22 -05:00
a2ffe1bbf1 Fix compiling in release mode 2025-02-06 23:36:18 -05:00
96f5ea8af1 Put compose text view attributed string behavior behind feature flag 2025-02-06 13:33:42 -05:00
e9e08bdadd Use new compose UI for share extension 2025-02-06 13:30:22 -05:00
c7c363782b Temporarily make compose toolbar appear above keyboard 2025-02-06 13:05:32 -05:00
c929c40cc9 Fix poll button not displaying pre-iOS 18 2025-02-06 12:57:20 -05:00
abbe0f82e6 Show edited attachments in gallery 2025-02-06 10:33:24 -05:00
f102ebbfc8 Oops 2025-02-06 10:18:43 -05:00
bf9ec17513 Fix gallery crossfade transition not working when gallery presented from modally-presented VC 2025-02-05 11:33:32 -05:00
fabb5cd257 Prevent live text button from showing below gallery bottom controls 2025-02-05 11:30:27 -05:00
64c377c663 Inset gallery content to safe area when editing attachment description 2025-02-05 11:17:00 -05:00
86e1403230 Fix live text button being behind edit attachment description text view 2025-02-04 19:32:29 -05:00
370b589a6b Disallow interactive gallery dismissal while editing attachment description 2025-02-04 19:14:26 -05:00
4fb7cacf31 Fix tall gallery content being inset on the bottom edge 2025-02-04 18:49:11 -05:00
da4787946d Fix compose attachment gallery dismiss animation on iPad 2025-02-03 15:56:40 -05:00
25e4e35b1f Fix drafts not being saved 2025-02-03 15:38:12 -05:00
fb39a93569 Re-add compose ducking 2025-02-03 15:31:55 -05:00
99a12c58de Let new ComposeView stand alone from ComposeController 2025-02-03 15:08:53 -05:00
ec3678d90d More compose screen tweaks 2025-01-30 21:57:23 -05:00
f46150422a Fix Post button enabling/disabling 2025-01-30 17:23:57 -05:00
221ea05629 Finish poll editor 2025-01-30 13:21:01 -05:00
d87e9e6d92 Merge branch 'develop' into compose-redesign-15 2025-01-30 10:17:02 -05:00
8c8da2988d Bump build number and update changelog 2025-01-30 10:16:45 -05:00
ac78fe2807 WIP poll editing 2025-01-30 10:02:13 -05:00
fbbcb0d07d Drafts list 2025-01-27 15:42:26 -05:00
a7924feb76 Merge branch 'develop' into compose-redesign-15 2025-01-27 11:10:04 -05:00
cb535196e2 Fix signing into API-restricted instances
Closes #556
2024-12-28 23:43:50 -05:00
238f246d64 Bump build number and update changelog 2024-12-26 18:31:07 -05:00
20e7d9ed10 Fix compiling on visionOS 2024-12-26 17:44:41 -05:00
7c43261f9c Revert "Raise min deployment target to iOS 16"
This reverts commit f4b51c06c1107cd8149a8e07a0f652ac6816ee3a.
2024-12-26 17:33:13 -05:00
a35b72d256 Revert "Replace WebURL with URL.ParseStrategy"
This reverts commit adaf8dc217f0857bef9b8dcc7fae0efc3c08bc3d.
2024-12-26 17:31:18 -05:00
666d2c468a Fix gallery controls being positioned incorrectly in landscape 2024-12-26 17:25:49 -05:00
4ea61542a0 Fix gallery buttons changing position during dismiss animation
Closes #554
2024-12-26 17:25:31 -05:00
bee3e53be7 Fix attachments collection view sizing 2024-12-17 15:23:01 -05:00
96fdef0558 Workaround for UIHostingConfiguration requiring iOS 16 2024-12-16 23:46:46 -05:00
5ce9892a9b Bump build number and update changelog 2024-12-16 20:17:44 -05:00
54376ac585 Handle empty urls in OptionalURLDecoder
Closes #553
2024-12-16 19:10:23 -05:00
e0e9d4a185 Remove old compose rewrite code 2024-12-15 21:04:29 -05:00
6730575aed Make things compile on iOS 15 2024-12-15 21:01:41 -05:00
c68902b34b Revert "Raise min deployment target to iOS 16"
This reverts commit f4b51c06c1107cd8149a8e07a0f652ac6816ee3a.
2024-12-15 20:46:05 -05:00
26c483fc9a Bump build number and update changelog 2024-12-15 20:28:08 -05:00
a68d2ce952 Fix compiling for visionOS 2024-12-15 17:07:38 -05:00
adaf8dc217 Replace WebURL with URL.ParseStrategy
Closes #170
2024-12-15 14:20:48 -05:00
572c5a0824 Fix NotificationGroupTests not compiling 2024-12-15 13:52:26 -05:00
e469d207b4 Make "no alt" badge all caps 2024-12-15 13:31:03 -05:00
82ec120871 Include rate limit reset date in error message
Closes #548
2024-12-15 13:27:09 -05:00
242c60d74d Workaround for tab bar content VC not being in responder chain almost ever
Closes #544, #179
2024-12-15 13:18:47 -05:00
20692b0630 Fix links in profile field values not at the beginning of the string not being tappable
Fixes #501
2024-12-07 13:00:54 -05:00
9990d50e3e Revert "Use text view for profile field value view"
This reverts commit c88076eec0599d90b80d059a69f7703863d8fed9.

Closes #521
2024-12-07 13:00:13 -05:00
ad5f45c620 Edit attachments gallery for non-drawings 2024-12-07 12:54:41 -05:00
c564bb4112 Merge branch 'develop' into compose-redesign 2024-12-03 13:55:23 -05:00
670047af6f Fix potential race between adding notification to NSManagedObjectContext and displaying VC 2024-11-26 23:26:35 -05:00
e8a492ef7d Fix potential crash with invalid attachment aspect ratio 2024-11-26 23:17:52 -05:00
583d9b97dd Bump build number and update changelog 2024-11-26 23:10:39 -05:00
88176fe599 Try to fix Live Text control weridness during interactive gallery dismissal 2024-11-26 20:06:23 -05:00
19c3008c8f Better error for when emoji URL decoding fails
Closes #549
2024-11-26 20:06:23 -05:00
51f9f421b8 Fix hang when using switching accounts on iPadOS 18
Closes #550
2024-11-26 20:06:23 -05:00
b700e17d7e Hide video controls in gallery if loading fails
Closes #546
2024-11-26 20:06:23 -05:00
dc01804359 Don't use snapshot for gallery present/dismiss transition of non-static content 2024-11-25 21:05:20 -05:00
a5066140fd Fix potential crash during gifv playback
I don't know how this is possible, but it evidently is
2024-11-25 20:54:35 -05:00
351efe4b58 Fix gallery content scrolling unnecessarily in certain circumstances 2024-11-25 20:40:13 -05:00
c716f03784 More gallery transition tweaks 2024-11-25 19:07:05 -05:00
fa828a5eae Gallery dismiss interaction fixes 2024-11-25 18:38:07 -05:00
56d12295ba Don't dismiss gallery if pan distance/velocity is low
See #520
2024-11-24 23:58:39 -05:00
a442197adf Improve gallery dismiss transition when source view is relatively small compared to content
See #520
2024-11-24 19:26:09 -05:00
a99fb7f0b0 Improve gallery transitions when there is something displaying on top of the source view
See #520
2024-11-24 19:26:06 -05:00
f44dae632c Improve gallery transitions when source/dest aspect ratio don't match
See #520
2024-11-24 18:19:59 -05:00
0dcb67c44e Fix gallery dismiss animation not working when the window's origin is not the screen origin
sourceView is inside to.view, so to.view needs to be added as a subview
of container before we call convert(_:from:)
2024-11-23 10:59:51 -05:00
e869fdc38f Disallow more interactions on non-browsable public instance timeline 2024-11-23 10:52:17 -05:00
ec9673f6c0 Use gallery VC for editing attachment descriptions 2024-11-23 10:47:58 -05:00
8cc9849b36 Merge branch 'develop' into compose-redesign 2024-11-21 19:29:03 -05:00
5c86feccb9 Move content VCs to GalleryVC package 2024-11-21 19:28:55 -05:00
8006b0add9 Prune DraftAttachment managed objects from drafts persistent store 2024-11-20 00:46:13 -05:00
b9e3d8ec5e Compose attachment section 2024-11-20 00:45:21 -05:00
2fb76e322a Start new compose design 2024-11-17 13:14:23 -05:00
01cf597b5d Account for bidi text in combined display/username label 2024-10-22 17:51:58 -04:00
178 changed files with 5285 additions and 5313 deletions

View File

@ -1,3 +1,13 @@
## 2024.5
Features/Improvements:
- Improve gallery animations
Bugfixes:
- Handle right-to-left text in display names
- Fix crash during gifv playback
- iPadOS: Fix app becoming unresponsive when switching accounts
- iPadOS/macOS: Fix Cmd+R shortcuts not working
## 2024.4
This release introduces support for iOS 18, including a new sidebar/tab bar on iPad, as well as bugfixes and improvements.

View File

@ -1,5 +1,45 @@
# Changelog
## 2025.1 (145)
This build introduces the new, redesigned Compose screen. Please report any issues you encounter.
Known issues:
- Autocomplete mentions/emojis/hashtags in Comopose is not available
- Rich text formatting actions in Compose are not available
- Non-pure-black dark mode preference is not respected in Compose
## 2025.1 (142)
Bugfixes:
- Fix signing into API-restricted instances
## 2024.5 (141)
Bugfixes:
- Fix gallery controls being positioned incorrectly during dismiss animation on certain devices
- Fix gallery controls being positioned incorrectly in landscape orientations
## 2024.5 (139)
Bugfixes:
- Fix error decoding certain posts
## 2024.5 (138)
Bugfixes:
- Fix potential crash when displaying certain attachments
- Fix potential crash due to race condition when opening push notification in app
- Fix misaligned text between profile field values/labels
- Fix rate limited error message not including reset timestamp
- iPadOS/macOS: Fix Cmd+R shortcut not working
## 2024.5 (137)
Features/Improvements:
- Improve gallery presentation/dismissal transitions
Bugfixes:
- Account for bidirectional text in display names
- Fix crash when playing back gifv
- Fix gallery controls not hiding if video loading fails
- iPadOS: Fix incorrect gallery dismiss animation on non-fullscreen windows
- iPadOS: Fix hang when switching accounts
## 2024.4 (136)
Features/Improvements:
- Import image description when adding attachments from Photos if possible

View File

@ -175,6 +175,7 @@ class NotificationService: UNNotificationServiceExtension {
} else {
conversationIdentifier = nil
}
content.threadIdentifier = conversationIdentifier ?? ""
let account: Account?
switch notification.kind {
@ -212,7 +213,7 @@ class NotificationService: UNNotificationServiceExtension {
outgoingMessageType: .outgoingMessageText,
content: notificationContent,
speakableGroupName: nil,
conversationIdentifier: conversationIdentifier,
conversationIdentifier: nil,
serviceName: nil,
sender: sender,
attachments: nil
@ -371,13 +372,14 @@ private struct HTMLCallbacks: HTMLConversionCallbacks {
// Converting WebURL to URL is a small but non-trivial expense (since it works by
// serializing the WebURL as a string and then having Foundation parse it again),
// so, if available, use the system parser which doesn't require another round trip.
if let url = try? URL.ParseStrategy().parse(string) {
if #available(iOS 16.0, macOS 13.0, *),
let url = try? URL.ParseStrategy().parse(string) {
url
} else if let web = WebURL(string),
let url = URL(web) {
url
} else {
nil
URL(string: string)
}
}

View File

@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "ComposeUI",
platforms: [
.iOS(.v16),
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
@ -19,15 +19,23 @@ let package = Package(
.package(path: "../Pachyderm"),
.package(path: "../InstanceFeatures"),
.package(path: "../TuskerComponents"),
.package(path: "../MatchedGeometryPresentation"),
.package(path: "../TuskerPreferences"),
.package(path: "../UserAccounts"),
.package(path: "../GalleryVC"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "ComposeUI",
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation", "TuskerPreferences"],
dependencies: [
"Pachyderm",
"InstanceFeatures",
"TuskerComponents",
"TuskerPreferences",
"UserAccounts",
"GalleryVC",
],
swiftSettings: [
.swiftLanguageMode(.v5)
]),

View File

@ -12,22 +12,22 @@ import UniformTypeIdentifiers
@MainActor
final class PostService: ObservableObject {
private let mastodonController: ComposeMastodonContext
private let config: ComposeUIConfig
private let mastodonController: any ComposeMastodonContext
private let contentType: StatusContentType
private let draft: Draft
@Published var currentStep = 1
@Published private(set) var totalSteps = 2
init(mastodonController: ComposeMastodonContext, config: ComposeUIConfig, draft: Draft) {
init(mastodonController: any ComposeMastodonContext, contentType: StatusContentType, draft: Draft) {
self.mastodonController = mastodonController
self.config = config
self.contentType = contentType
self.draft = draft
}
func post() async throws {
func post() async throws(Error) -> Status {
guard draft.hasContent || draft.editedStatusID != nil else {
return
throw .noContent
}
// save before posting, so if a crash occurs during network request, the status won't be lost
@ -45,10 +45,18 @@ final class PostService: ObservableObject {
await updateEditedAttachments()
}
let pollParams: EditPollParameters?
if draft.pollEnabled,
let poll = draft.poll {
pollParams = EditPollParameters(options: poll.pollOptions.map(\.text), expiresIn: Int(poll.duration), multiple: poll.multiple)
} else {
pollParams = nil
}
request = Client.editStatus(
id: editedStatusID,
text: textForPosting(),
contentType: config.contentType,
contentType: contentType,
spoilerText: contentWarning,
sensitive: sensitive,
language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
@ -60,23 +68,35 @@ final class PostService: ObservableObject {
return nil
}
},
poll: draft.poll.map {
EditPollParameters(options: $0.pollOptions.map(\.text), expiresIn: Int($0.duration), multiple: $0.multiple)
}
poll: pollParams
)
} else {
let pollOptions: [String]?
let pollExpiresIn: Int?
let pollMultiple: Bool?
if draft.pollEnabled,
let poll = draft.poll {
pollOptions = poll.pollOptions.map(\.text)
pollExpiresIn = Int(poll.duration)
pollMultiple = poll.multiple
} else {
pollOptions = nil
pollExpiresIn = nil
pollMultiple = nil
}
request = Client.createStatus(
text: textForPosting(),
contentType: config.contentType,
contentType: contentType,
inReplyTo: draft.inReplyToID,
mediaIDs: uploadedAttachments,
sensitive: sensitive,
spoilerText: contentWarning,
visibility: draft.localOnly && mastodonController.instanceFeatures.localOnlyPostsVisibility ? Status.localPostVisibility : draft.visibility.rawValue,
language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
pollOptions: draft.poll?.pollOptions.map(\.text),
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
pollMultiple: draft.poll?.multiple,
pollOptions: pollOptions,
pollExpiresIn: pollExpiresIn,
pollMultiple: pollMultiple,
localOnly: mastodonController.instanceFeatures.localOnlyPosts && !mastodonController.instanceFeatures.localOnlyPostsVisibility ? draft.localOnly : nil,
idempotencyKey: draft.id.uuidString
)
@ -85,13 +105,13 @@ final class PostService: ObservableObject {
do {
let (status, _) = try await mastodonController.run(request)
currentStep += 1
mastodonController.storeCreatedStatus(status)
} catch let error as Client.Error {
return status
} catch {
throw Error.posting(error)
}
}
private func uploadAttachments() async throws -> [String] {
private func uploadAttachments() async throws(Error) -> [String] {
// 2 steps (request data, then upload) for each attachment
self.totalSteps += 2 * draft.attachments.count
@ -111,7 +131,7 @@ final class PostService: ObservableObject {
do {
(data, utType) = try await getData(for: attachment)
currentStep += 1
} catch let error as DraftAttachment.ExportError {
} catch {
throw Error.attachmentData(index: index, cause: error)
}
let uploaded = try await uploadAttachment(index: index, data: data, utType: utType, description: attachment.attachmentDescription)
@ -121,20 +141,21 @@ final class PostService: ObservableObject {
return attachments
}
private func getData(for attachment: DraftAttachment) async throws -> (Data, UTType) {
return try await withCheckedThrowingContinuation { continuation in
private func getData(for attachment: DraftAttachment) async throws(DraftAttachment.ExportError) -> (Data, UTType) {
let result = await withCheckedContinuation { continuation in
attachment.getData(features: mastodonController.instanceFeatures) { result in
switch result {
case let .success(res):
continuation.resume(returning: res)
case let .failure(error):
continuation.resume(throwing: error)
}
continuation.resume(returning: result)
}
}
switch result {
case .success(let result):
return result
case .failure(let error):
throw error
}
}
private func uploadAttachment(index: Int, data: Data, utType: UTType, description: String?) async throws -> Attachment {
private func uploadAttachment(index: Int, data: Data, utType: UTType, description: String?) async throws(Error) -> Attachment {
guard let mimeType = utType.preferredMIMEType else {
throw Error.attachmentMissingMimeType(index: index, type: utType)
}
@ -146,7 +167,7 @@ final class PostService: ObservableObject {
let req = Client.upload(attachment: formAttachment, description: description)
do {
return try await mastodonController.run(req).0
} catch let error as Client.Error {
} catch {
throw Error.attachmentUpload(index: index, cause: error)
}
}
@ -176,6 +197,7 @@ final class PostService: ObservableObject {
}
enum Error: Swift.Error, LocalizedError {
case noContent
case attachmentData(index: Int, cause: DraftAttachment.ExportError)
case attachmentMissingMimeType(index: Int, type: UTType)
case attachmentUpload(index: Int, cause: Client.Error)
@ -183,6 +205,8 @@ final class PostService: ObservableObject {
var localizedDescription: String {
switch self {
case .noContent:
return "No content"
case let .attachmentData(index: index, cause: cause):
return "Attachment \(index + 1): \(cause.localizedDescription)"
case let .attachmentMissingMimeType(index: index, type: type):

View File

@ -30,11 +30,12 @@ enum ToolbarElement {
}
private struct FocusedComposeInput: FocusedValueKey {
typealias Value = any ComposeInput
typealias Value = (any ComposeInput)?
}
extension FocusedValues {
var composeInput: (any ComposeInput)? {
// double optional is necessary pre-iOS 16
var composeInput: (any ComposeInput)?? {
get { self[FocusedComposeInput.self] }
set { self[FocusedComposeInput.self] = newValue }
}
@ -69,3 +70,31 @@ struct FocusedInputModifier: ViewModifier {
.focusedValue(\.composeInput, box.wrappedValue)
}
}
// In the input accessory view toolbar, we get the focused input through the box injected from the ComposeView.
// Otherwise we get it from @FocusedValue (which doesn't seem to work via the hacks we use for the input accessory).
// This property wrapper abstracts over them both.
@propertyWrapper
struct FocusedInput: DynamicProperty {
@Environment(\.toolbarInjectedFocusedInputBox) private var box
@FocusedValue(\.composeInput) private var input
@StateObject private var updater = Updater()
var wrappedValue: (any ComposeInput)? {
box?.wrappedValue ?? input ?? nil
}
func update() {
updater.update(box: box)
}
private class Updater: ObservableObject {
private var cancellable: AnyCancellable?
func update(box: MutableObservableBox<(any ComposeInput)?>?) {
cancellable = box?.objectWillChange.sink { [unowned self] _ in
self.objectWillChange.send()
}
}
}
}

View File

@ -15,7 +15,7 @@ public protocol ComposeMastodonContext {
var accountInfo: UserAccountInfo? { get }
var instanceFeatures: InstanceFeatures { get }
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?)
func run<Result: Sendable>(_ request: Request<Result>) async throws(Client.Error) -> (Result, Pagination?)
func getCustomEmojis() async -> [Emoji]
@ -26,7 +26,5 @@ public protocol ComposeMastodonContext {
@MainActor
func searchCachedHashtags(query: String) -> [Hashtag]
func storeCreatedStatus(_ status: Status)
func fetchStatus(id: String) -> (any StatusProtocol)?
}

View File

@ -10,33 +10,42 @@ import Pachyderm
import PhotosUI
import PencilKit
import TuskerComponents
import GalleryVC
// Configuration/data injected from outside the compose UI.
public struct ComposeUIConfig {
// Config
public var allowSwitchingDrafts = true
public var textSelectionStartsAtBeginning = false
public var showToolbar = true
// Style
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 = AvatarImageView.Style.roundRect
// Preferences
public var useTwitterKeyboard = false
public var contentType = StatusContentType.plain
public var requireAttachmentDescriptions = false
// Host callbacks
public var dismiss: @MainActor (DismissMode) -> Void = { _ in }
public var presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)?
public var presentDrawing: ((PKDrawing, @escaping (PKDrawing) -> Void) -> Void)?
public var userActivityForDraft: ((Draft) -> NSItemProvider?) = { _ in nil }
public var fetchAvatar: AvatarImageView.FetchAvatar = { _ in nil }
public var displayNameLabel: (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView = { _, _, _ in AnyView(EmptyView()) }
public var replyContentView: (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView = { _, _ in AnyView(EmptyView()) }
public var fetchImageAndGIFData: (URL) async -> (UIImage, Data)? = { _ in nil }
public var makeGifvGalleryContentVC: (URL) -> (any GalleryContentViewController)? = { _ in nil }
public init() {
}
}
extension ComposeUIConfig {
private struct ComposeUIConfigEnvironmentKey: EnvironmentKey {
static let defaultValue = ComposeUIConfig()
}
extension EnvironmentValues {
var composeUIConfig: ComposeUIConfig {
get { self[ComposeUIConfigEnvironmentKey.self] }
set { self[ComposeUIConfigEnvironmentKey.self] = newValue }
}
}

View File

@ -1,223 +0,0 @@
//
// AttachmentRowController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/12/23.
//
import SwiftUI
import TuskerComponents
import Vision
import MatchedGeometryPresentation
class AttachmentRowController: ViewController {
let parent: ComposeController
let attachment: DraftAttachment
@Published var descriptionMode: DescriptionMode = .allowEntry
@Published var textRecognitionError: Error?
@Published var focusAttachmentOnTextEditorUnfocus = false
let thumbnailController: AttachmentThumbnailController
private var descriptionObservation: NSKeyValueObservation?
init(parent: ComposeController, attachment: DraftAttachment) {
self.parent = parent
self.attachment = attachment
self.thumbnailController = AttachmentThumbnailController(attachment: attachment, parent: parent)
descriptionObservation = attachment.observe(\.attachmentDescription, changeHandler: { [unowned self] _, _ in
// the faultingState is non-zero for objects that are being cascade deleted when the draft is deleted
if attachment.faultingState == 0 {
self.updateAttachmentDescriptionState()
}
})
}
private func updateAttachmentDescriptionState() {
if attachment.attachmentDescription.isEmpty {
parent.attachmentsMissingDescriptions.insert(attachment.id)
} else {
parent.attachmentsMissingDescriptions.remove(attachment.id)
}
}
var view: some View {
AttachmentView(attachment: attachment)
}
private func removeAttachment() {
withAnimation {
var newAttachments = parent.draft.draftAttachments
newAttachments.removeAll(where: { $0.id == attachment.id })
parent.draft.attachments = NSMutableOrderedSet(array: newAttachments)
}
}
private func editDrawing() {
guard case .drawing(let drawing) = attachment.data else {
return
}
parent.config.presentDrawing?(drawing) { newDrawing in
self.attachment.drawing = newDrawing
}
}
private func focusAttachment() {
focusAttachmentOnTextEditorUnfocus = false
parent.focusedAttachment = (attachment, thumbnailController)
}
private func recognizeText() {
descriptionMode = .recognizingText
self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
DispatchQueue.main.async {
let data: Data
switch result {
case .success((let d, _)):
data = d
case .failure(let error):
self.descriptionMode = .allowEntry
self.textRecognitionError = error
return
}
let handler = VNImageRequestHandler(data: data)
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.descriptionMode = .allowEntry
}
}
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
DispatchQueue.global(qos: .userInitiated).async {
do {
try handler.perform([request])
} catch let error as NSError where error.code == 1 {
// The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for.
return
} catch {
DispatchQueue.main.async {
self.descriptionMode = .allowEntry
self.textRecognitionError = error
}
}
}
}
}
}
struct AttachmentView: View {
@ObservedObject private var attachment: DraftAttachment
@EnvironmentObject private var controller: AttachmentRowController
@FocusState private var textEditorFocused: Bool
init(attachment: DraftAttachment) {
self.attachment = attachment
}
var body: some View {
HStack(alignment: .center, spacing: 4) {
ControllerView(controller: { controller.thumbnailController })
.clipShape(RoundedRectangle(cornerRadius: 8))
.environment(\.attachmentThumbnailConfiguration, .init(contentMode: .fit, fullSize: false))
.matchedGeometrySource(id: attachment.id, presentationID: attachment.id)
.overlay {
thumbnailFocusedOverlay
}
.frame(width: thumbnailSize, height: thumbnailSize)
.onTapGesture {
textEditorFocused = false
// if we just focus the attachment immediately, the text editor doesn't actually unfocus
controller.focusAttachmentOnTextEditorUnfocus = true
}
.contextMenu {
if attachment.drawingData != nil {
Button(action: controller.editDrawing) {
Label("Edit Drawing", systemImage: "hand.draw")
}
} else if attachment.type == .image {
Button(action: controller.recognizeText) {
Label("Recognize Text", systemImage: "doc.text.viewfinder")
}
}
Button(role: .destructive, action: controller.removeAttachment) {
Label("Delete", systemImage: "trash")
}
} preview: {
ControllerView(controller: { controller.thumbnailController })
}
switch controller.descriptionMode {
case .allowEntry:
InlineAttachmentDescriptionView(attachment: attachment, minHeight: thumbnailSize)
.matchedGeometrySource(id: AttachmentDescriptionTextViewID(attachment), presentationID: attachment.id)
.focused($textEditorFocused)
case .recognizingText:
ProgressView()
.progressViewStyle(.circular)
}
}
.alertWithData("Text Recognition Failed", data: $controller.textRecognitionError) { _ in
Button("OK") {}
} message: { error in
Text(error.localizedDescription)
}
.onAppear(perform: controller.updateAttachmentDescriptionState)
#if os(visionOS)
.onChange(of: textEditorFocused) {
if !textEditorFocused && controller.focusAttachmentOnTextEditorUnfocus {
controller.focusAttachment()
}
}
#else
.onChange(of: textEditorFocused) { newValue in
if !newValue && controller.focusAttachmentOnTextEditorUnfocus {
controller.focusAttachment()
}
}
#endif
}
private var thumbnailSize: CGFloat {
#if os(visionOS)
120
#else
80
#endif
}
@ViewBuilder
private var thumbnailFocusedOverlay: some View {
Image(systemName: "arrow.up.backward.and.arrow.down.forward")
.foregroundColor(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
// use .opacity and an animation, because .transition doesn't seem to play nice with @FocusState
.opacity(textEditorFocused ? 1 : 0)
.animation(.linear(duration: 0.1), value: textEditorFocused)
}
}
}
extension AttachmentRowController {
enum DescriptionMode {
case allowEntry, recognizingText
}
}

View File

@ -1,201 +0,0 @@
//
// AttachmentThumbnailController.swift
// ComposeUI
//
// Created by Shadowfacts on 11/10/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import SwiftUI
import Photos
import TuskerComponents
class AttachmentThumbnailController: ViewController {
unowned let parent: ComposeController
let attachment: DraftAttachment
@Published private var image: UIImage?
@Published private var gifController: GIFController?
@Published private var fullSize: Bool = false
init(attachment: DraftAttachment, parent: ComposeController) {
self.attachment = attachment
self.parent = parent
}
func loadImageIfNecessary(fullSize: Bool) {
if (gifController != nil) || (image != nil && self.fullSize) {
return
}
self.fullSize = fullSize
switch attachment.data {
case .editing(_, let kind, let url):
switch kind {
case .image:
Task { @MainActor in
self.image = await parent.fetchAttachment(url)
}
case .video, .gifv:
let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
#if os(visionOS)
#warning("Use async AVAssetImageGenerator.image(at:)")
#else
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
self.image = UIImage(cgImage: cgImage)
}
#endif
case .audio, .unknown:
break
}
case .asset(let id):
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
return
}
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
guard let data else { return }
if typeIdentifier == UTType.gif.identifier {
self.gifController = GIFController(gifData: data)
} else {
let image = UIImage(data: data)
DispatchQueue.main.async {
self.image = image
}
}
}
} else {
let size: CGSize
if fullSize {
size = PHImageManagerMaximumSize
} else {
// currently only used as thumbnail in ComposeAttachmentRow
size = CGSize(width: 80, height: 80)
}
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
DispatchQueue.main.async {
self.image = image
}
}
}
case .drawing(let drawing):
image = drawing.imageInLightMode(from: drawing.bounds)
case .file(let url, let type):
if type.conforms(to: .movie) {
let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
#if os(visionOS)
#warning("Use async AVAssetImageGenerator.image(at:)")
#else
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
self.image = UIImage(cgImage: cgImage)
}
#endif
} else if let data = try? Data(contentsOf: url) {
if type == .gif {
self.gifController = GIFController(gifData: data)
} else if type.conforms(to: .image),
let image = UIImage(data: data) {
// using prepareThumbnail on images from PHPicker results in extremely high memory usage,
// crashing share extension. see FB12186346
// if fullSize {
image.prepareForDisplay { prepared in
DispatchQueue.main.async {
self.image = image
}
}
// } else {
// image.prepareThumbnail(of: CGSize(width: 80, height: 80)) { prepared in
// DispatchQueue.main.async {
// self.image = prepared
// }
// }
// }
}
}
case .none:
break
}
}
var view: some SwiftUI.View {
View()
}
struct View: SwiftUI.View {
@EnvironmentObject private var controller: AttachmentThumbnailController
@Environment(\.attachmentThumbnailConfiguration) private var config
var body: some SwiftUI.View {
content
.onAppear {
controller.loadImageIfNecessary(fullSize: config.fullSize)
}
}
@ViewBuilder
private var content: some SwiftUI.View {
if let gifController = controller.gifController {
GIFViewWrapper(controller: gifController)
} else if let image = controller.image {
Image(uiImage: image)
.resizable()
.aspectRatio(config.aspectRatio, contentMode: config.contentMode)
} else {
Image(systemName: "photo")
}
}
}
}
struct AttachmentThumbnailConfiguration {
let aspectRatio: CGFloat?
let contentMode: ContentMode
let fullSize: Bool
init(aspectRatio: CGFloat? = nil, contentMode: ContentMode = .fit, fullSize: Bool = false) {
self.aspectRatio = aspectRatio
self.contentMode = contentMode
self.fullSize = fullSize
}
}
private struct AttachmentThumbnailConfigurationEnvironmentKey: EnvironmentKey {
static let defaultValue = AttachmentThumbnailConfiguration()
}
extension EnvironmentValues {
var attachmentThumbnailConfiguration: AttachmentThumbnailConfiguration {
get { self[AttachmentThumbnailConfigurationEnvironmentKey.self] }
set { self[AttachmentThumbnailConfigurationEnvironmentKey.self] = newValue }
}
}
struct GIFViewWrapper: UIViewRepresentable {
typealias UIViewType = GIFImageView
@State var controller: GIFController
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) {
}
}

View File

@ -1,224 +0,0 @@
//
// AttachmentsListController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/8/23.
//
import SwiftUI
import PhotosUI
import PencilKit
class AttachmentsListController: ViewController {
unowned let parent: ComposeController
var draft: Draft { parent.draft }
var isValid: Bool {
!requiresAttachmentDescriptions && validAttachmentCombination
}
private var requiresAttachmentDescriptions: Bool {
if parent.config.requireAttachmentDescriptions {
if draft.attachments.count == 0 {
return false
} else {
return !parent.attachmentsMissingDescriptions.isEmpty
}
} else {
return false
}
}
var validAttachmentCombination: Bool {
if !parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
return true
} else if draft.attachments.count > 1,
draft.draftAttachments.contains(where: { $0.type == .video }) {
return false
} else if draft.attachments.count > 4 {
return false
}
return true
}
init(parent: ComposeController) {
self.parent = parent
}
var canAddAttachment: Bool {
if parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
return draft.attachments.count < 4 && draft.draftAttachments.allSatisfy { $0.type == .image } && draft.poll == nil
} else {
return true
}
}
private var canAddPoll: Bool {
if parent.mastodonController.instanceFeatures.pollsAndAttachments {
return true
} else {
return draft.attachments.count == 0
}
}
var view: some View {
AttachmentsList()
}
private func moveAttachments(from source: IndexSet, to destination: Int) {
// just using moveObjects(at:to:) on the draft.attachments NSMutableOrderedSet
// results in the order switching back to the previous order and then to the correct one
// on the subsequent 2 view updates. creating a new set with the proper order doesn't have that problem
var array = draft.draftAttachments
array.move(fromOffsets: source, toOffset: destination)
draft.attachments = NSMutableOrderedSet(array: array)
}
private func deleteAttachments(at indices: IndexSet) {
var array = draft.draftAttachments
array.remove(atOffsets: indices)
draft.attachments = NSMutableOrderedSet(array: array)
}
private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) {
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
guard let attachment = object as? DraftAttachment else { return }
DispatchQueue.main.async { [weak self] in
guard let self,
self.canAddAttachment else {
return
}
DraftsPersistentContainer.shared.viewContext.insert(attachment)
attachment.draft = self.draft
self.draft.attachments.add(attachment)
}
}
}
}
private func addImage() {
parent.deleteDraftOnDisappear = false
parent.config.presentAssetPicker?({ results in
self.insertAttachments(at: self.draft.attachments.count, itemProviders: results.map(\.itemProvider))
})
}
private func addDrawing() {
parent.deleteDraftOnDisappear = false
parent.config.presentDrawing?(PKDrawing()) { drawing in
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
attachment.id = UUID()
attachment.drawing = drawing
attachment.draft = self.draft
self.draft.attachments.add(attachment)
}
}
private func togglePoll() {
UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil)
withAnimation {
draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil
}
}
struct AttachmentsList: View {
private let cellHeight: CGFloat = 80
private let cellPadding: CGFloat = 12
@EnvironmentObject private var controller: AttachmentsListController
@EnvironmentObject private var draft: Draft
@Environment(\.colorScheme) private var colorScheme
var body: some View {
attachmentsList
Group {
if controller.parent.config.presentAssetPicker != nil {
addImageButton
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
}
if controller.parent.config.presentDrawing != nil {
addDrawingButton
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
}
togglePollButton
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
}
#if os(visionOS)
.buttonStyle(.bordered)
.labelStyle(AttachmentButtonLabelStyle())
#endif
}
private var attachmentsList: some View {
ForEach(draft.attachments.array as! [DraftAttachment]) { attachment in
ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) })
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
.id(attachment.id)
}
.onMove(perform: controller.moveAttachments)
.onDelete(perform: controller.deleteAttachments)
.conditionally(controller.canAddAttachment) {
$0.onInsert(of: DraftAttachment.readableTypeIdentifiersForItemProvider, perform: { offset, providers in
controller.insertAttachments(at: offset, itemProviders: providers)
})
}
// only sort of works, see #240
.onDrop(of: DraftAttachment.readableTypeIdentifiersForItemProvider, isTargeted: nil) { providers in
controller.insertAttachments(at: 0, itemProviders: providers)
return true
}
}
private var addImageButton: some View {
Button(action: controller.addImage) {
Label("Add photo or video", systemImage: colorScheme == .dark ? "photo.fill" : "photo")
}
.disabled(!controller.canAddAttachment)
.foregroundColor(.accentColor)
.frame(height: cellHeight / 2)
}
private var addDrawingButton: some View {
Button(action: controller.addDrawing) {
Label("Draw something", systemImage: "hand.draw")
}
.disabled(!controller.canAddAttachment)
.foregroundColor(.accentColor)
.frame(height: cellHeight / 2)
}
private var togglePollButton: some View {
Button(action: controller.togglePoll) {
Label(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal")
}
.disabled(!controller.canAddPoll)
.foregroundColor(.accentColor)
.frame(height: cellHeight / 2)
}
}
}
fileprivate extension View {
@ViewBuilder
func conditionally(_ condition: Bool, body: (Self) -> some View) -> some View {
if condition {
body(self)
} else {
self
}
}
}
@available(visionOS 1.0, *)
struct AttachmentButtonLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
DefaultLabelStyle().makeBody(configuration: configuration)
.foregroundStyle(.white)
}
}

View File

@ -1,83 +0,0 @@
//
// AutocompleteController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/25/23.
//
import SwiftUI
import Combine
class AutocompleteController: ViewController {
unowned let parent: ComposeController
@Published var mode: Mode?
init(parent: ComposeController) {
self.parent = parent
parent.$currentInput
.compactMap { $0 }
.flatMap { $0.autocompleteStatePublisher }
.map {
switch $0 {
case .mention(_):
return Mode.mention
case .emoji(_):
return Mode.emoji
case .hashtag(_):
return Mode.hashtag
case nil:
return nil
}
}
.assign(to: &$mode)
}
var view: some View {
AutocompleteView()
}
struct AutocompleteView: View {
@EnvironmentObject private var parent: ComposeController
@EnvironmentObject private var controller: AutocompleteController
@Environment(\.colorScheme) private var colorScheme: ColorScheme
var body: some View {
if let mode = controller.mode {
VStack(spacing: 0) {
Divider()
suggestionsView(mode: mode)
}
.background(backgroundColor)
}
}
@ViewBuilder
private func suggestionsView(mode: Mode) -> some View {
switch mode {
case .mention:
ControllerView(controller: { AutocompleteMentionsController(composeController: parent) })
case .emoji:
ControllerView(controller: { AutocompleteEmojisController(composeController: parent) })
case .hashtag:
ControllerView(controller: { AutocompleteHashtagsController(composeController: parent) })
}
}
private var backgroundColor: Color {
Color(white: colorScheme == .light ? 0.98 : 0.15)
}
private var borderColor: Color {
Color(white: colorScheme == .light ? 0.85 : 0.25)
}
}
enum Mode {
case mention
case emoji
case hashtag
}
}

View File

@ -1,188 +0,0 @@
//
// AutocompleteEmojisController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/26/23.
//
import SwiftUI
import Pachyderm
import Combine
import TuskerComponents
class AutocompleteEmojisController: ViewController {
unowned let composeController: ComposeController
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
private var stateCancellable: AnyCancellable?
private var searchTask: Task<Void, Never>?
@Published var expanded = false
@Published var emojis: [Emoji] = []
@Published var emojisBySection: [String: [Emoji]] = [:]
init(composeController: ComposeController) {
self.composeController = composeController
stateCancellable = composeController.$currentInput
.compactMap { $0 }
.flatMap { $0.autocompleteStatePublisher }
.compactMap {
if case .emoji(let s) = $0 {
return s
} else {
return nil
}
}
.removeDuplicates()
.sink { [unowned self] query in
self.searchTask?.cancel()
self.searchTask = Task { [weak self] in
await self?.queryChanged(query)
}
}
}
@MainActor
private func queryChanged(_ query: String) async {
var emojis = await composeController.mastodonController.getCustomEmojis()
guard !Task.isCancelled else {
return
}
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>()
var newEmojis = [Emoji]()
var newEmojisBySection = [String: [Emoji]]()
for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
newEmojis.append(emoji)
shortcodes.insert(emoji.shortcode)
let category = emoji.category ?? ""
if newEmojisBySection.keys.contains(category) {
newEmojisBySection[category]!.append(emoji)
} else {
newEmojisBySection[category] = [emoji]
}
}
self.emojis = newEmojis
self.emojisBySection = newEmojisBySection
}
private func toggleExpanded() {
withAnimation {
expanded.toggle()
}
}
private func autocomplete(with emoji: Emoji) {
guard let input = composeController.currentInput else { return }
input.autocomplete(with: ":\(emoji.shortcode):")
}
var view: some View {
AutocompleteEmojisView()
}
struct AutocompleteEmojisView: View {
@EnvironmentObject private var composeController: ComposeController
@EnvironmentObject private var controller: AutocompleteEmojisController
@ScaledMetric private var emojiSize = 30
var body: some View {
// When exapnded, the toggle button should be at the top. When collapsed, it should be centered.
HStack(alignment: controller.expanded ? .top : .center, spacing: 0) {
emojiList
.transition(.move(edge: .bottom))
toggleExpandedButton
.padding(.trailing, 8)
.padding(.top, controller.expanded ? 8 : 0)
}
}
@ViewBuilder
private var emojiList: some View {
if controller.expanded {
verticalGrid
.frame(height: 150)
} else {
horizontalScrollView
}
}
private var verticalGrid: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) {
ForEach(controller.emojisBySection.keys.sorted(), id: \.self) { section in
Section {
ForEach(controller.emojisBySection[section]!, id: \.shortcode) { emoji in
Button(action: { controller.autocomplete(with: emoji) }) {
composeController.emojiImageView(emoji)
.frame(height: emojiSize)
}
.accessibilityLabel(emoji.shortcode)
}
} header: {
if !section.isEmpty {
VStack(alignment: .leading, spacing: 2) {
Text(section)
.font(.caption)
Divider()
}
.padding(.top, 4)
}
}
}
}
.padding(.all, 8)
// the spacing between the grid sections doesn't seem to be taken into account by the ScrollView?
.padding(.bottom, CGFloat(controller.emojisBySection.keys.count) * 4)
}
.frame(maxWidth: .infinity)
}
private var horizontalScrollView: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 8) {
ForEach(controller.emojis, id: \.shortcode) { emoji in
Button(action: { controller.autocomplete(with: emoji) }) {
HStack(spacing: 4) {
composeController.emojiImageView(emoji)
.frame(height: emojiSize)
Text(verbatim: ":\(emoji.shortcode):")
.foregroundColor(.primary)
}
}
.accessibilityLabel(emoji.shortcode)
.frame(height: emojiSize)
}
.animation(.linear(duration: 0.2), value: controller.emojis)
}
.padding(.horizontal, 8)
.frame(height: emojiSize + 16)
}
}
private var toggleExpandedButton: some View {
Button(action: controller.toggleExpanded) {
Image(systemName: "chevron.down")
.resizable()
.aspectRatio(contentMode: .fit)
.rotationEffect(controller.expanded ? .zero : .degrees(180))
}
.accessibilityLabel(controller.expanded ? "Collapse" : "Expand")
.frame(width: 20, height: 20)
}
}
}

View File

@ -1,125 +0,0 @@
//
// AutocompleteHashtagsController.swift
// ComposeUI
//
// Created by Shadowfacts on 4/1/23.
//
import SwiftUI
import Combine
import Pachyderm
import TuskerComponents
class AutocompleteHashtagsController: ViewController {
unowned let composeController: ComposeController
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
private var stateCancellable: AnyCancellable?
private var searchTask: Task<Void, Never>?
@Published var hashtags: [Hashtag] = []
init(composeController: ComposeController) {
self.composeController = composeController
stateCancellable = composeController.$currentInput
.compactMap { $0 }
.flatMap { $0.autocompleteStatePublisher }
.compactMap {
if case .hashtag(let s) = $0 {
return s
} else {
return nil
}
}
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
.sink { [unowned self] query in
self.searchTask?.cancel()
self.searchTask = Task { [weak self] in
await self?.queryChanged(query)
}
}
}
@MainActor
private func queryChanged(_ query: String) async {
guard !query.isEmpty else {
hashtags = []
return
}
let localHashtags = mastodonController.searchCachedHashtags(query: query)
var onlyLocalTagsTask: Task<Void, any Error>?
if !localHashtags.isEmpty {
onlyLocalTagsTask = Task {
// we only want to do the local-only search if the trends API call takes more than .25sec or it fails
try await Task.sleep(nanoseconds: 250 * NSEC_PER_MSEC)
self.updateHashtags(searchResults: [], trendingTags: [], localHashtags: localHashtags, query: query)
}
}
async let trendingTags = try? mastodonController.run(Client.getTrendingHashtags()).0
async let searchResults = try? mastodonController.run(Client.search(query: "#\(query)", types: [.hashtags])).0.hashtags
let trends = await trendingTags ?? []
let search = await searchResults ?? []
onlyLocalTagsTask?.cancel()
guard !Task.isCancelled else { return }
updateHashtags(searchResults: search, trendingTags: trends, localHashtags: localHashtags, query: query)
}
@MainActor
private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], localHashtags: [Hashtag], query: String) {
var addedHashtags = Set<String>()
var hashtags = [(Hashtag, Int)]()
for group in [searchResults, trendingTags, localHashtags] {
for tag in group where !addedHashtags.contains(tag.name) {
let (matched, score) = FuzzyMatcher.match(pattern: query, str: tag.name)
if matched {
hashtags.append((tag, score))
addedHashtags.insert(tag.name)
}
}
}
self.hashtags = hashtags
.sorted { $0.1 > $1.1 }
.map(\.0)
}
private func autocomplete(with hashtag: Hashtag) {
guard let currentInput = composeController.currentInput else { return }
currentInput.autocomplete(with: "#\(hashtag.name)")
}
var view: some View {
AutocompleteHashtagsView()
}
struct AutocompleteHashtagsView: View {
@EnvironmentObject private var controller: AutocompleteHashtagsController
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 8) {
ForEach(controller.hashtags, id: \.name) { hashtag in
Button(action: { controller.autocomplete(with: hashtag) }) {
Text(verbatim: "#\(hashtag.name)")
.foregroundColor(Color(uiColor: .label))
}
.frame(height: 30)
.padding(.vertical, 8)
}
Spacer()
}
.padding(.horizontal, 8)
.animation(.linear(duration: 0.2), value: controller.hashtags)
}
}
}
}

View File

@ -1,179 +0,0 @@
//
// AutocompleteMentionsController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/25/23.
//
import SwiftUI
import Combine
import Pachyderm
import TuskerComponents
class AutocompleteMentionsController: ViewController {
unowned let composeController: ComposeController
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
private var stateCancellable: AnyCancellable?
@Published private var accounts: [AnyAccount] = []
private var searchTask: Task<Void, Never>?
init(composeController: ComposeController) {
self.composeController = composeController
stateCancellable = composeController.$currentInput
.compactMap { $0 }
.flatMap { $0.autocompleteStatePublisher }
.compactMap {
if case .mention(let s) = $0 {
return s
} else {
return nil
}
}
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
.sink { [unowned self] query in
self.searchTask?.cancel()
// weak in case the autocomplete controller is dealloc'd racing with the task starting
self.searchTask = Task { [weak self] in
await self?.queryChanged(query)
}
}
}
@MainActor
private func queryChanged(_ query: String) async {
guard !query.isEmpty else {
accounts = []
return
}
let localSearchTask = Task {
// we only want to search locally if the search API call takes more than .25sec or it fails
try await Task.sleep(nanoseconds: 250 * NSEC_PER_MSEC)
let results = self.mastodonController.searchCachedAccounts(query: query)
try Task.checkCancellation()
if !results.isEmpty {
self.loadAccounts(results.map { .init(value: $0) }, query: query)
}
}
let accounts = try? await mastodonController.run(Client.searchForAccount(query: query)).0
guard let accounts,
!Task.isCancelled else {
return
}
localSearchTask.cancel()
loadAccounts(accounts.map { .init(value: $0) }, query: query)
}
@MainActor
private func loadAccounts(_ accounts: [AnyAccount], query: String) {
guard case .mention(query) = composeController.currentInput?.autocompleteState else {
return
}
// 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.cachedRelationship(for: account.value.id) {
if relationship.following {
score += 3
}
if relationship.followedBy {
score += 2
}
}
return (account, score)
}
.sorted { $0.1 > $1.1 }
.map(\.0)
}
private func autocomplete(with account: AnyAccount) {
guard let input = composeController.currentInput else {
return
}
input.autocomplete(with: "@\(account.value.acct)")
}
var view: some View {
AutocompleteMentionsView()
}
struct AutocompleteMentionsView: View {
@EnvironmentObject private var controller: AutocompleteMentionsController
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 8) {
ForEach(controller.accounts) { account in
AutocompleteMentionButton(account: account)
}
Spacer()
}
.padding(.horizontal, 8)
.animation(.linear(duration: 0.2), value: controller.accounts)
}
.onDisappear {
controller.searchTask?.cancel()
}
}
}
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,
style: composeController.config.avatarStyle,
fetchAvatar: composeController.fetchAvatar
)
VStack(alignment: .leading) {
controller.composeController.displayNameLabel(account.value, .subheadline, 14)
.foregroundColor(.primary)
Text(verbatim: "@\(account.value.acct)")
.font(.caption)
.foregroundColor(.primary)
}
}
}
.frame(height: 30)
.padding(.vertical, 8)
}
}
}
fileprivate struct AnyAccount: Equatable, Identifiable {
let value: any AccountProtocol
var id: String { value.id }
static func ==(lhs: AnyAccount, rhs: AnyAccount) -> Bool {
return lhs.value.id == rhs.value.id
}
}

View File

@ -1,508 +0,0 @@
//
// ComposeController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/4/23.
//
import SwiftUI
import Combine
import Pachyderm
import TuskerComponents
import MatchedGeometryPresentation
import CoreData
public final class ComposeController: ViewController {
public typealias FetchAttachment = (URL) async -> UIImage?
public typealias FetchStatus = (String) -> (any StatusProtocol)?
public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView
public typealias CurrentAccountContainerView = (AnyView) -> AnyView
public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView
public typealias EmojiImageView = (Emoji) -> AnyView
@Published public private(set) var draft: Draft {
didSet {
assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
}
}
@Published public var config: ComposeUIConfig
@Published public var mastodonController: ComposeMastodonContext
let fetchAvatar: AvatarImageView.FetchAvatar
let fetchAttachment: FetchAttachment
let fetchStatus: FetchStatus
let displayNameLabel: DisplayNameLabel
let currentAccountContainerView: CurrentAccountContainerView
let replyContentView: ReplyContentView
let emojiImageView: EmojiImageView
@Published public var currentAccount: (any AccountProtocol)?
@Published public var showToolbar = true
@Published public var deleteDraftOnDisappear = true
@Published var autocompleteController: AutocompleteController!
@Published var toolbarController: ToolbarController!
@Published var attachmentsListController: AttachmentsListController!
// this property is here rather than on the AttachmentsListController so that the ComposeView
// updates when it changes, because changes to it may alter postButtonEnabled
@Published var attachmentsMissingDescriptions = Set<UUID>()
@Published var focusedAttachment: (DraftAttachment, AttachmentThumbnailController)?
let scrollToAttachment = PassthroughSubject<UUID, Never>()
@Published var contentWarningBecomeFirstResponder = false
@Published var mainComposeTextViewBecomeFirstResponder = false
@Published var currentInput: (any ComposeInput)? = nil
@Published var shouldEmojiAutocompletionBeginExpanded = false
@Published var isShowingSaveDraftSheet = false
@Published var isShowingDraftsList = false
@Published var poster: PostService?
@Published var postError: PostService.Error?
@Published public private(set) var didPostSuccessfully = false
@Published var hasChangedLanguageSelection = false
private var isDisappearing = false
private var userConfirmedDelete = false
public var isPosting: Bool {
poster != nil
}
var charactersRemaining: Int {
let instanceFeatures = mastodonController.instanceFeatures
let limit = instanceFeatures.maxStatusChars
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
}
var postButtonEnabled: Bool {
draft.editedStatusID != nil ||
(draft.hasContent
&& charactersRemaining >= 0
&& !isPosting
&& attachmentsListController.isValid
&& isPollValid)
}
private var isPollValid: Bool {
draft.poll == nil || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty }
}
public var navigationTitle: String {
if let id = draft.inReplyToID,
let status = fetchStatus(id) {
return "Reply to @\(status.account.acct)"
} else if draft.editedStatusID != nil {
return "Edit Post"
} else {
return "New Post"
}
}
public init(
draft: Draft,
config: ComposeUIConfig,
mastodonController: ComposeMastodonContext,
fetchAvatar: @escaping AvatarImageView.FetchAvatar,
fetchAttachment: @escaping FetchAttachment,
fetchStatus: @escaping FetchStatus,
displayNameLabel: @escaping DisplayNameLabel,
currentAccountContainerView: @escaping CurrentAccountContainerView = { $0 },
replyContentView: @escaping ReplyContentView,
emojiImageView: @escaping EmojiImageView
) {
self.draft = draft
assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
self.config = config
self.mastodonController = mastodonController
self.fetchAvatar = fetchAvatar
self.fetchAttachment = fetchAttachment
self.fetchStatus = fetchStatus
self.displayNameLabel = displayNameLabel
self.currentAccountContainerView = currentAccountContainerView
self.replyContentView = replyContentView
self.emojiImageView = emojiImageView
self.autocompleteController = AutocompleteController(parent: self)
self.toolbarController = ToolbarController(parent: self)
self.attachmentsListController = AttachmentsListController(parent: self)
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext)
}
public var view: some View {
if Preferences.shared.hasFeatureFlag(.composeRewrite) {
ComposeUI.ComposeView(draft: draft, mastodonController: mastodonController)
.environment(\.currentAccount, currentAccount)
.environment(\.composeUIConfig, config)
} else {
ComposeView(poster: poster)
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
.environmentObject(draft)
.environmentObject(mastodonController.instanceFeatures)
.environment(\.composeUIConfig, config)
}
}
@MainActor
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject>,
deleted.contains(where: { $0.objectID == self.draft.objectID }),
!isDisappearing {
self.config.dismiss(.cancel)
}
}
public func canPaste(itemProviders: [NSItemProvider]) -> Bool {
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: DraftAttachment.self) }) else {
return false
}
if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
if draft.draftAttachments.allSatisfy({ $0.type == .image }) {
// if providers are videos, this technically allows invalid video/image combinations
return itemProviders.count + draft.attachments.count <= 4
} else {
return false
}
} else {
return true
}
}
public func paste(itemProviders: [NSItemProvider]) {
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
guard let attachment = object as? DraftAttachment else { return }
DispatchQueue.main.async {
guard self.attachmentsListController.canAddAttachment else { return }
DraftsPersistentContainer.shared.viewContext.insert(attachment)
attachment.draft = self.draft
self.draft.attachments.add(attachment)
}
}
}
}
@MainActor
func cancel() {
if draft.hasContent {
isShowingSaveDraftSheet = true
} else {
deleteDraftOnDisappear = true
config.dismiss(.cancel)
}
}
@MainActor
func cancel(deleteDraft: Bool) {
deleteDraftOnDisappear = true
userConfirmedDelete = deleteDraft
config.dismiss(.cancel)
}
func postStatus() {
guard !isPosting,
draft.editedStatusID != nil || draft.hasContent else {
return
}
Task { @MainActor in
let poster = PostService(mastodonController: mastodonController, config: config, draft: draft)
self.poster = poster
// try to resign the first responder, if there is one.
// otherwise, the existence of the poster changes the .disabled modifier which causes the keyboard to hide
// and the first responder to change during a view update, which in turn triggers a bunch of state changes
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
do {
try await poster.post()
deleteDraftOnDisappear = true
didPostSuccessfully = true
// wait .25 seconds so the user can see the progress bar has completed
try? await Task.sleep(nanoseconds: 250_000_000)
// don't unset the poster, so the ui remains disabled while dismissing
config.dismiss(.post)
} catch let error as PostService.Error {
self.postError = error
self.poster = nil
} catch {
fatalError("unreachable")
}
}
}
func showDrafts() {
isShowingDraftsList = true
}
func selectDraft(_ newDraft: Draft) {
let oldDraft = self.draft
self.draft = newDraft
if !oldDraft.hasContent {
DraftsPersistentContainer.shared.viewContext.delete(oldDraft)
}
DraftsPersistentContainer.shared.save()
}
func onDisappear() {
isDisappearing = true
if deleteDraftOnDisappear && (!draft.hasContent || didPostSuccessfully || userConfirmedDelete) {
DraftsPersistentContainer.shared.viewContext.delete(draft)
}
DraftsPersistentContainer.shared.save()
}
func toggleContentWarning() {
draft.contentWarningEnabled.toggle()
if draft.contentWarningEnabled {
contentWarningBecomeFirstResponder = true
}
}
@available(iOS 16.0, *)
@objc private func currentInputModeChanged() {
guard let mode = currentInput?.textInputMode,
let code = LanguagePicker.codeFromInputMode(mode),
!hasChangedLanguageSelection && !draft.hasContent else {
return
}
draft.language = code.identifier
}
struct ComposeView: View {
@OptionalObservedObject var poster: PostService?
@EnvironmentObject var controller: ComposeController
@EnvironmentObject var draft: Draft
#if !os(visionOS)
@StateObject private var keyboardReader = KeyboardReader()
#endif
@State private var globalFrameOutsideList = CGRect.zero
init(poster: PostService?) {
self.poster = poster
}
var config: ComposeUIConfig {
controller.config
}
var body: some View {
NavigationView {
navRoot
}
.navigationViewStyle(.stack)
}
private var navRoot: some View {
ZStack(alignment: .top) {
// just using .background doesn't work; for some reason it gets inset immediately after the software keyboard is dismissed
config.backgroundColor
.edgesIgnoringSafeArea(.all)
ScrollViewReader { proxy in
mainList
.onReceive(controller.scrollToAttachment) { id in
proxy.scrollTo(id, anchor: .center)
}
}
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 controller.showToolbar {
VStack(spacing: 0) {
ControllerView(controller: { controller.autocompleteController })
.transition(.move(edge: .bottom))
.animation(.default, value: controller.currentInput?.autocompleteState)
#if !os(visionOS)
ControllerView(controller: { controller.toolbarController })
#endif
}
.transition(.move(edge: .bottom))
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) { cancelButton }
#if targetEnvironment(macCatalyst)
ToolbarItem(placement: .topBarTrailing) { draftsButton }
ToolbarItem(placement: .confirmationAction) { postButton }
#else
ToolbarItem(placement: .confirmationAction) { postOrDraftsButton }
#endif
#if os(visionOS)
ToolbarItem(placement: .bottomOrnament) {
ControllerView(controller: { controller.toolbarController })
}
#endif
}
.background(GeometryReader { proxy in
Color.clear
.preference(key: GlobalFrameOutsideListPrefKey.self, value: proxy.frame(in: .global))
.onPreferenceChange(GlobalFrameOutsideListPrefKey.self) { newValue in
globalFrameOutsideList = newValue
}
})
.sheet(isPresented: $controller.isShowingDraftsList) {
ControllerView(controller: { DraftsController(parent: controller, isPresented: $controller.isShowingDraftsList) })
}
.alertWithData("Error Posting", data: $controller.postError, actions: { _ in
Button("OK") {}
}, message: { error in
Text(error.localizedDescription)
})
.matchedGeometryPresentation(id: Binding(get: { () -> UUID?? in
let id = controller.focusedAttachment?.0.id
// this needs to be a double optional, since the type used for for the presentationID in the geom source is a UUID?
return id.map { Optional.some($0) }
}, set: {
if $0 == nil {
controller.focusedAttachment = nil
} else {
fatalError()
}
}), backgroundColor: .black) {
ControllerView(controller: {
FocusedAttachmentController(
parent: controller,
attachment: controller.focusedAttachment!.0,
thumbnailController: controller.focusedAttachment!.1
)
})
}
.onDisappear(perform: controller.onDisappear)
.navigationTitle(controller.navigationTitle)
.navigationBarTitleDisplayMode(.inline)
}
private var mainList: some View {
List {
if let id = draft.inReplyToID,
let status = controller.fetchStatus(id) {
ReplyStatusView(
status: status,
rowTopInset: 8,
globalFrameOutsideList: globalFrameOutsideList
)
// i don't know why swiftui can't infer this from the status that's passed into the ReplyStatusView changing
.id(id)
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(config.backgroundColor)
}
HeaderView(currentAccount: controller.currentAccount, charsRemaining: controller.charactersRemaining)
.listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(config.backgroundColor)
if draft.contentWarningEnabled {
EmojiTextField(
text: $draft.contentWarning,
placeholder: "Write your warning here",
maxLength: nil,
becomeFirstResponder: $controller.contentWarningBecomeFirstResponder,
focusNextView: $controller.mainComposeTextViewBecomeFirstResponder
)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(config.backgroundColor)
}
MainTextView()
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(config.backgroundColor)
if let poll = draft.poll {
ControllerView(controller: { PollController(parent: controller, poll: poll) })
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(config.backgroundColor)
}
ControllerView(controller: { controller.attachmentsListController })
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
.listRowBackground(config.backgroundColor)
}
.listStyle(.plain)
#if !os(visionOS)
.scrollDismissesKeyboard(.interactively)
#endif
.disabled(controller.isPosting)
}
private var cancelButton: some View {
Button(action: controller.cancel) {
Text("Cancel")
// otherwise all Buttons in the nav bar are made semibold
.font(.system(size: 17, weight: .regular))
}
.disabled(controller.isPosting)
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
// edit drafts can't be saved
if draft.editedStatusID == nil {
Button(action: { controller.cancel(deleteDraft: false) }) {
Text("Save Draft")
}
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
Text("Delete Draft")
}
} else {
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
Text("Cancel Edit")
}
}
}
}
@ViewBuilder
private var postOrDraftsButton: some View {
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
postButton
} else {
draftsButton
}
}
private var draftsButton: some View {
Button(action: controller.showDrafts) {
Text("Drafts")
}
}
private var postButton: some View {
Button(action: controller.postStatus) {
Text(draft.editedStatusID == nil ? "Post" : "Edit")
}
.keyboardShortcut(.return, modifiers: .command)
.disabled(!controller.postButtonEnabled)
}
}
}
private struct GlobalFrameOutsideListPrefKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
private struct ComposeUIConfigEnvironmentKey: EnvironmentKey {
static let defaultValue = ComposeUIConfig()
}
extension EnvironmentValues {
var composeUIConfig: ComposeUIConfig {
get { self[ComposeUIConfigEnvironmentKey.self] }
set { self[ComposeUIConfigEnvironmentKey.self] = newValue }
}
}

View File

@ -1,174 +0,0 @@
//
// DraftsController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/7/23.
//
import SwiftUI
import TuskerComponents
import CoreData
class DraftsController: ViewController {
unowned let parent: ComposeController
@Binding var isPresented: Bool
@Published var draftForDifferentReply: Draft?
init(parent: ComposeController, isPresented: Binding<Bool>) {
self.parent = parent
self._isPresented = isPresented
}
var view: some View {
DraftsRepresentable()
}
func maybeSelectDraft(_ draft: Draft) {
if draft.inReplyToID != parent.draft.inReplyToID,
parent.draft.hasContent {
draftForDifferentReply = draft
} else {
confirmSelectDraft(draft)
}
}
func cancelSelectingDraft() {
draftForDifferentReply = nil
}
func confirmSelectDraft(_ draft: Draft) {
parent.selectDraft(draft)
closeDrafts()
}
func deleteDraft(_ draft: Draft) {
DraftsPersistentContainer.shared.viewContext.delete(draft)
}
func closeDrafts() {
isPresented = false
DraftsPersistentContainer.shared.save()
}
struct DraftsRepresentable: UIViewControllerRepresentable {
typealias UIViewControllerType = UIHostingController<DraftsView>
func makeUIViewController(context: Context) -> UIHostingController<DraftsController.DraftsView> {
return UIHostingController(rootView: DraftsView())
}
func updateUIViewController(_ uiViewController: UIHostingController<DraftsController.DraftsView>, context: Context) {
}
}
struct DraftsView: View {
@EnvironmentObject private var controller: DraftsController
@EnvironmentObject private var currentDraft: Draft
@FetchRequest(sortDescriptors: [SortDescriptor(\Draft.lastModified, order: .reverse)]) private var drafts: FetchedResults<Draft>
var body: some View {
NavigationView {
List {
ForEach(drafts) { draft in
Button(action: { controller.maybeSelectDraft(draft) }) {
DraftRow(draft: draft)
}
.contextMenu {
Button(role: .destructive, action: { controller.deleteDraft(draft) }) {
Label("Delete Draft", systemImage: "trash")
}
}
.ifLet(controller.parent.config.userActivityForDraft(draft), modify: { view, activity in
view.onDrag { activity }
})
}
.onDelete { indices in
indices.map { drafts[$0] }.forEach(controller.deleteDraft)
}
}
.listStyle(.plain)
.navigationTitle("Drafts")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { cancelButton }
}
}
.alertWithData("Different Reply", data: $controller.draftForDifferentReply) { draft in
Button(role: .cancel, action: controller.cancelSelectingDraft) {
Text("Cancel")
}
Button(action: { controller.confirmSelectDraft(draft) }) {
Text("Restore Draft")
}
} message: { _ in
Text("The selected draft is a reply to a different post, do you wish to use it?")
}
.onAppear {
drafts.nsPredicate = NSPredicate(format: "accountID == %@ AND id != %@ AND lastModified != nil", controller.parent.mastodonController.accountInfo!.id, currentDraft.id as NSUUID)
}
}
private var cancelButton: some View {
Button(action: controller.closeDrafts) {
Text("Cancel")
}
}
}
}
private struct DraftRow: View {
@ObservedObject var draft: Draft
@EnvironmentObject private var controller: DraftsController
var body: some View {
HStack {
VStack(alignment: .leading) {
if draft.editedStatusID != nil {
// shouldn't happen unless the app crashed/was killed during an edit
Text("Edit")
.font(.body.bold())
.foregroundColor(.orange)
}
if draft.contentWarningEnabled {
Text(draft.contentWarning)
.font(.body.bold())
.foregroundColor(.secondary)
}
Text(draft.text)
.font(.body)
HStack(spacing: 8) {
ForEach(draft.draftAttachments) { attachment in
ControllerView(controller: { AttachmentThumbnailController(attachment: attachment, parent: controller.parent) })
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 5))
.frame(height: 50)
}
}
}
Spacer()
if let lastModified = draft.lastModified {
Text(lastModified.formatted(.abbreviatedTimeAgo))
.font(.body)
.foregroundColor(.secondary)
}
}
}
}
private extension View {
@ViewBuilder
func ifLet<T, V: View>(_ value: T?, modify: (Self, T) -> V) -> some View {
if let value {
modify(self, value)
} else {
self
}
}
}

View File

@ -1,119 +0,0 @@
//
// FocusedAttachmentController.swift
// ComposeUI
//
// Created by Shadowfacts on 4/29/23.
//
import SwiftUI
import MatchedGeometryPresentation
import AVKit
class FocusedAttachmentController: ViewController {
unowned let parent: ComposeController
let attachment: DraftAttachment
let thumbnailController: AttachmentThumbnailController
private let player: AVPlayer?
init(parent: ComposeController, attachment: DraftAttachment, thumbnailController: AttachmentThumbnailController) {
self.parent = parent
self.attachment = attachment
self.thumbnailController = thumbnailController
if case let .file(url, type) = attachment.data,
type.conforms(to: .movie) {
self.player = AVPlayer(url: url)
self.player!.isMuted = true
} else {
self.player = nil
}
}
var view: some View {
FocusedAttachmentView(attachment: attachment)
}
struct FocusedAttachmentView: View {
@ObservedObject var attachment: DraftAttachment
@EnvironmentObject private var controller: FocusedAttachmentController
@Environment(\.dismiss) private var dismiss
@FocusState private var textEditorFocused: Bool
@EnvironmentObject private var matchedGeomState: MatchedGeometryState
var body: some View {
VStack(spacing: 0) {
Spacer(minLength: 0)
if let player = controller.player {
VideoPlayer(player: player)
.matchedGeometryDestination(id: attachment.id)
.onAppear {
player.play()
}
} else {
ZoomableScrollView {
attachmentView
.matchedGeometryDestination(id: attachment.id)
}
}
Spacer(minLength: 0)
FocusedAttachmentDescriptionView(attachment: attachment)
.environment(\.colorScheme, .dark)
.matchedGeometryDestination(id: AttachmentDescriptionTextViewID(attachment))
.frame(height: 150)
.focused($textEditorFocused)
}
.background(.black)
.overlay(alignment: .topLeading, content: {
Button {
// set the mode to dismissing immediately, so that layout changes due to the keyboard hiding
// (which happens before the dismiss animation controller starts running) don't alter the destination frames
if textEditorFocused {
matchedGeomState.mode = .dismissing
}
dismiss()
} label: {
Image(systemName: "arrow.down.forward.and.arrow.up.backward")
}
.buttonStyle(DismissFocusedAttachmentButtonStyle())
.padding([.top, .leading], 4)
})
}
private var attachmentView: some View {
ControllerView(controller: { controller.thumbnailController })
.environment(\.attachmentThumbnailConfiguration, .init(contentMode: .fit, fullSize: true))
}
}
}
private struct DismissFocusedAttachmentButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(.black.opacity(0.5))
configuration.label
.foregroundColor(.white)
.imageScale(.large)
}
.frame(width: 40, height: 40)
}
}
struct AttachmentDescriptionTextViewID: Hashable {
let attachmentID: UUID!
init(_ attachment: DraftAttachment) {
self.attachmentID = attachment.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(attachmentID)
hasher.combine("descriptionTextView")
}
}

View File

@ -1,195 +0,0 @@
//
// PollController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/25/23.
//
import SwiftUI
import TuskerComponents
class PollController: ViewController {
unowned let parent: ComposeController
var draft: Draft { parent.draft }
let poll: Poll
@Published var duration: Duration
init(parent: ComposeController, poll: Poll) {
self.parent = parent
self.poll = poll
self.duration = .fromTimeInterval(poll.duration) ?? .oneDay
}
var view: some View {
PollView()
.environmentObject(poll)
}
private func removePoll() {
withAnimation {
draft.poll = nil
}
}
private func moveOptions(indices: IndexSet, newIndex: Int) {
// see AttachmentsListController.moveAttachments
var array = poll.pollOptions
array.move(fromOffsets: indices, toOffset: newIndex)
poll.options = NSMutableOrderedSet(array: array)
}
private func removeOption(_ option: PollOption) {
var array = poll.pollOptions
array.remove(at: poll.options.index(of: option))
poll.options = NSMutableOrderedSet(array: array)
}
private var canAddOption: Bool {
if let max = parent.mastodonController.instanceFeatures.maxPollOptionsCount {
return poll.options.count < max
} else {
return true
}
}
private func addOption() {
let option = PollOption(context: DraftsPersistentContainer.shared.viewContext)
option.poll = poll
poll.options.add(option)
}
struct PollView: View {
@EnvironmentObject private var controller: PollController
@EnvironmentObject private var poll: Poll
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack {
HStack {
Text("Poll")
.font(.headline)
Spacer()
Button(action: controller.removePoll) {
Image(systemName: "xmark")
.imageScale(.small)
.padding(4)
}
.accessibilityLabel("Remove poll")
.buttonStyle(.plain)
.accentColor(buttonForegroundColor)
.background(Circle().foregroundColor(buttonBackgroundColor))
.hoverEffect()
}
List {
ForEach($poll.pollOptions) { $option in
PollOptionView(option: option, remove: { controller.removeOption(option) })
.frame(height: 36)
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
.onMove(perform: controller.moveOptions)
}
.listStyle(.plain)
.scrollDisabled(true)
.frame(height: 44 * CGFloat(poll.options.count))
Button(action: controller.addOption) {
Label {
Text("Add Option")
} icon: {
Image(systemName: "plus")
.foregroundColor(.accentColor)
}
}
.buttonStyle(.borderless)
.disabled(!controller.canAddOption)
HStack {
MenuPicker(selection: $poll.multiple, options: [
.init(value: true, title: "Allow multiple"),
.init(value: false, title: "Single choice"),
])
.frame(maxWidth: .infinity)
MenuPicker(selection: $controller.duration, options: Duration.allCases.map {
.init(value: $0, title: Duration.formatter.string(from: $0.timeInterval)!)
})
.frame(maxWidth: .infinity)
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.foregroundColor(backgroundColor)
)
#if os(visionOS)
.onChange(of: controller.duration) {
poll.duration = controller.duration.timeInterval
}
#else
.onChange(of: controller.duration) { newValue in
poll.duration = newValue.timeInterval
}
#endif
}
private var backgroundColor: Color {
// in light mode, .secondarySystemBackground has a blue-ish hue, which we don't want
colorScheme == .dark ? controller.parent.config.fillColor : Color(white: 0.95)
}
private var buttonForegroundColor: Color {
Color(uiColor: .label)
}
private var buttonBackgroundColor: Color {
Color(white: colorScheme == .dark ? 0.1 : 0.8)
}
}
}
extension PollController {
enum Duration: Hashable, Equatable, CaseIterable {
case fiveMinutes, thirtyMinutes, oneHour, sixHours, oneDay, threeDays, sevenDays
static let formatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.maximumUnitCount = 1
f.unitsStyle = .full
f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
return f
}()
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
}
}
}
}

View File

@ -1,200 +0,0 @@
//
// ToolbarController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/7/23.
//
import SwiftUI
import Pachyderm
import TuskerComponents
class ToolbarController: ViewController {
static let height: CGFloat = 44
unowned let parent: ComposeController
@Published var minWidth: CGFloat?
@Published var realWidth: CGFloat?
init(parent: ComposeController) {
self.parent = parent
}
var view: some View {
ToolbarView()
}
func showEmojiPicker() {
guard parent.currentInput?.autocompleteState == nil else {
return
}
parent.shouldEmojiAutocompletionBeginExpanded = true
parent.currentInput?.beginAutocompletingEmoji()
}
func formatAction(_ format: StatusFormat) -> () -> Void {
{ [weak self] in
self?.parent.currentInput?.applyFormat(format)
}
}
struct ToolbarView: View {
@EnvironmentObject private var draft: Draft
@EnvironmentObject private var controller: ToolbarController
@EnvironmentObject private var composeController: ComposeController
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
#if !os(visionOS)
@State private var minWidth: CGFloat?
@State private var realWidth: CGFloat?
#endif
var body: some View {
#if os(visionOS)
buttons
#else
ScrollView(.horizontal, showsIndicators: false) {
buttons
.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
}
})
}
.scrollDisabled(realWidth ?? 0 <= minWidth ?? 0)
.frame(height: ToolbarController.height)
.frame(maxWidth: .infinity)
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
.overlay(alignment: .top) {
Divider()
.edgesIgnoringSafeArea([.leading, .trailing])
}
.background(GeometryReader { proxy in
Color.clear
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
minWidth = width
}
})
#endif
}
@ViewBuilder
private var buttons: some View {
HStack(spacing: 0) {
cwButton
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
#if !targetEnvironment(macCatalyst) && !os(visionOS)
// the button has a bunch of extra space by default, but combined with what we add it's too much
.padding(.horizontal, -8)
#endif
.disabled(draft.editedStatusID != nil)
.disabled(composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
localOnlyPicker
#if targetEnvironment(macCatalyst)
.padding(.leading, 4)
#elseif !os(visionOS)
.padding(.horizontal, -8)
#endif
.disabled(draft.editedStatusID != nil)
}
if let currentInput = composeController.currentInput,
currentInput.toolbarElements.contains(.emojiPicker) {
customEmojiButton
}
if let currentInput = composeController.currentInput,
currentInput.toolbarElements.contains(.formattingButtons),
composeController.config.contentType != .plain {
Spacer()
formatButtons
}
Spacer()
if composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
}
}
}
private var cwButton: some View {
Button("CW", action: controller.parent.toggleContentWarning)
.accessibilityLabel(draft.contentWarningEnabled ? "Remove content warning" : "Add content warning")
.padding(5)
.hoverEffect()
}
private var visibilityBinding: Binding<Pachyderm.Visibility> {
// On instances that conflate visibliity and local only, we still show two separate controls but don't allow
// changing the visibility when local-only.
if draft.localOnly,
composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility {
return .constant(.public)
} else {
return $draft.visibility
}
}
private var visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] {
let visibilities: [Pachyderm.Visibility]
if !composeController.mastodonController.instanceFeatures.composeDirectStatuses {
visibilities = [.public, .unlisted, .private]
} else {
visibilities = Pachyderm.Visibility.allCases
}
return visibilities.map { vis in
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
}
}
private var localOnlyPicker: some View {
let domain = composeController.mastodonController.accountInfo!.instanceURL.host!
return MenuPicker(selection: $draft.localOnly, options: [
.init(value: true, title: "Local-only", subtitle: "Only \(domain)", image: UIImage(named: "link.broken")),
.init(value: false, title: "Federated", image: UIImage(systemName: "link")),
], buttonStyle: .iconOnly)
}
private var customEmojiButton: some View {
Button(action: controller.showEmojiPicker) {
Label("Insert custom emoji", systemImage: "face.smiling")
}
.labelStyle(.iconOnly)
.font(.system(size: imageSize))
.padding(5)
.hoverEffect()
.transition(.opacity.animation(.linear(duration: 0.2)))
}
private var formatButtons: some View {
ForEach(StatusFormat.allCases, id: \.rawValue) { format in
Button(action: controller.formatAction(format)) {
Image(systemName: format.imageName)
.font(.system(size: imageSize))
}
.accessibilityLabel(format.accessibilityLabel)
.padding(5)
.hoverEffect()
.transition(.opacity.animation(.linear(duration: 0.2)))
}
}
}
}
private struct ToolbarWidthPrefKey: PreferenceKey {
static var defaultValue: CGFloat? = nil
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
value = nextValue()
}
}

View File

@ -31,12 +31,18 @@ public class Draft: NSManagedObject, Identifiable {
@NSManaged public var language: String? // ISO 639 language code
@NSManaged public var lastModified: Date!
@NSManaged public var localOnly: Bool
@NSManaged private var pollEnabledInternal: NSNumber?
@NSManaged public var text: String
@NSManaged private var visibilityStr: String
@NSManaged internal var attachments: NSMutableOrderedSet
@NSManaged public var poll: Poll?
public var pollEnabled: Bool {
get { pollEnabledInternal.map(\.boolValue) ?? (poll != nil) }
set { pollEnabledInternal = NSNumber(booleanLiteral: newValue) }
}
public var visibility: Visibility {
get {
Visibility(rawValue: visibilityStr) ?? .public
@ -61,13 +67,22 @@ public class Draft: NSManagedObject, Identifiable {
lastModified = Date()
}
public func addAttachment(_ attachment: DraftAttachment) {
attachments.add(attachment)
}
}
extension Draft {
var hasText: Bool {
!text.isEmpty && text != initialText
}
var hasContentWarning: Bool {
contentWarningEnabled && contentWarning != initialContentWarning
}
public var hasContent: Bool {
(!text.isEmpty && text != initialText) ||
(contentWarningEnabled && !contentWarning.isEmpty && contentWarning != initialContentWarning) ||
attachments.count > 0 ||
poll?.hasContent == true
hasText || hasContentWarning || attachments.count > 0 || (pollEnabled && poll!.hasContent)
}
}

View File

@ -32,7 +32,7 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
@NSManaged internal var fileType: String?
@NSManaged public var id: UUID!
@NSManaged internal var draft: Draft
@NSManaged public var draft: Draft
public var drawing: PKDrawing? {
get {
@ -89,7 +89,7 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
}
extension DraftAttachment {
var type: AttachmentType {
public var type: AttachmentType {
if let editedAttachmentKind {
switch editedAttachmentKind {
case .image:
@ -129,7 +129,7 @@ extension DraftAttachment {
}
}
enum AttachmentType {
public enum AttachmentType {
case image, video, unknown
}
}

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22222" systemVersion="22G91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23605" systemVersion="24A335" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Draft" representedClassName="ComposeUI.Draft" syncable="YES">
<attribute name="accountID" attributeType="String"/>
<attribute name="contentWarning" attributeType="String" defaultValueString=""/>
@ -12,6 +12,7 @@
<attribute name="language" optional="YES" attributeType="String"/>
<attribute name="lastModified" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="pollEnabledInternal" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="text" attributeType="String" defaultValueString=""/>
<attribute name="visibilityStr" optional="YES" attributeType="String"/>
<relationship name="attachments" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="DraftAttachment" inverseName="draft" inverseEntity="DraftAttachment"/>

View File

@ -12,7 +12,7 @@ import Pachyderm
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsPersistentContainer")
public class DraftsPersistentContainer: NSPersistentContainer {
public final class DraftsPersistentContainer: NSPersistentContainer {
public static let shared = DraftsPersistentContainer()
@ -131,11 +131,11 @@ public class DraftsPersistentContainer: NSPersistentContainer {
draft.poll = poll
if let expiresAt = existingPoll.expiresAt,
!existingPoll.effectiveExpired {
poll.duration = PollController.Duration.allCases.max(by: {
poll.duration = PollDuration.allCases.max(by: {
(expiresAt.timeIntervalSinceNow - $0.timeInterval) < (expiresAt.timeIntervalSinceNow - $1.timeInterval)
})!.timeInterval
} else {
poll.duration = PollController.Duration.oneDay.timeInterval
poll.duration = PollDuration.oneDay.timeInterval
}
poll.multiple = existingPoll.multiple
// rmeove default empty options
@ -170,17 +170,26 @@ public class DraftsPersistentContainer: NSPersistentContainer {
return
}
performBackgroundTask { context in
let orphanedAttachmentsReq: NSFetchRequest<any NSFetchRequestResult> = DraftAttachment.fetchRequest()
orphanedAttachmentsReq.predicate = NSPredicate(format: "draft == nil")
let deleteReq = NSBatchDeleteRequest(fetchRequest: orphanedAttachmentsReq)
do {
try context.execute(deleteReq)
} catch {
logger.error("Failed to remove orphaned attachments: \(String(describing: error), privacy: .public)")
}
let allAttachmentsReq = DraftAttachment.fetchRequest()
allAttachmentsReq.predicate = NSPredicate(format: "fileURL != nil")
guard let allAttachments = try? context.fetch(allAttachmentsReq) else {
return
}
let orphaned = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
for url in orphaned {
let orphanedFiles = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
for url in orphanedFiles {
do {
try FileManager.default.removeItem(at: url)
} catch {
logger.error("Failed to remove orphaned attachment: \(String(describing: error), privacy: .public)")
logger.error("Failed to remove orphaned attachment files: \(String(describing: error), privacy: .public)")
}
}
completion()

View File

@ -41,6 +41,6 @@ public class Poll: NSManagedObject {
extension Poll {
public var hasContent: Bool {
pollOptions.allSatisfy { !$0.text.isEmpty }
pollOptions.contains { !$0.text.isEmpty }
}
}

View File

@ -10,6 +10,7 @@
import UIKit
import Combine
@available(iOS, obsoleted: 16.0)
class KeyboardReader: ObservableObject {
@Published var keyboardHeight: CGFloat = 0

View File

@ -6,7 +6,10 @@
//
import Foundation
import Pachyderm
public enum DismissMode {
case cancel, post
case cancel
case edit(Status)
case post(Status)
}

View File

@ -0,0 +1,47 @@
//
// PollDuration.swift
// ComposeUI
//
// Created by Shadowfacts on 2/7/25.
//
import Foundation
enum PollDuration: Hashable, Equatable, CaseIterable {
case fiveMinutes, thirtyMinutes, oneHour, sixHours, oneDay, threeDays, sevenDays
static let formatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.maximumUnitCount = 1
f.unitsStyle = .full
f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
return f
}()
static func fromTimeInterval(_ ti: TimeInterval) -> PollDuration? {
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
}
}
}

View File

@ -16,7 +16,6 @@ struct OptionalObservedObject<T: ObservableObject>: DynamicProperty {
didSet {
cancellable?.cancel()
cancellable = wrapped?.objectWillChange
.receive(on: RunLoop.main)
.sink { [unowned self] _ in
self.objectWillChange.send()
}
@ -27,6 +26,10 @@ struct OptionalObservedObject<T: ObservableObject>: DynamicProperty {
@StateObject private var republisher = Republisher()
var wrappedValue: T?
init(wrappedValue: T?) {
self.wrappedValue = wrappedValue
}
func update() {
republisher.wrapped = wrappedValue
}

View File

@ -7,10 +7,7 @@
import SwiftUI
final class PlaceholderController: ViewController, PlaceholderViewProvider {
private let placeholderView: PlaceholderView = PlaceholderController.makePlaceholderView()
struct PlaceholderController: PlaceholderViewProvider {
static func makePlaceholderView() -> some View {
let components = Calendar.current.dateComponents([.month, .day], from: Date())
if components.month == 3 && components.day == 14,
@ -34,10 +31,6 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider {
Text("Whats on your mind?")
}
}
var view: some View {
placeholderView
}
}
// exists to provide access to the type alias since the @State property needs it to be explicit

View File

@ -0,0 +1,55 @@
//
// View+ForwardsCompat.swift
// ComposeUI
//
// Created by Shadowfacts on 3/25/23.
//
import SwiftUI
extension View {
#if os(visionOS)
func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
self.scrollDisabled(disabled)
}
#else
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
if #available(iOS 16.0, *) {
self.scrollDisabled(disabled)
} else {
self
}
}
#endif
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
#if os(visionOS)
self.scrollDismissesKeyboard(.interactively)
#else
if #available(iOS 16.0, *) {
self.scrollDismissesKeyboard(.interactively)
} else {
self
}
#endif
}
@available(iOS, obsoleted: 16.0)
@available(visionOS 1.0, *)
@ViewBuilder
func contextMenu<M: View, P: View>(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View {
#if os(visionOS)
self.contextMenu(menuItems: menuItems, preview: preview)
#else
if #available(iOS 16.0, *) {
self.contextMenu(menuItems: menuItems, preview: preview)
} else {
self.contextMenu(menuItems: menuItems)
}
#endif
}
}

View File

@ -1,30 +0,0 @@
//
// ViewController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/4/23.
//
import SwiftUI
import Combine
public protocol ViewController: ObservableObject {
associatedtype ContentView: View
@MainActor
@ViewBuilder
var view: ContentView { get }
}
public struct ControllerView<Controller: ViewController>: View {
@StateObject private var controller: Controller
public init(controller: @escaping () -> Controller) {
self._controller = StateObject(wrappedValue: controller())
}
public var body: some View {
controller.view
.environmentObject(controller)
}
}

View File

@ -1,151 +0,0 @@
//
// AttachmentDescriptionTextView.swift
// ComposeUI
//
// Created by Shadowfacts on 3/12/23.
//
import SwiftUI
private var placeholder: some View {
Text("Describe for the visually impaired…")
}
struct InlineAttachmentDescriptionView: View {
@ObservedObject private var attachment: DraftAttachment
private let minHeight: CGFloat
@State private var height: CGFloat?
init(attachment: DraftAttachment, minHeight: CGFloat) {
self.attachment = attachment
self.minHeight = minHeight
}
private var placeholderOffset: CGSize {
#if os(visionOS)
CGSize(width: 8, height: 8)
#else
CGSize(width: 4, height: 8)
#endif
}
var body: some View {
ZStack(alignment: .topLeading) {
if attachment.attachmentDescription.isEmpty {
placeholder
.font(.body)
.foregroundColor(.secondary)
.offset(placeholderOffset)
}
WrappedTextView(
text: $attachment.attachmentDescription,
backgroundColor: .clear,
textDidChange: self.textDidChange
)
.frame(height: height ?? minHeight)
}
}
private func textDidChange(_ textView: UITextView) {
height = max(minHeight, textView.contentSize.height)
}
}
struct FocusedAttachmentDescriptionView: View {
@ObservedObject var attachment: DraftAttachment
var body: some View {
ZStack(alignment: .topLeading) {
WrappedTextView(
text: $attachment.attachmentDescription,
backgroundColor: .secondarySystemBackground,
textDidChange: nil
)
.edgesIgnoringSafeArea([.bottom, .leading, .trailing])
if attachment.attachmentDescription.isEmpty {
placeholder
.font(.body)
.foregroundColor(.secondary)
.offset(x: 4, y: 8)
.allowsHitTesting(false)
}
}
}
}
private struct WrappedTextView: UIViewRepresentable {
typealias UIViewType = UITextView
@Binding var text: String
let backgroundColor: UIColor
let textDidChange: (((UITextView) -> Void))?
@Environment(\.isEnabled) private var isEnabled
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.delegate = context.coordinator
view.backgroundColor = backgroundColor
view.font = .preferredFont(forTextStyle: .body)
view.adjustsFontForContentSizeCategory = true
view.textContainer.lineBreakMode = .byWordWrapping
#if os(visionOS)
view.borderStyle = .roundedRect
view.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
#endif
return view
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text
uiView.isEditable = isEnabled
context.coordinator.textView = uiView
context.coordinator.text = $text
context.coordinator.didChange = textDidChange
if let 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 {
textDidChange(uiView)
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(text: $text, didChange: textDidChange)
}
class Coordinator: NSObject, UITextViewDelegate, TextViewCaretScrolling {
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)
}
}
}

View File

@ -1,182 +0,0 @@
//
// AttachmentRowView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/18/24.
//
import SwiftUI
import InstanceFeatures
import Vision
struct AttachmentRowView: View {
@ObservedObject var attachment: DraftAttachment
@State private var isRecognizingText = false
@State private var textRecognitionError: (any Error)?
private var thumbnailSize: CGFloat {
#if os(visionOS)
120
#else
80
#endif
}
var body: some View {
HStack(alignment: .center, spacing: 4) {
thumbnailView
descriptionView
}
.alertWithData("Text Recognition Failed", data: $textRecognitionError) { _ in
Button("OK") {}
} message: { error in
Text(error.localizedDescription)
}
}
// TODO: attachments missing descriptions feature
private var thumbnailView: some View {
AttachmentThumbnailView(attachment: attachment)
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: thumbnailSize, height: thumbnailSize)
.contextMenu {
EditDrawingButton(attachment: attachment)
RecognizeTextButton(attachment: attachment, isRecognizingText: $isRecognizingText, error: $textRecognitionError)
DeleteButton(attachment: attachment)
} preview: {
// TODO: need to fix flash of preview changing size
AttachmentThumbnailView(attachment: attachment)
}
}
@ViewBuilder
private var descriptionView: some View {
if isRecognizingText {
ProgressView()
.progressViewStyle(.circular)
} else {
InlineAttachmentDescriptionView(attachment: attachment, minHeight: thumbnailSize)
}
}
}
private struct EditDrawingButton: View {
@ObservedObject var attachment: DraftAttachment
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
var body: some View {
if attachment.drawingData != nil {
Button(action: editDrawing) {
Label("Edit Drawing", systemImage: "hand.draw")
}
}
}
private func editDrawing() {
if case .drawing(let drawing) = attachment.data {
presentDrawing?(drawing) {
attachment.drawing = $0
}
}
}
}
private struct RecognizeTextButton: View {
@ObservedObject var attachment: DraftAttachment
@Binding var isRecognizingText: Bool
@Binding var error: (any Error)?
@EnvironmentObject private var instanceFeatures: InstanceFeatures
var body: some View {
if attachment.type == .image {
Button {
Task {
await recognizeText()
}
} label: {
Label("Recognize Text", systemImage: "doc.text.viewfinder")
}
}
}
private func recognizeText() async {
isRecognizingText = true
defer { isRecognizingText = false }
do {
let data = try await getAttachmentData()
let observations = try await runRecognizeTextRequest(data: data)
if let observations {
var text = ""
for observation in observations {
let result = observation.topCandidates(1).first!
text.append(result.string)
text.append("\n")
}
self.attachment.attachmentDescription = text
}
} catch let error as NSError where error.domain == VNErrorDomain && error.code == 1 {
// The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for.
return
} catch {
self.error = error
}
}
private func getAttachmentData() async throws -> Data {
return try await withCheckedThrowingContinuation { continuation in
attachment.getData(features: instanceFeatures) { result in
switch result {
case .success(let (data, _)):
continuation.resume(returning: data)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
private func runRecognizeTextRequest(data: Data) async throws -> [VNRecognizedTextObservation]? {
return try await withCheckedThrowingContinuation { continuation in
let handler = VNImageRequestHandler(data: data)
let request = VNRecognizeTextRequest { request, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: request.results as? [VNRecognizedTextObservation])
}
}
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
DispatchQueue.global(qos: .userInitiated).async {
try? handler.perform([request])
}
}
}
}
private struct DeleteButton: View {
let attachment: DraftAttachment
var body: some View {
Button(role: .destructive, action: removeAttachment) {
Label("Delete", systemImage: "trash")
}
}
private func removeAttachment() {
let draft = attachment.draft
var array = draft.draftAttachments
guard let index = array.firstIndex(of: attachment) else {
return
}
array.remove(at: index)
draft.attachments = NSMutableOrderedSet(array: array)
}
}
//#Preview {
// AttachmentRowView()
//}

View File

@ -12,14 +12,33 @@ import Photos
struct AttachmentThumbnailView: View {
let attachment: DraftAttachment
let contentMode: ContentMode = .fit
@State private var mode: Mode = .empty
@EnvironmentObject private var composeController: ComposeController
var contentMode: ContentMode = .fit
var thumbnailSize: CGSize?
var body: some View {
AttachmentThumbnailViewContent(
attachment: attachment,
contentMode: contentMode,
thumbnailSize: thumbnailSize
)
.id(attachment.id)
}
}
private struct AttachmentThumbnailViewContent: View {
var attachment: DraftAttachment
var contentMode: ContentMode = .fit
var thumbnailSize: CGSize?
@State private var mode: Mode = .empty
@Environment(\.composeUIConfig.fetchImageAndGIFData) private var fetchImageAndGIFData
var body: some View {
switch mode {
case .empty:
Image(systemName: "photo")
.imageScale(.large)
.foregroundStyle(.gray)
.task {
await loadThumbnail()
}
@ -27,6 +46,9 @@ struct AttachmentThumbnailView: View {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: contentMode)
.task(id: attachment.drawingData) {
await loadThumbnail()
}
case .gifController(let controller):
GIFViewWrapper(controller: controller)
}
@ -37,7 +59,7 @@ struct AttachmentThumbnailView: View {
case .editing(_, let kind, let url):
switch kind {
case .image:
if let image = await composeController.fetchAttachment(url) {
if let (image, _) = await fetchImageAndGIFData(url) {
self.mode = .image(image)
}
@ -65,7 +87,7 @@ struct AttachmentThumbnailView: View {
}
}
} else {
let size = CGSize(width: 80, height: 80)
let size = thumbnailSize ?? CGSize(width: 80, height: 80)
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { image, _ in
if let image {
self.mode = .image(image)
@ -86,7 +108,7 @@ struct AttachmentThumbnailView: View {
if let image = UIImage(data: data),
// using prepareThumbnail on images from PHPicker results in extremely high memory usage,
// crashing share extension. see FB12186346
let prepared = await image.byPreparingForDisplay() {
let prepared = await thumbnailImage(image) {
self.mode = .image(prepared)
}
}
@ -97,13 +119,33 @@ struct AttachmentThumbnailView: View {
}
}
private func thumbnailImage(_ image: UIImage) async -> UIImage? {
if let thumbnailSize {
await image.byPreparingThumbnail(ofSize: thumbnailSize)
} else {
await image.byPreparingForDisplay()
}
}
private func loadVideoThumbnail(url: URL) async {
let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
#if os(visionOS)
if let (cgImage, _) = try? await imageGenerator.image(at: .zero) {
self.mode = .image(UIImage(cgImage: cgImage))
}
#else
if #available(iOS 16.0, *) {
if let (cgImage, _) = try? await imageGenerator.image(at: .zero) {
self.mode = .image(UIImage(cgImage: cgImage))
}
} else {
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
self.mode = .image(UIImage(cgImage: cgImage))
}
}
#endif
}
enum Mode {
@ -112,3 +154,22 @@ struct AttachmentThumbnailView: View {
case gifController(GIFController)
}
}
struct GIFViewWrapper: UIViewRepresentable {
typealias UIViewType = GIFImageView
@State var controller: GIFController
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) {
}
}

View File

@ -0,0 +1,282 @@
//
// AttachmentCollectionViewCell.swift
// ComposeUI
//
// Created by Shadowfacts on 11/20/24.
//
import UIKit
import SwiftUI
import InstanceFeatures
import Vision
struct AttachmentCollectionViewCellView: View {
let attachment: DraftAttachment?
@State private var recognizingText = false
var body: some View {
if let attachment {
AttachmentThumbnailView(attachment: attachment, contentMode: .fill)
.squareFrame()
.background {
RoundedSquare(cornerRadius: 5)
.fill(.quaternary)
}
.overlay(alignment: .bottomLeading) {
AttachmentDescriptionLabel(attachment: attachment, recognizingText: recognizingText)
}
.overlay(alignment: .topLeading) {
AttachmentOptionsMenu(attachment: attachment, recognizingText: $recognizingText)
}
.overlay(alignment: .topTrailing) {
AttachmentRemoveButton(attachment: attachment)
}
.clipShape(RoundedSquare(cornerRadius: 5))
}
}
}
private struct AttachmentOptionsMenu: View {
let attachment: DraftAttachment
@Binding var recognizingText: Bool
var body: some View {
if attachment.drawingData != nil || attachment.type == .image {
Menu {
if attachment.drawingData != nil {
EditDrawingButton(attachment: attachment)
} else if attachment.type == .image {
RecognizeTextButton(attachment: attachment, recognizingText: $recognizingText)
}
} label: {
Label("Options", systemImage: "ellipsis.circle.fill")
}
.buttonStyle(AttachmentOverlayButtonStyle())
.padding([.top, .leading], 2)
}
}
}
private struct RecognizeTextButton: View {
let attachment: DraftAttachment
@Binding var recognizingText: Bool
@State private var error: (any Error)?
@EnvironmentObject private var instanceFeatures: InstanceFeatures
var body: some View {
Button(action: self.recognizeText) {
Label("Recognize Text", systemImage: "doc.text.viewfinder")
}
.alertWithData("Text Recognition Failed", data: $error) { _ in
Button("OK") {}
} message: { error in
Text(error.localizedDescription)
}
}
private func recognizeText() {
recognizingText = true
attachment.getData(features: instanceFeatures) { result in
switch result {
case .failure(let error):
self.recognizingText = false
self.error = error
case .success(let (data, _)):
let handler = VNImageRequestHandler(data: data)
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.recognizingText = false
}
}
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
DispatchQueue.global(qos: .userInitiated).async {
do {
try handler.perform([request])
} catch let error as NSError where error.code == 1 {
// The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for.
return
} catch {
DispatchQueue.main.async {
self.recognizingText = false
self.error = error
}
}
}
}
}
}
}
private struct EditDrawingButton: View {
let attachment: DraftAttachment
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
var body: some View {
Button(action: self.editDrawing) {
Label("Edit Drawing", systemImage: "hand.draw")
}
}
private func editDrawing() {
guard let drawing = attachment.drawing else {
return
}
presentDrawing?(drawing) { drawing in
self.attachment.drawing = drawing
}
}
}
private struct AttachmentRemoveButton: View {
let attachment: DraftAttachment
var body: some View {
Button("Remove", systemImage: "xmark.circle.fill") {
let draft = attachment.draft
let attachments = draft.attachments.mutableCopy() as! NSMutableOrderedSet
attachments.remove(attachment)
draft.attachments = attachments
DraftsPersistentContainer.shared.viewContext.delete(attachment)
}
.buttonStyle(AttachmentOverlayButtonStyle())
.padding([.top, .trailing], 2)
}
}
private struct AttachmentOverlayButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.labelStyle(.iconOnly)
.imageScale(.large)
.foregroundStyle(.white)
.shadow(radius: 2)
}
}
private struct AttachmentDescriptionLabel: View {
@ObservedObject var attachment: DraftAttachment
let recognizingText: Bool
var body: some View {
ZStack(alignment: .bottomLeading) {
LinearGradient(
stops: [.init(color: .clear, location: 0.6), .init(color: .black.opacity(0.15), location: 0.7), .init(color: .black.opacity(0.5), location: 1)],
startPoint: .top,
endPoint: .bottom
)
labelOrProgress
.padding([.horizontal, .bottom], 4)
}
}
@ViewBuilder
private var labelOrProgress: some View {
if recognizingText {
ProgressView()
.progressViewStyle(.circular)
} else {
label
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.75), radius: 1)
}
}
@ViewBuilder
private var label: some View {
if attachment.attachmentDescription.isEmpty {
Label("Add alt", systemImage: "pencil")
.labelStyle(NarrowSpacingLabelStyle())
.font(.callout)
.lineLimit(1)
} else {
Text(attachment.attachmentDescription)
.font(.caption)
.lineLimit(2)
}
}
}
private struct NarrowSpacingLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(spacing: 4) {
configuration.icon
configuration.title
}
}
}
private struct RoundedSquare: Shape {
let cornerRadius: CGFloat
nonisolated func path(in rect: CGRect) -> Path {
let minDimension = min(rect.width, rect.height)
let square = CGRect(x: rect.minX - (rect.width - minDimension) / 2, y: rect.minY - (rect.height - minDimension), width: minDimension, height: minDimension)
return RoundedRectangle(cornerRadius: cornerRadius).path(in: square)
}
}
@available(iOS 16.0, *)
private struct SquareFrame: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
precondition(subviews.count == 1)
let size = proposal.replacingUnspecifiedDimensions(by: subviews[0].sizeThatFits(proposal))
let minDimension = min(size.width, size.height)
return CGSize(width: minDimension, height: minDimension)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
precondition(subviews.count == 1)
let subviewSize = subviews[0].sizeThatFits(proposal)
let minDimension = min(bounds.width, bounds.height)
let origin = CGPoint(x: bounds.minX - (subviewSize.width - minDimension) / 2, y: bounds.minY - (subviewSize.height - minDimension) / 2)
subviews[0].place(at: origin, proposal: ProposedViewSize(subviewSize))
}
}
#if !os(visionOS)
@available(iOS, obsoleted: 16.0)
private struct LegacySquareFrame<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
GeometryReader { proxy in
let minDimension = min(proxy.size.width, proxy.size.height)
content
.frame(width: minDimension, height: minDimension, alignment: .center)
}
}
}
#endif
private extension View {
@ViewBuilder
func squareFrame() -> some View {
#if os(visionOS)
SquareFrame {
self
}
#else
if #available(iOS 16.0, *) {
SquareFrame {
self
}
} else {
LegacySquareFrame {
self
}
}
#endif
}
}

View File

@ -0,0 +1,217 @@
//
// AttachmentWrapperGalleryContentViewController.swift
// ComposeUI
//
// Created by Shadowfacts on 11/22/24.
//
import UIKit
import GalleryVC
class AttachmentWrapperGalleryContentViewController: UIViewController, GalleryContentViewController {
let draftAttachment: DraftAttachment
let wrapped: any GalleryContentViewController
var container: (any GalleryContentViewControllerContainer)?
var contentSize: CGSize {
wrapped.contentSize
}
var activityItemsForSharing: [Any] {
wrapped.activityItemsForSharing
}
var caption: String? {
wrapped.caption
}
private lazy var editDescriptionViewController: EditAttachmentDescriptionViewController = EditAttachmentDescriptionViewController(draftAttachment: draftAttachment, wrapped: wrapped.bottomControlsAccessoryViewController)
var bottomControlsAccessoryViewController: UIViewController? {
editDescriptionViewController
}
var insetBottomControlsAccessoryViewControllerToSafeArea: Bool {
false
}
var presentationAnimation: GalleryContentPresentationAnimation {
wrapped.presentationAnimation
}
var hideControlsOnZoom: Bool {
false
}
var showBelowSafeArea: Bool {
false
}
init(draftAttachment: DraftAttachment, wrapped: any GalleryContentViewController) {
self.draftAttachment = draftAttachment
self.wrapped = wrapped
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
wrapped.container = container
addChild(wrapped)
wrapped.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(wrapped.view)
NSLayoutConstraint.activate([
wrapped.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
wrapped.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
wrapped.view.topAnchor.constraint(equalTo: view.topAnchor),
wrapped.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
wrapped.didMove(toParent: self)
}
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
wrapped.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
if !visible {
editDescriptionViewController.textView?.resignFirstResponder()
}
}
func setInsetForBottomControls(_ inset: CGFloat) {
wrapped.setInsetForBottomControls(inset)
}
func galleryContentDidAppear() {
wrapped.galleryContentDidAppear()
}
func galleryContentWillDisappear() {
wrapped.galleryContentWillDisappear()
}
func shouldHideControls() -> Bool {
if editDescriptionViewController.textView.isFirstResponder {
editDescriptionViewController.textView.resignFirstResponder()
return false
} else {
return true
}
}
func galleryShouldBeginInteractiveDismiss() -> Bool {
if editDescriptionViewController.textView.isFirstResponder {
editDescriptionViewController.textView.resignFirstResponder()
return false
} else {
return true
}
}
}
private class EditAttachmentDescriptionViewController: UIViewController {
private let draftAttachment: DraftAttachment
private let wrapped: UIViewController?
private(set) var textView: UITextView!
private var isShowingPlaceholder = false
private var descriptionObservation: NSKeyValueObservation?
init(draftAttachment: DraftAttachment, wrapped: UIViewController?) {
self.draftAttachment = draftAttachment
self.wrapped = wrapped
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.overrideUserInterfaceStyle = .dark
view.backgroundColor = .secondarySystemFill
let stack = UIStackView()
stack.axis = .vertical
stack.distribution = .fill
stack.spacing = 0
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
stack.topAnchor.constraint(equalTo: view.topAnchor),
stack.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
])
if let wrapped {
addChild(wrapped)
stack.addArrangedSubview(wrapped.view)
wrapped.didMove(toParent: self)
}
textView = UITextView()
textView.backgroundColor = nil
textView.font = .preferredFont(forTextStyle: .body)
textView.adjustsFontForContentSizeCategory = true
if draftAttachment.attachmentDescription.isEmpty {
showPlaceholder()
} else {
removePlaceholder()
textView.text = draftAttachment.attachmentDescription
}
textView.delegate = self
stack.addArrangedSubview(textView)
textView.heightAnchor.constraint(equalToConstant: 150).isActive = true
descriptionObservation = draftAttachment.observe(\.attachmentDescription) { [unowned self] _, _ in
let desc = self.draftAttachment.attachmentDescription
if desc.isEmpty {
if !isShowingPlaceholder {
showPlaceholder()
}
} else {
if isShowingPlaceholder {
removePlaceholder()
}
self.textView.text = desc
}
}
}
fileprivate func showPlaceholder() {
isShowingPlaceholder = true
textView.text = "Describe for the visually impaired"
textView.textColor = .secondaryLabel
}
fileprivate func removePlaceholder() {
isShowingPlaceholder = false
textView.text = ""
textView.textColor = .label
}
}
extension EditAttachmentDescriptionViewController: UITextViewDelegate {
func textViewDidBeginEditing(_ textView: UITextView) {
if isShowingPlaceholder {
removePlaceholder()
}
}
func textViewDidEndEditing(_ textView: UITextView) {
draftAttachment.attachmentDescription = textView.text
if textView.text.isEmpty {
showPlaceholder()
}
}
}

View File

@ -0,0 +1,117 @@
//
// AttachmentsGalleryDataSource.swift
// ComposeUI
//
// Created by Shadowfacts on 11/21/24.
//
import UIKit
import GalleryVC
import TuskerComponents
import Photos
struct AttachmentsGalleryDataSource: GalleryDataSource {
let collectionView: UICollectionView
let fetchImageAndGIFData: (URL) async -> (UIImage, Data)?
let makeGifvGalleryContentVC: (URL) -> (any GalleryContentViewController)?
let attachmentAtIndex: (Int) -> DraftAttachment?
func galleryItemsCount() -> Int {
collectionView.numberOfItems(inSection: 0) - 1
}
func galleryContentViewController(forItemAt index: Int) -> any GalleryVC.GalleryContentViewController {
let attachment = attachmentAtIndex(index)!
let content: any GalleryContentViewController
switch attachment.data {
case .editing(_, let kind, let url):
switch kind {
case .image:
content = LoadingGalleryContentViewController(caption: nil) {
if let (image, data) = await fetchImageAndGIFData(url) {
let gifController: GIFController? = if url.pathExtension == "gif" {
GIFController(gifData: data)
} else {
nil
}
return ImageGalleryContentViewController(image: image, caption: nil, gifController: gifController)
} else {
return nil
}
}
case .video, .audio:
content = VideoGalleryContentViewController(url: url, caption: nil)
case .gifv:
content = LoadingGalleryContentViewController(caption: nil) { makeGifvGalleryContentVC(url) }
case .unknown:
content = LoadingGalleryContentViewController(caption: nil) { nil }
}
case .asset(let id):
content = LoadingGalleryContentViewController(caption: nil) {
if let (image, gifData) = await fetchAssetImageAndGIFData(assetID: id) {
let gifController = gifData.map(GIFController.init)
return ImageGalleryContentViewController(image: image, caption: nil, gifController: gifController)
} else {
return nil
}
}
case .drawing(let drawing):
let image = drawing.imageInLightMode(from: drawing.bounds)
content = ImageGalleryContentViewController(image: image, caption: nil, gifController: nil)
case .file(let url, let type):
if type.conforms(to: .movie) {
content = VideoGalleryContentViewController(url: url, caption: nil)
} else if type.conforms(to: .image),
let data = try? Data(contentsOf: url),
let image = UIImage(data: data) {
let gifController = type == .gif ? GIFController(gifData: data) : nil
content = ImageGalleryContentViewController(image: image, caption: nil, gifController: gifController)
} else {
return LoadingGalleryContentViewController(caption: nil) {
nil
}
}
case .none:
return LoadingGalleryContentViewController(caption: nil) {
nil
}
}
return AttachmentWrapperGalleryContentViewController(draftAttachment: attachment, wrapped: content)
}
func galleryContentTransitionSourceView(forItemAt index: Int) -> UIView? {
if let cell = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) as? HostingCollectionViewCell {
// Use the hostView, because otherwise, the animation's changes to the source view opacity get clobbered by SwiftUI
cell.hostView
} else {
nil
}
}
private func fetchAssetImageAndGIFData(assetID id: String) async -> (UIImage, Data?)? {
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
return nil
}
let (type, data) = await withCheckedContinuation { continuation in
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in
continuation.resume(returning: (typeIdentifier, data))
}
}
guard let data,
let image = UIImage(data: data) else {
return nil
}
if type == UTType.gif.identifier {
return (image, data)
} else {
return (image, nil)
}
}
}

View File

@ -0,0 +1,554 @@
//
// AttachmentsSection.swift
// ComposeUI
//
// Created by Shadowfacts on 11/17/24.
//
import SwiftUI
import PhotosUI
import PencilKit
import GalleryVC
import InstanceFeatures
struct AttachmentsSection: View {
@ObservedObject var draft: Draft
private let spacing: CGFloat = 8
private let minItemSize: CGFloat = 100
var body: some View {
#if os(visionOS)
collectionView
#else
if #available(iOS 16.0, *) {
collectionView
} else {
LegacyCollectionViewSizingView {
collectionView
} computeHeight: { width in
WrappedCollectionView.totalHeight(width: width, minItemSize: minItemSize, spacing: spacing, items: draft.attachments.count + 1)
}
}
#endif
}
private var collectionView: some View {
WrappedCollectionView(
draft: draft,
spacing: spacing,
minItemSize: minItemSize
)
// Impose a minimum height, because otherwise it defaults to zero which prevents the collection
// view from laying out, and leaving the intrinsic content size at zero too.
// Add 4 to the minItemSize because otherwise drag-and-drop while reordering can alter the contentOffset by that much.
.frame(minHeight: minItemSize + 4)
}
static func insertAttachments(in draft: Draft, at index: Int, itemProviders: [NSItemProvider]) {
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
guard let attachment = object as? DraftAttachment else { return }
DispatchQueue.main.async {
DraftsPersistentContainer.shared.viewContext.insert(attachment)
attachment.draft = draft
draft.attachments.add(attachment)
}
}
}
}
}
#if !os(visionOS)
@available(iOS, obsoleted: 16.0)
private struct LegacyCollectionViewSizingView<Content: View>: View {
@ViewBuilder let content: Content
let computeHeight: (CGFloat) -> CGFloat
@State private var width: CGFloat = 0
var body: some View {
let height = computeHeight(width)
content
.frame(height: max(height, 10))
.overlay {
GeometryReader { proxy in
Color.clear
.preference(key: WidthPrefKey.self, value: proxy.size.width)
.onPreferenceChange(WidthPrefKey.self) {
width = $0
}
}
}
}
}
private struct WidthPrefKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
let next = nextValue()
if next != 0 {
value = next
}
}
}
#endif
// Use a UIViewControllerRepresentable so we have something from which to present the gallery VC.
private struct WrappedCollectionView: UIViewControllerRepresentable {
@ObservedObject var draft: Draft
let spacing: CGFloat
let minItemSize: CGFloat
@Environment(\.composeUIConfig.fetchImageAndGIFData) private var fetchImageAndGIFData
@Environment(\.composeUIConfig.makeGifvGalleryContentVC) private var makeGifvGalleryContentVC
func makeUIViewController(context: Context) -> WrappedCollectionViewController {
WrappedCollectionViewController(
spacing: spacing,
minItemSize: minItemSize,
fetchImageAndGIFData: fetchImageAndGIFData,
makeGifvGalleryContentVC: makeGifvGalleryContentVC
)
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
uiViewController.draft = draft
uiViewController.addAttachment = {
DraftsPersistentContainer.shared.viewContext.insert($0)
$0.draft = draft
draft.attachments.add($0)
}
uiViewController.updateAttachments()
}
@available(iOS 16.0, *)
func sizeThatFits(_ proposal: ProposedViewSize, uiViewController: WrappedCollectionViewController, context: Context) -> CGSize? {
guard let width = proposal.width,
width.isFinite else {
return nil
}
let count = draft.attachments.count + 1
return CGSize(
width: width,
height: Self.totalHeight(width: width, minItemSize: minItemSize, spacing: spacing, items: count)
)
}
fileprivate static func itemSize(width: CGFloat, minItemSize: CGFloat, spacing: CGFloat) -> (CGFloat, Int) {
// The maximum item size is 2*minItemSize + spacing - 1,
// in the case where one item fits in the row but we are one pt short of
// adding a second item.
var itemSize = minItemSize
var fittingCount = floor((width + spacing) / (itemSize + spacing))
var usedSpaceForFittingCount = fittingCount * itemSize + (fittingCount - 1) * spacing
var remainingSpace = width - usedSpaceForFittingCount
if fittingCount == 0 {
return (0, 0)
} else if fittingCount == 1 && remainingSpace > minItemSize / 2 {
// If there's only one item that would fit at min size, and giving
// it the rest of the space would increase it by at least 50%,
// add a second item anywyas.
itemSize = (width - spacing) / 2
fittingCount = 2
usedSpaceForFittingCount = fittingCount * itemSize + (fittingCount - 1) * spacing
remainingSpace = width - usedSpaceForFittingCount
}
itemSize = itemSize + remainingSpace / fittingCount
return (itemSize, Int(fittingCount))
}
fileprivate static func totalHeight(width: CGFloat, minItemSize: CGFloat, spacing: CGFloat, items: Int) -> CGFloat {
let (size, itemsPerRow) = itemSize(width: width, minItemSize: minItemSize, spacing: spacing)
guard itemsPerRow != 0 else {
return 0
}
let rows = ceil(Double(items) / Double(itemsPerRow))
return size * rows + spacing * (rows - 1)
}
}
private class WrappedCollectionViewController: UIViewController {
let spacing: CGFloat
let minItemSize: CGFloat
var draft: Draft!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
fileprivate var currentInteractiveMoveStartOffsetInCell: CGPoint?
fileprivate var currentInteractiveMoveCell: HostingCollectionViewCell?
fileprivate var addAttachment: ((DraftAttachment) -> Void)? = nil
fileprivate var fetchImageAndGIFData: (URL) async -> (UIImage, Data)?
fileprivate var makeGifvGalleryContentVC: (URL) -> (any GalleryContentViewController)?
var collectionView: UICollectionView {
view as! UICollectionView
}
init(
spacing: CGFloat,
minItemSize: CGFloat,
fetchImageAndGIFData: @escaping (URL) async -> (UIImage, Data)?,
makeGifvGalleryContentVC: @escaping (URL) -> (any GalleryContentViewController)?
) {
self.spacing = spacing
self.minItemSize = minItemSize
self.fetchImageAndGIFData = fetchImageAndGIFData
self.makeGifvGalleryContentVC = makeGifvGalleryContentVC
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
let layout = UICollectionViewCompositionalLayout { [unowned self] section, environment in
let (itemSize, itemsPerRow) = WrappedCollectionView.itemSize(width: environment.container.contentSize.width, minItemSize: minItemSize, spacing: spacing)
let items = Array(repeating: NSCollectionLayoutItem(layoutSize: .init(widthDimension: .absolute(itemSize), heightDimension: .absolute(itemSize))), count: itemsPerRow)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(itemSize)), subitems: items)
group.interItemSpacing = .fixed(spacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = spacing
return section
}
let attachmentCell = UICollectionView.CellRegistration<HostingCollectionViewCell, DraftAttachment> { [unowned self] cell, indexPath, attachment in
#if !os(visionOS)
cell.containingViewController = self
#endif
cell.setView(AttachmentCollectionViewCellView(attachment: attachment))
}
let addButtonCell = UICollectionView.CellRegistration<HostingCollectionViewCell, Void> { [unowned self] cell, indexPath, item in
#if !os(visionOS)
cell.containingViewController = self
#endif
cell.setView(AddAttachmentButton(viewController: self))
}
let collectionView = IntrinsicContentSizeCollectionView(frame: .zero, collectionViewLayout: layout)
self.view = collectionView
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .attachment(let attachment):
return collectionView.dequeueConfiguredReusableCell(using: attachmentCell, for: indexPath, item: attachment)
case .addButton:
return collectionView.dequeueConfiguredReusableCell(using: addButtonCell, for: indexPath, item: ())
}
}
dataSource.reorderingHandlers.canReorderItem = { item in
switch item {
case .attachment(_):
true
case .addButton:
false
}
}
dataSource.reorderingHandlers.didReorder = { [unowned self] transaction in
let attachmentChanges = transaction.difference.map {
switch $0 {
case .insert(let offset, let element, let associatedWith):
guard case .attachment(let attachment) = element else { fatalError() }
return CollectionDifference<DraftAttachment>.Change.insert(offset: offset, element: attachment, associatedWith: associatedWith)
case .remove(let offset, let element, let associatedWith):
guard case .attachment(let attachment) = element else { fatalError() }
return CollectionDifference<DraftAttachment>.Change.remove(offset: offset, element: attachment, associatedWith: associatedWith)
}
}
let attachmentsDiff = CollectionDifference(attachmentChanges)!
let array = draft.draftAttachments.applying(attachmentsDiff)!
draft.attachments = NSMutableOrderedSet(array: array)
}
collectionView.isScrollEnabled = false
collectionView.clipsToBounds = false
collectionView.delegate = self
let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(reorderingLongPressRecognized))
longPressRecognizer.delegate = self
collectionView.addGestureRecognizer(longPressRecognizer)
}
func updateAttachments() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.all])
snapshot.appendItems(draft.draftAttachments.map { .attachment($0) })
snapshot.appendItems([.addButton])
dataSource.apply(snapshot)
}
@objc func reorderingLongPressRecognized(_ recognizer: UILongPressGestureRecognizer) {
let collectionView = recognizer.view as! UICollectionView
switch recognizer.state {
case .began:
break
case .changed:
var pos = recognizer.location(in: collectionView)
if let currentInteractiveMoveStartOffsetInCell {
pos.x -= currentInteractiveMoveStartOffsetInCell.x
pos.y -= currentInteractiveMoveStartOffsetInCell.y
}
collectionView.updateInteractiveMovementTargetPosition(pos)
case .ended:
collectionView.endInteractiveMovement()
UIView.animate(withDuration: 0.2) {
self.currentInteractiveMoveCell?.hostView?.transform = .identity
}
currentInteractiveMoveCell = nil
currentInteractiveMoveStartOffsetInCell = nil
case .cancelled:
collectionView.cancelInteractiveMovement()
UIView.animate(withDuration: 0.2) {
self.currentInteractiveMoveCell?.hostView?.transform = .identity
}
currentInteractiveMoveCell = nil
currentInteractiveMoveStartOffsetInCell = nil
default:
break
}
}
enum Section {
case all
}
enum Item: Hashable {
case attachment(DraftAttachment)
case addButton
}
}
extension WrappedCollectionViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
let collectionView = gestureRecognizer.view as! UICollectionView
let location = gestureRecognizer.location(in: collectionView)
guard let indexPath = collectionView.indexPathForItem(at: location),
let cell = collectionView.cellForItem(at: indexPath) as? HostingCollectionViewCell else {
return false
}
guard collectionView.beginInteractiveMovementForItem(at: indexPath) else {
return false
}
UIView.animate(withDuration: 0.2) {
cell.hostView?.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
}
currentInteractiveMoveCell = cell
currentInteractiveMoveStartOffsetInCell = gestureRecognizer.location(in: cell)
currentInteractiveMoveStartOffsetInCell!.x -= cell.bounds.midX
currentInteractiveMoveStartOffsetInCell!.y -= cell.bounds.midY
return true
}
}
extension WrappedCollectionViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveOfItemFromOriginalIndexPath originalIndexPath: IndexPath, atCurrentIndexPath currentIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
let snapshot = dataSource.snapshot()
let items = snapshot.itemIdentifiers(inSection: .all).count
if proposedIndexPath.row == items - 1 {
return IndexPath(item: items - 2, section: proposedIndexPath.section)
} else {
return proposedIndexPath
}
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard case .attachment(_) = dataSource.itemIdentifier(for: indexPath) else {
return
}
let dataSource = AttachmentsGalleryDataSource(
collectionView: collectionView,
fetchImageAndGIFData: self.fetchImageAndGIFData,
makeGifvGalleryContentVC: self.makeGifvGalleryContentVC
) { [dataSource] in
let item = dataSource?.itemIdentifier(for: IndexPath(item: $0, section: 0))
switch item {
case .attachment(let attachment):
return attachment
default:
return nil
}
}
let galleryVC = GalleryViewController(dataSource: dataSource, initialItemIndex: indexPath.item)
galleryVC.showShareButton = false
present(galleryVC, animated: true)
}
}
private final class IntrinsicContentSizeCollectionView: UICollectionView {
private var _intrinsicContentSize = CGSize.zero
override var intrinsicContentSize: CGSize {
_intrinsicContentSize
}
override func layoutSubviews() {
super.layoutSubviews()
if contentSize != _intrinsicContentSize {
_intrinsicContentSize = contentSize
invalidateIntrinsicContentSize()
}
}
}
#if os(visionOS)
final class HostingCollectionViewCell: UICollectionViewCell {
private(set) var hostView: UIView?
func setView<V: View>(_ view: V) {
let config = UIHostingConfiguration(content: {
view
}).margins(.all, 0)
if let hostView = hostView as? UIContentView {
hostView.configuration = config
} else {
hostView = config.makeContentView()
hostView!.frame = contentView.bounds
contentView.addSubview(hostView!)
}
}
}
#else
@available(iOS, obsoleted: 16.0)
final class HostingCollectionViewCell: UICollectionViewCell {
weak var containingViewController: UIViewController?
@available(iOS, obsoleted: 16.0)
private var hostController: UIHostingController<AnyView>?
private(set) var hostView: UIView?
func setView<V: View>(_ view: V) {
if #available(iOS 16.0, *) {
let config = UIHostingConfiguration(content: {
view
}).margins(.all, 0)
// We don't just use the cell's contentConfiguration property because we need to animate
// the size of the host view, and when the host view is the contentView, that doesn't work.
if let hostView = hostView as? UIContentView {
hostView.configuration = config
} else {
hostView = config.makeContentView()
hostView!.frame = contentView.bounds
contentView.addSubview(hostView!)
}
} else {
if let hostController {
hostController.rootView = AnyView(view)
} else {
let host = UIHostingController(rootView: AnyView(view))
containingViewController!.addChild(host)
host.view.frame = contentView.bounds
contentView.addSubview(host.view)
host.didMove(toParent: containingViewController!)
hostController = host
hostView = host.view
}
}
}
}
#endif
private struct AddAttachmentButton: View {
unowned let viewController: WrappedCollectionViewController
@Environment(\.canAddAttachment) private var enabled
@Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
var body: some View {
Menu {
if let presentAssetPicker {
Button("Add photo or video", systemImage: "photo") {
presentAssetPicker {
let draft = viewController.draft!
AttachmentsSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: $0.map(\.itemProvider))
}
}
}
if let presentDrawing {
Button("Draw something", systemImage: "hand.draw") {
presentDrawing(PKDrawing()) { drawing in
let draft = viewController.draft!
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
attachment.id = UUID()
attachment.drawing = drawing
attachment.draft = draft
draft.attachments.add(attachment)
}
}
}
} label: {
Image(systemName: iconName)
.imageScale(.large)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
RoundedRectangle(cornerRadius: 5)
.foregroundStyle(.tint.opacity(0.1))
RoundedRectangle(cornerRadius: 5)
.stroke(.tint, style: StrokeStyle(lineWidth: 2, dash: [5]))
}
}
.disabled(!enabled)
.animation(.linear(duration: 0.2), value: enabled)
}
private var iconName: String {
if #available(iOS 17.0, *) {
"photo.badge.plus"
} else {
"photo"
}
}
}
struct AddAttachmentConditionsModifier: ViewModifier {
@ObservedObject var draft: Draft
@EnvironmentObject private var instanceFeatures: InstanceFeatures
private var canAddAttachment: Bool {
if instanceFeatures.mastodonAttachmentRestrictions {
return draft.attachments.count < 4
&& draft.draftAttachments.allSatisfy { $0.type == .image }
&& !draft.pollEnabled
} else {
return true
}
}
func body(content: Content) -> some View {
content
.environment(\.canAddAttachment, canAddAttachment)
}
}
private struct CanAddAttachmentKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
var canAddAttachment: Bool {
get { self[CanAddAttachmentKey.self] }
set { self[CanAddAttachmentKey.self] = newValue }
}
}
struct DropAttachmentModifier: ViewModifier {
let draft: Draft
@Environment(\.canAddAttachment) private var canAddAttachment
func body(content: Content) -> some View {
content
.onDrop(of: DraftAttachment.readableTypeIdentifiersForItemProvider, delegate: AttachmentDropDelegate(draft: draft, canAddAttachment: canAddAttachment))
}
}
private struct AttachmentDropDelegate: DropDelegate {
let draft: Draft
let canAddAttachment: Bool
func validateDrop(info: DropInfo) -> Bool {
canAddAttachment
}
func performDrop(info: DropInfo) -> Bool {
AttachmentsSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: info.itemProviders(for: DraftAttachment.readableTypeIdentifiersForItemProvider))
return true
}
}

View File

@ -1,182 +0,0 @@
//
// AttachmentsListSection.swift
// ComposeUI
//
// Created by Shadowfacts on 10/14/24.
//
import SwiftUI
import InstanceFeatures
import PencilKit
struct AttachmentsListSection: View {
@ObservedObject var draft: Draft
@ObservedObject var instanceFeatures: InstanceFeatures
@Environment(\.canAddAttachment) private var canAddAttachment
var body: some View {
attachmentRows
buttons
.foregroundStyle(.tint)
#if os(visionOS)
.buttonStyle(.bordered)
.labelStyle(AttachmentButtonLabelStyle())
#endif
}
private var attachmentRows: some View {
ForEach(draft.draftAttachments) { attachment in
AttachmentRowView(attachment: attachment)
}
.onMove(perform: moveAttachments)
.onDelete(perform: deleteAttachments)
.onInsert(of: DraftAttachment.readableTypeIdentifiersForItemProvider) { offset, providers in
Self.insertAttachments(in: draft, at: offset, itemProviders: providers)
}
}
@ViewBuilder
private var buttons: some View {
AddPhotoButton(addAttachments: self.addAttachments)
AddDrawingButton(draft: draft)
TogglePollButton(draft: draft)
}
static func insertAttachments(in draft: Draft, at index: Int, itemProviders: [NSItemProvider]) {
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
guard let attachment = object as? DraftAttachment else { return }
DispatchQueue.main.async {
DraftsPersistentContainer.shared.viewContext.insert(attachment)
attachment.draft = draft
draft.attachments.add(attachment)
}
}
}
}
private func addAttachments(itemProviders: [NSItemProvider]) {
Self.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders)
}
private func moveAttachments(from source: IndexSet, to destination: Int) {
// just using moveObjects(at:to:) on the draft.attachments NSMutableOrderedSet
// results in the order switching back to the previous order and then to the correct one
// on the subsequent 2 view updates. creating a new set with the proper order doesn't have that problem
var array = draft.draftAttachments
array.move(fromOffsets: source, toOffset: destination)
draft.attachments = NSMutableOrderedSet(array: array)
}
private func deleteAttachments(at indices: IndexSet) {
var array = draft.draftAttachments
array.remove(atOffsets: indices)
draft.attachments = NSMutableOrderedSet(array: array)
}
}
private struct AddPhotoButton: View {
let addAttachments: ([NSItemProvider]) -> Void
@Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker
var body: some View {
if let presentAssetPicker {
Button("Add photo or video", systemImage: "photo") {
presentAssetPicker { results in
addAttachments(results.map(\.itemProvider))
}
}
}
}
}
private struct AddDrawingButton: View {
let draft: Draft
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
var body: some View {
if let presentDrawing {
Button("Add drawing", systemImage: "hand.draw") {
presentDrawing(PKDrawing()) { drawing in
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
attachment.id = UUID()
attachment.drawing = drawing
attachment.draft = self.draft
self.draft.attachments.add(attachment)
}
}
}
}
}
private struct TogglePollButton: View {
@ObservedObject var draft: Draft
var body: some View {
Button(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal") {
withAnimation {
draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil
}
}
.disabled(draft.attachments.count > 0)
}
}
struct AddAttachmentConditionsModifier: ViewModifier {
@ObservedObject var draft: Draft
@ObservedObject var instanceFeatures: InstanceFeatures
private var canAddAttachment: Bool {
if instanceFeatures.mastodonAttachmentRestrictions {
return draft.attachments.count < 4
&& draft.draftAttachments.allSatisfy { $0.type == .image }
&& draft.poll == nil
} else {
return true
}
}
func body(content: Content) -> some View {
content
.environment(\.canAddAttachment, canAddAttachment)
}
}
private struct CanAddAttachmentKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
var canAddAttachment: Bool {
get { self[CanAddAttachmentKey.self] }
set { self[CanAddAttachmentKey.self] = newValue }
}
}
struct DropAttachmentModifier: ViewModifier {
let draft: Draft
@Environment(\.canAddAttachment) private var canAddAttachment
func body(content: Content) -> some View {
content
.onDrop(of: DraftAttachment.readableTypeIdentifiersForItemProvider, delegate: AttachmentDropDelegate(draft: draft, canAddAttachment: canAddAttachment))
}
}
private struct AttachmentDropDelegate: DropDelegate {
let draft: Draft
let canAddAttachment: Bool
func validateDrop(info: DropInfo) -> Bool {
canAddAttachment
}
func performDrop(info: DropInfo) -> Bool {
AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: info.itemProviders(for: DraftAttachment.readableTypeIdentifiersForItemProvider))
return true
}
}

View File

@ -0,0 +1,242 @@
//
// ComposeNavigationBarActions.swift
// ComposeUI
//
// Created by Shadowfacts on 1/30/25.
//
import SwiftUI
import Combine
import InstanceFeatures
import TuskerPreferences
struct ComposeNavigationBarActions: ToolbarContent {
@ObservedObject var draft: Draft
@Binding var isShowingDrafts: Bool
let isPosting: Bool
let cancel: (_ deleteDraft: Bool) -> Void
let postStatus: () async -> Void
var body: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
ToolbarCancelButton(draft: draft, isPosting: isPosting, cancel: cancel)
}
#if targetEnvironment(macCatalyst)
ToolbarItem(placement: .topBarTrailing) {
DraftsButton(isShowingDrafts: $isShowingDrafts)
}
ToolbarItem(placement: .confirmationAction) {
PostButton(draft: draft, isPosting: isPosting, postStatus: postStatus)
}
#else
ToolbarItem(placement: .confirmationAction) {
PostOrDraftsButton(draft: draft, isShowingDrafts: $isShowingDrafts, isPosting: isPosting, postStatus: postStatus)
}
#endif
}
}
private struct ToolbarCancelButton: View {
let draft: Draft
let isPosting: Bool
let cancel: (_ deleteDraft: Bool) -> Void
@State private var isShowingSaveDraftSheet = false
var body: some View {
Button(role: .cancel, action: self.showConfirmationOrCancel) {
Text("Cancel")
}
.disabled(isPosting)
.confirmationDialog("Are you sure?", isPresented: $isShowingSaveDraftSheet) {
// edit drafts can't be saved
if draft.editedStatusID == nil {
Button(action: { cancel(false) }) {
Text("Save Draft")
}
Button(role: .destructive, action: { cancel(true) }) {
Text("Delete Draft")
}
} else {
Button(role: .destructive, action: { cancel(true) }) {
Text("Cancel Edit")
}
}
}
}
private func showConfirmationOrCancel() {
if draft.hasContent {
isShowingSaveDraftSheet = true
} else {
cancel(true)
}
}
}
#if !targetEnvironment(macCatalyst)
private struct PostOrDraftsButton: View {
@DraftObserving var draft: Draft
@Binding var isShowingDrafts: Bool
let isPosting: Bool
let postStatus: () async -> Void
@Environment(\.composeUIConfig.allowSwitchingDrafts) private var allowSwitchingDrafts
var body: some View {
if !draftIsEmpty || draft.editedStatusID != nil || !allowSwitchingDrafts {
PostButton(draft: draft, isPosting: isPosting, postStatus: postStatus)
} else {
DraftsButton(isShowingDrafts: $isShowingDrafts)
}
}
private var draftIsEmpty: Bool {
draft.text == draft.initialText && (!draft.contentWarningEnabled || draft.contentWarning == draft.initialContentWarning) && draft.attachments.count == 0 && (!draft.pollEnabled || !draft.poll!.hasContent)
}
}
#endif
private struct PostButton: View {
@DraftObserving var draft: Draft
let isPosting: Bool
let postStatus: () async -> Void
@EnvironmentObject private var instanceFeatures: InstanceFeatures
@PreferenceObserving(\.$requireAttachmentDescriptions) private var requireAttachmentDescriptions
var body: some View {
Button {
Task {
await postStatus()
}
} label: {
Text(draft.editedStatusID == nil ? "Post" : "Edit")
}
.keyboardShortcut(.return, modifiers: .command)
.disabled(!draftValid)
.disabled(isPosting)
}
private var hasCharactersRemaining: Bool {
let limit = instanceFeatures.maxStatusChars
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
let bodyCount = CharacterCounter.count(text: draft.text, for: instanceFeatures)
let remaining = limit - (cwCount + bodyCount)
return remaining >= 0
}
private var attachmentsCombinationValid: Bool {
if !instanceFeatures.mastodonAttachmentRestrictions {
true
} else if draft.attachments.count > 1,
draft.draftAttachments.contains(where: { $0.type == .video }) {
false
} else if draft.attachments.count > 4 {
false
} else {
true
}
}
private var attachmentsValid: Bool {
(!requireAttachmentDescriptions || draft.draftAttachments.allSatisfy { !$0.attachmentDescription.isEmpty })
&& attachmentsCombinationValid
}
private var pollValid: Bool {
!draft.pollEnabled || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty }
}
private var draftValid: Bool {
draft.editedStatusID != nil ||
((draft.hasText || draft.attachments.count > 0)
&& hasCharactersRemaining
&& attachmentsValid
&& pollValid)
}
}
private struct DraftsButton: View {
@Binding var isShowingDrafts: Bool
var body: some View {
Button {
isShowingDrafts = true
} label: {
Text("Drafts")
}
}
}
// This property wrapper lets a View observe all of the following:
// 1. The Draft itself
// 2. The Draft's Poll (if it has one)
// 3. Each of the Poll's PollOptions (if there is a Poll)
// 4. Each of the Draft's DraftAttachments
@propertyWrapper
private struct DraftObserving: DynamicProperty {
let wrappedValue: Draft
@StateObject private var observer = Observer()
init(wrappedValue: Draft) {
self.wrappedValue = wrappedValue
}
func update() {
observer.update(draft: wrappedValue)
}
private class Observer: ObservableObject {
private var draft: Draft?
private var cancellable: AnyCancellable?
private var draftPollObservation: NSKeyValueObservation?
private var pollOptionsObservation: NSKeyValueObservation?
private var pollOptionsCancellables: [AnyCancellable] = []
private var draftAttachmentsObservation: NSKeyValueObservation?
private var draftAttachmentsCancellables: [AnyCancellable] = []
func update(draft: Draft) {
guard draft !== self.draft else {
return
}
self.draft = draft
cancellable = draft.objectWillChange
.sink { [unowned self] _ in self.objectWillChange.send() }
draftPollObservation = draft.observe(\.poll) { [unowned self] _, _ in
objectWillChange.send()
self.pollChanged()
}
pollChanged()
draftAttachmentsObservation = draft.observe(\.attachments) { [unowned self] _, _ in
objectWillChange.send()
self.draftAttachmentsChanged()
}
draftAttachmentsChanged()
}
private func pollChanged() {
pollOptionsObservation = (draft?.poll).map {
$0.observe(\.options) { [unowned self] _, _ in
objectWillChange.send()
self.pollOptionsChanged()
}
}
pollOptionsChanged()
}
private func pollOptionsChanged() {
pollOptionsCancellables = draft?.poll?.pollOptions.map {
$0.objectWillChange
.sink { [unowned self] _ in self.objectWillChange.send() }
} ?? []
}
private func draftAttachmentsChanged() {
draftAttachmentsCancellables = draft?.draftAttachments.map {
$0.objectWillChange
.sink { [unowned self] _ in self.objectWillChange.send() }
} ?? []
}
}
}

View File

@ -36,7 +36,7 @@ struct ComposeToolbarView: View {
}
private var buttons: some View {
HStack(spacing: 0) {
HStack(spacing: 4) {
ContentWarningButton(enabled: $draft.contentWarningEnabled, focusedField: $focusedField)
VisibilityButton(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
@ -48,8 +48,6 @@ struct ComposeToolbarView: View {
FormatButtons()
Spacer()
LangaugeButton(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
}
}
}
@ -74,7 +72,7 @@ private struct ToolbarScrollView<Content: View>: View {
}
}
}
.scrollDisabled(realWidth ?? 0 <= minWidth ?? 0)
.scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
.frame(maxWidth: .infinity)
.background {
GeometryReader { proxy in
@ -109,13 +107,23 @@ private struct ContentWarningButton: View {
private func toggleContentWarning() {
enabled.toggle()
if enabled {
focusedField = .contentWarning
if focusedField != nil {
if enabled {
focusedField = .contentWarning
} else if focusedField == .contentWarning {
focusedField = .body
}
}
}
}
private struct VisibilityButton: View {
private static var allOptions: [MenuPicker<Pachyderm.Visibility>.Option] {
Pachyderm.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: Draft
@ObservedObject var instanceFeatures: InstanceFeatures
@ -131,29 +139,24 @@ private struct VisibilityButton: View {
}
private var visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] {
let visibilities: [Pachyderm.Visibility]
if !instanceFeatures.composeDirectStatuses {
visibilities = [.public, .unlisted, .private]
Self.allOptions.filter { $0.value != .direct }
} else {
visibilities = Pachyderm.Visibility.allCases
}
return visibilities.map { vis in
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
Self.allOptions
}
}
var body: some View {
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
#if !targetEnvironment(macCatalyst) && !os(visionOS)
// the button has a bunch of extra space by default, but combined with what we add it's too much
.padding(.horizontal, -8)
#endif
.disabled(draft.editedStatusID != nil)
.disabled(instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
}
}
private struct LocalOnlyButton: View {
private static let localOnlyImage = UIImage(named: "link.broken")!
private static let federatedImage = UIImage(systemName: "link")!
@Binding var enabled: Bool
var mastodonController: any ComposeMastodonContext
@ObservedObject private var instanceFeatures: InstanceFeatures
@ -167,8 +170,8 @@ private struct LocalOnlyButton: View {
private var options: [MenuPicker<Bool>.Option] {
let domain = mastodonController.accountInfo!.instanceURL.host!
return [
.init(value: true, title: "Local-only", subtitle: "Only \(domain)", image: UIImage(named: "link.broken")),
.init(value: false, title: "Federated", image: UIImage(systemName: "link")),
.init(value: true, title: "Local-only", subtitle: "Only \(domain)", image: Self.localOnlyImage),
.init(value: false, title: "Federated", image: Self.federatedImage),
]
}
@ -180,7 +183,7 @@ private struct LocalOnlyButton: View {
}
private struct InsertEmojiButton: View {
@FocusedValue(\.composeInput) private var input
@FocusedInput private var input
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
var body: some View {
@ -202,7 +205,7 @@ private struct InsertEmojiButton: View {
}
private struct FormatButtons: View {
@FocusedValue(\.composeInput) private var input
@FocusedInput private var input
@PreferenceObserving(\.$statusContentType) private var contentType
var body: some View {
@ -239,31 +242,13 @@ private struct FormatButton: View {
}
}
private struct LangaugeButton: View {
@ObservedObject var draft: Draft
@ObservedObject var instanceFeatures: InstanceFeatures
@FocusedValue(\.composeInput) private var input
@State private var hasChanged = false
var body: some View {
if instanceFeatures.createStatusWithLanguage {
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $hasChanged)
.onReceive(NotificationCenter.default.publisher(for: UITextInputMode.currentInputModeDidChangeNotification), perform: currentInputModeChanged)
.onChange(of: draft.id) { _ in
hasChanged = false
}
}
}
@available(iOS 16.0, *)
private func currentInputModeChanged(_ notification: Foundation.Notification) {
guard !hasChanged,
!draft.hasContent,
let mode = input?.textInputMode,
let code = LanguagePicker.codeFromInputMode(mode) else {
return
}
draft.language = code.identifier
private struct InputAccessoryToolbarHost: EnvironmentKey {
static var defaultValue: UIView? { nil }
}
extension EnvironmentValues {
var inputAccessoryToolbarHost: UIView? {
get { self[InputAccessoryToolbarHost.self] }
set { self[InputAccessoryToolbarHost.self] = newValue }
}
}

View File

@ -6,54 +6,183 @@
//
import SwiftUI
import CoreData
import Pachyderm
import TuskerComponents
import TuskerPreferences
struct ComposeView: View {
// State owned by the compose UI but that needs to be accessible from outside.
public final class ComposeViewState: ObservableObject {
@Published var poster: PostService?
@Published public internal(set) var draft: Draft
@Published public internal(set) var didPostSuccessfully = false
public var isPosting: Bool {
poster != nil
}
public init(draft: Draft) {
self.draft = draft
}
}
public struct ComposeView: View {
@ObservedObject var state: ComposeViewState
let mastodonController: any ComposeMastodonContext
let currentAccount: (any AccountProtocol)?
let config: ComposeUIConfig
@FocusState private var focusedField: FocusableField?
public init(
state: ComposeViewState,
mastodonController: any ComposeMastodonContext,
currentAccount: (any AccountProtocol)?,
config: ComposeUIConfig
) {
self.state = state
self.mastodonController = mastodonController
self.currentAccount = currentAccount
self.config = config
}
public var body: some View {
ComposeViewBody(
draft: state.draft,
mastodonController: mastodonController,
state: state,
setDraft: self.setDraft,
focusedField: $focusedField
)
.environment(\.composeUIConfig, config)
.environment(\.currentAccount, currentAccount)
.injectInputAccessoryHost(state: state, mastodonController: mastodonController, focusedField: $focusedField)
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext), perform: self.managedObjectsDidChange)
}
private func setDraft(_ draft: Draft) {
let oldDraft = state.draft
state.draft = draft
if oldDraft.hasContent {
oldDraft.lastModified = Date()
} else {
DraftsPersistentContainer.shared.viewContext.delete(oldDraft)
}
DraftsPersistentContainer.shared.save()
}
private func managedObjectsDidChange(_ notification: Foundation.Notification) {
if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject>,
deleted.contains(where: { $0.objectID == state.draft.objectID }) {
config.dismiss(.cancel)
}
}
}
// TODO: see if this can be broken up further
private struct ComposeViewBody: View {
@ObservedObject var draft: Draft
let mastodonController: any ComposeMastodonContext
@State private var poster: PostService? = nil
@FocusState private var focusedField: FocusableField?
@EnvironmentObject private var controller: ComposeController
@ObservedObject var state: ComposeViewState
let setDraft: (Draft) -> Void
@FocusState.Binding var focusedField: FocusableField?
@State private var postError: PostService.Error?
@State private var isShowingDrafts = false
@State private var isDismissing = false
@State private var userConfirmedDelete = false
@Environment(\.composeUIConfig) private var config
@PreferenceObserving(\.$statusContentType) private var statusContentType
var body: some View {
public var body: some View {
navigation
.environmentObject(mastodonController.instanceFeatures)
.sheet(isPresented: $isShowingDrafts) {
DraftsView(
currentDraft: draft,
isShowingDrafts: $isShowingDrafts,
accountInfo: mastodonController.accountInfo!,
selectDraft: {
self.setDraft($0)
self.isShowingDrafts = false
}
)
}
.alertWithData("Error Posting", data: $postError, actions: { _ in
Button("OK") {}
}, message: { error in
Text(error.localizedDescription)
})
.onDisappear(perform: self.deleteOrSaveDraft)
}
@ViewBuilder
private var navigation: some View {
#if os(visionOS)
NavigationStack {
navigationRoot
}
#else
if #available(iOS 16.0, *) {
NavigationStack {
navigationRoot
}
} else {
NavigationView {
navigationRoot
}
.navigationViewStyle(.stack)
}
#endif
}
private var navigationRoot: some View {
ZStack {
List {
listContent
ScrollView {
scrollContent
}
.listStyle(.plain)
.scrollDismissesKeyboard(.interactively)
#if !os(visionOS)
.scrollDismissesKeyboardInteractivelyIfAvailable()
#endif
#if !os(visionOS) && !targetEnvironment(macCatalyst)
.modifier(ToolbarSafeAreaInsetModifier())
#endif
}
.overlay(alignment: .top) {
if let poster {
if let poster = state.poster {
PostProgressView(poster: poster)
.frame(alignment: .top)
}
}
#if !os(visionOS)
.overlay(alignment: .bottom, content: {
// TODO: during ducking animation, toolbar should move off the botto edge
// This needs to be in an overlay, ignoring the keyboard safe area
// doesn't work with the safeAreaInset modifier.
toolbarView
.frame(maxHeight: .infinity, alignment: .bottom)
.ignoresSafeArea(.keyboard)
// When we're using the input accessory toolbar, hide the overlay toolbar so that,
// on iPad, we don't get two toolbars showing.
let showOverlayToolbar = if #available(iOS 16.0, *) {
focusedField == nil
} else {
true
}
if config.showToolbar,
showOverlayToolbar {
toolbarView
.frame(maxHeight: .infinity, alignment: .bottom)
.modifier(IgnoreKeyboardSafeAreaIfUsingInputAccessory())
.transition(.move(edge: .bottom))
.animation(.snappy, value: config.showToolbar)
}
})
#endif
// Have these after the overlays so they barely work instead of not working at all. FB11790805
.modifier(DropAttachmentModifier(draft: draft))
.modifier(AddAttachmentConditionsModifier(draft: draft, instanceFeatures: mastodonController.instanceFeatures))
.modifier(AddAttachmentConditionsModifier(draft: draft))
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarActions(draft: draft, controller: controller)
ComposeNavigationBarActions(draft: draft, isShowingDrafts: $isShowingDrafts, isPosting: state.isPosting, cancel: self.cancel(deleteDraft:), postStatus: self.postStatus)
#if os(visionOS)
ToolbarItem(placement: .bottomOrnament) {
toolbarView
@ -67,36 +196,69 @@ struct ComposeView: View {
}
@ViewBuilder
private var listContent: some View {
NewReplyStatusView(draft: draft, mastodonController: mastodonController)
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
NewHeaderView(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
ContentWarningTextField(draft: draft, focusedField: $focusedField)
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
NewMainTextView(value: $draft.text, focusedField: $focusedField, handleAttachmentDrop: self.addAttachments)
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
AttachmentsListSection(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
private var scrollContent: some View {
VStack(spacing: 8) {
NewReplyStatusView(draft: draft, mastodonController: mastodonController)
DraftEditor(draft: draft, focusedField: $focusedField)
}
.padding(8)
}
private func addAttachments(_ itemProviders: [NSItemProvider]) {
AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders)
AttachmentsSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders)
}
private func deleteOrSaveDraft() {
if isDismissing,
!draft.hasContent || state.didPostSuccessfully || userConfirmedDelete {
DraftsPersistentContainer.shared.viewContext.delete(draft)
} else {
draft.lastModified = Date()
}
DraftsPersistentContainer.shared.save()
}
private func cancel(deleteDraft: Bool) {
isDismissing = true
userConfirmedDelete = deleteDraft
config.dismiss(.cancel)
}
private func postStatus() async {
guard !state.isPosting,
draft.editedStatusID != nil || draft.hasContent else {
return
}
let poster = PostService(mastodonController: mastodonController, contentType: statusContentType, draft: draft)
state.poster = poster
do {
let status = try await poster.post()
isDismissing = true
state.didPostSuccessfully = true
// wait .25 seconds so the user can see the progress bar has completed
try? await Task.sleep(nanoseconds: 250_000_000)
// don't unset the poster, so the ui remains disabled while dismissing
if draft.editedStatusID != nil {
config.dismiss(.edit(status))
} else {
config.dismiss(.post(status))
}
} catch {
self.postError = error
state.poster = nil
}
}
}
private struct NavigationTitleModifier: ViewModifier {
let draft: Draft
let mastodonController: any ComposeMastodonContext
private var navigationTitle: String {
extension ComposeView {
public static func navigationTitle(for draft: Draft, mastodonController: any ComposeMastodonContext) -> String {
if let id = draft.inReplyToID,
let status = mastodonController.fetchStatus(id: id) {
return "Reply to @\(status.account.acct)"
@ -106,108 +268,23 @@ private struct NavigationTitleModifier: ViewModifier {
return "New Post"
}
}
}
private struct NavigationTitleModifier: ViewModifier {
let draft: Draft
let mastodonController: any ComposeMastodonContext
func body(content: Content) -> some View {
let title = ComposeView.navigationTitle(for: draft, mastodonController: mastodonController)
content
.navigationTitle(navigationTitle)
.preference(key: NavigationTitlePreferenceKey.self, value: navigationTitle)
}
}
// Public preference so that the host can read the title.
public struct NavigationTitlePreferenceKey: PreferenceKey {
public static var defaultValue: String? { nil }
public static func reduce(value: inout String?, nextValue: () -> String?) {
value = value ?? nextValue()
}
}
private struct ToolbarActions: ToolbarContent {
@ObservedObject var draft: Draft
// Prior to iOS 16, the toolbar content doesn't seem to have access
// to the environment form the containing view.
let controller: ComposeController
var body: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) { ToolbarCancelButton(draft: draft) }
#if targetEnvironment(macCatalyst)
ToolbarItem(placement: .topBarTrailing) { draftsButton }
ToolbarItem(placement: .confirmationAction) { postButton }
#else
ToolbarItem(placement: .confirmationAction) { postOrDraftsButton }
#endif
}
private var draftsButton: some View {
Button(action: controller.showDrafts) {
Text("Drafts")
}
}
private var postButton: some View {
// TODO: don't use the controller for this
Button(action: controller.postStatus) {
Text(draft.editedStatusID == nil ? "Post" : "Edit")
}
.keyboardShortcut(.return, modifiers: .command)
.disabled(!controller.postButtonEnabled)
}
#if !targetEnvironment(macCatalyst)
@ViewBuilder
private var postOrDraftsButton: some View {
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
postButton
} else {
draftsButton
}
}
#endif
}
private struct ToolbarCancelButton: View {
let draft: Draft
@EnvironmentObject private var controller: ComposeController
var body: some View {
Button(action: controller.cancel) {
Text("Cancel")
// otherwise all Buttons in the nav bar are made semibold
.font(.system(size: 17, weight: .regular))
}
.disabled(controller.isPosting)
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
// edit drafts can't be saved
if draft.editedStatusID == nil {
Button(action: { controller.cancel(deleteDraft: false) }) {
Text("Save Draft")
}
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
Text("Delete Draft")
}
} else {
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
Text("Cancel Edit")
}
}
}
.navigationTitle(title)
}
}
enum FocusableField: Hashable {
case contentWarning
case body
case attachmentDescription(UUID)
var nextField: FocusableField? {
switch self {
case .contentWarning:
return .body
default:
return nil
}
}
case pollOption(NSManagedObjectID)
}
#if !os(visionOS) && !targetEnvironment(macCatalyst)
@ -230,6 +307,119 @@ private struct ToolbarSafeAreaInsetModifier: ViewModifier {
}
#endif
private struct IgnoreKeyboardSafeAreaIfUsingInputAccessory: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 16.0, *) {
content
.ignoresSafeArea(.keyboard)
} else {
content
}
}
}
private extension View {
@ViewBuilder
func injectInputAccessoryHost(
state: ComposeViewState,
mastodonController: any ComposeMastodonContext,
focusedField: FocusState<FocusableField?>.Binding
) -> some View {
if #available(iOS 16.0, *) {
self.modifier(InputAccessoryHostInjector(state: state, mastodonController: mastodonController, focusedField: focusedField))
} else {
self
}
}
}
@available(iOS 16.0, *)
private struct InputAccessoryHostInjector: ViewModifier {
// This is in a StateObject so we can use the autoclosure StateObject initializer.
@StateObject private var factory: ViewFactory
@FocusedValue(\.composeInput) private var composeInput
init(
state: ComposeViewState,
mastodonController: any ComposeMastodonContext,
focusedField: FocusState<FocusableField?>.Binding
) {
self._factory = StateObject(wrappedValue: ViewFactory(state: state, mastodonController: mastodonController, focusedField: focusedField))
}
func body(content: Content) -> some View {
content
.environment(\.inputAccessoryToolbarHost, factory.view)
.onChange(of: ComposeInputEquatableBox(input: composeInput ?? nil)) { newValue in
factory.focusedInput = newValue.input ?? nil
}
}
@MainActor
private class ViewFactory: ObservableObject {
let view: UIView
@MutableObservableBox var focusedInput: (any ComposeInput)?
init(
state: ComposeViewState,
mastodonController: any ComposeMastodonContext,
focusedField: FocusState<FocusableField?>.Binding
) {
self._focusedInput = MutableObservableBox(wrappedValue: nil)
let view = InputAccessoryToolbarView(state: state, mastodonController: mastodonController, focusedField: focusedField, focusedInputBox: _focusedInput)
let controller = UIHostingController(rootView: view)
controller.sizingOptions = .intrinsicContentSize
controller.view.autoresizingMask = .flexibleHeight
self.view = controller.view
}
}
}
private struct ComposeInputEquatableBox: Equatable {
let input: (any ComposeInput)?
static func ==(lhs: Self, rhs: Self) -> Bool {
lhs.input === rhs.input
}
}
/// FocusedValue doesn't seem to work through the hacks we're doing for the input accessory.
private struct FocusedInputBoxEnvironmentKey: EnvironmentKey {
static var defaultValue: MutableObservableBox<(any ComposeInput)?>? { nil }
}
extension EnvironmentValues {
var toolbarInjectedFocusedInputBox: MutableObservableBox<(any ComposeInput)?>? {
get { self[FocusedInputBoxEnvironmentKey.self] }
set { self[FocusedInputBoxEnvironmentKey.self] = newValue }
}
}
private struct InputAccessoryToolbarView: View {
@ObservedObject var state: ComposeViewState
let mastodonController: any ComposeMastodonContext
@FocusState.Binding var focusedField: FocusableField?
let focusedInputBox: MutableObservableBox<(any ComposeInput)?>
@PreferenceObserving(\.$accentColor) private var accentColor
init(
state: ComposeViewState,
mastodonController: any ComposeMastodonContext,
focusedField: FocusState<FocusableField?>.Binding,
focusedInputBox: MutableObservableBox<(any ComposeInput)?>
) {
self.state = state
self.mastodonController = mastodonController
self._focusedField = focusedField
self.focusedInputBox = focusedInputBox
}
var body: some View {
ComposeToolbarView(draft: state.draft, mastodonController: mastodonController, focusedField: $focusedField)
.environment(\.toolbarInjectedFocusedInputBox, focusedInputBox)
.tint(accentColor.color.map(Color.init(uiColor:)))
}
}
//#Preview {
// ComposeView()
//}

View File

@ -12,24 +12,16 @@ struct ContentWarningTextField: View {
@FocusState.Binding var focusedField: FocusableField?
var body: some View {
if draft.contentWarningEnabled {
EmojiTextField(
text: $draft.contentWarning,
placeholder: "Write your warning here",
maxLength: nil,
// TODO: completely replace this with FocusState
becomeFirstResponder: .constant(false),
focusNextView: Binding(get: {
false
}, set: {
if $0 {
focusedField = .body
}
})
)
.focused($focusedField, equals: .contentWarning)
.modifier(FocusedInputModifier())
}
EmojiTextField(
text: $draft.contentWarning,
placeholder: "Write your warning here",
maxLength: nil,
focusNextView: {
focusedField = .body
}
)
.focused($focusedField, equals: .contentWarning)
.modifier(FocusedInputModifier())
}
}

View File

@ -1,45 +0,0 @@
//
// CurrentAccountView.swift
// ComposeUI
//
// Created by Shadowfacts on 3/4/23.
//
import SwiftUI
import Pachyderm
import TuskerComponents
struct CurrentAccountView: View {
let account: (any AccountProtocol)?
@EnvironmentObject private var controller: ComposeController
var body: some View {
controller.currentAccountContainerView(AnyView(currentAccount))
}
private var currentAccount: some View {
HStack(alignment: .top) {
AvatarImageView(
url: account?.avatar,
size: 50,
style: controller.config.avatarStyle,
fetchAvatar: controller.fetchAvatar
)
.accessibilityHidden(true)
if let account {
VStack(alignment: .leading) {
controller.displayNameLabel(account, .title2, 24)
.lineLimit(1)
Text(verbatim: "@\(account.acct)")
.font(.body.weight(.light))
.foregroundColor(.secondary)
.lineLimit(1)
}
}
Spacer()
}
}
}

View File

@ -0,0 +1,152 @@
//
// DraftContentEditor.swift
// ComposeUI
//
// Created by Shadowfacts on 11/16/24.
//
import SwiftUI
import InstanceFeatures
struct DraftContentEditor: View {
@ObservedObject var draft: Draft
@FocusState.Binding var focusedField: FocusableField?
@Environment(\.colorScheme) private var colorScheme
@Environment(\.composeUIConfig.fillColor) private var fillColor
var body: some View {
VStack(spacing: 4) {
NewMainTextView(value: $draft.text, focusedField: $focusedField, handleAttachmentDrop: self.addAttachments)
HStack(alignment: .firstTextBaseline) {
LanguageButton(draft: draft)
TogglePollButton(draft: draft, focusedField: $focusedField)
Spacer()
CharactersRemaining(draft: draft)
.padding(.trailing, 6)
}
}
.composePlatterBackground()
}
private func addAttachments(_ providers: [NSItemProvider]) {
AttachmentsSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: providers)
}
}
private struct CharactersRemaining: View {
@ObservedObject var draft: Draft
@EnvironmentObject var instanceFeatures: InstanceFeatures
private var charsRemaining: Int {
let limit = instanceFeatures.maxStatusChars
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
}
var body: some View {
Text(verbatim: charsRemaining.description)
.foregroundStyle(charsRemaining < 0 ? .red : .secondary)
.font(.body.monospacedDigit())
.accessibility(label: Text(charsRemaining < 0 ? "\(-charsRemaining) characters too many" : "\(charsRemaining) characters remaining"))
}
}
private struct LanguageButton: View {
@ObservedObject var draft: Draft
@EnvironmentObject private var instanceFeatures: InstanceFeatures
@FocusedValue(\.composeInput) private var input
@State private var hasChanged = false
var body: some View {
if #available(iOS 16.0, *),
instanceFeatures.createStatusWithLanguage {
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $hasChanged)
.buttonStyle(LanguageButtonStyle())
.onReceive(NotificationCenter.default.publisher(for: UITextInputMode.currentInputModeDidChangeNotification), perform: currentInputModeChanged)
}
}
@available(iOS 16.0, *)
private func currentInputModeChanged(_ notification: Foundation.Notification) {
guard !hasChanged,
!draft.hasContent,
let mode = input??.textInputMode,
let code = LanguagePicker.codeFromInputMode(mode) else {
return
}
draft.language = code.identifier
}
}
private struct LanguageButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.body.monospaced())
.foregroundStyle(.tint.opacity(configuration.isPressed ? 0.8 : 1))
.padding(.vertical, 2)
.padding(.horizontal, 4)
.background(.tint.opacity(configuration.isPressed ? 0.15 : 0.2), in: RoundedRectangle(cornerRadius: 3))
.modifier(LanguageButtonStyleAnimationModifier(isPressed: configuration.isPressed))
.padding(2)
}
}
@available(iOS, obsoleted: 16.0)
private struct LanguageButtonStyleAnimationModifier: ViewModifier {
let isPressed: Bool
func body(content: Content) -> some View {
// This looks weird while the button is being pressed on iOS 15 for some reason.
if #available(iOS 16.0, *) {
content
.animation(.linear(duration: 0.1), value: isPressed)
} else {
content
}
}
}
private struct TogglePollButton: View {
@ObservedObject var draft: Draft
@FocusState.Binding var focusedField: FocusableField?
@EnvironmentObject private var instanceFeatures: InstanceFeatures
var body: some View {
Button(action: togglePoll) {
Image(systemName: draft.pollEnabled ? "chart.bar.doc.horizontal.fill" : "chart.bar.doc.horizontal")
}
.buttonStyle(LanguageButtonStyle())
.disabled(disabled)
.animation(.linear(duration: 0.2), value: disabled)
.animation(.linear(duration: 0.2), value: draft.pollEnabled)
}
private var disabled: Bool {
instanceFeatures.mastodonAttachmentRestrictions && draft.attachments.count > 0
}
private func togglePoll() {
if draft.pollEnabled {
draft.pollEnabled = false
if case .pollOption(_) = focusedField {
focusedField = .body
}
} else {
let poll: Poll
if let p = draft.poll {
poll = p
} else {
poll = Poll(context: DraftsPersistentContainer.shared.viewContext)
draft.poll = poll
}
draft.pollEnabled = true
if focusedField != nil {
let optionToFocus = poll.pollOptions.first(where: { $0.text.isEmpty }) ?? poll.pollOptions.last!
focusedField = .pollOption(optionToFocus.id)
}
}
}
}

View File

@ -0,0 +1,97 @@
//
// DraftEditor.swift
// ComposeUI
//
// Created by Shadowfacts on 11/16/24.
//
import SwiftUI
import Pachyderm
import TuskerComponents
import TuskerPreferences
struct DraftEditor: View {
@ObservedObject var draft: Draft
@FocusState.Binding var focusedField: FocusableField?
@Environment(\.currentAccount) private var currentAccount
var body: some View {
HStack(alignment: .top, spacing: 8) {
// TODO: scroll effect?
AvatarView(account: currentAccount)
VStack(alignment: .leading, spacing: 4) {
if let currentAccount {
AccountNameView(account: currentAccount)
}
if draft.contentWarningEnabled {
ContentWarningTextField(draft: draft, focusedField: $focusedField)
.transition(.opacity)
}
DraftContentEditor(draft: draft, focusedField: $focusedField)
if let poll = draft.poll,
draft.pollEnabled {
PollEditor(poll: poll, focusedField: $focusedField)
.padding(.bottom, 4)
// So that during the appearance transition, it's behind the text view.
.zIndex(-1)
.transition(.move(edge: .top).combined(with: .opacity))
}
AttachmentsSection(draft: draft)
// We want the padding between the poll/attachments to be part of the poll, so it animates in/out with the transition.
// Otherwise, when the poll is added, its bottom edge is aligned with the top edge of the attachments
.padding(.top, draft.pollEnabled ? -4 : 0)
}
// These animations are here, because the height of the VStack and the positions of the lower views needs to animate too.
.animation(.snappy, value: draft.pollEnabled)
.modifier(PollAnimatingModifier(poll: draft.poll))
.animation(.snappy, value: draft.contentWarningEnabled)
}
}
}
private struct AvatarView: View {
let account: (any AccountProtocol)?
@PreferenceObserving(\.$avatarStyle) private var avatarStyle
@Environment(\.composeUIConfig.fetchAvatar) private var fetchAvatar
var body: some View {
AvatarImageView(
url: account?.avatar,
size: 50,
style: avatarStyle == .circle ? .circle : .roundRect,
fetchAvatar: fetchAvatar
)
.accessibilityHidden(true)
}
}
private struct AccountNameView: View {
let account: any AccountProtocol
@Environment(\.composeUIConfig.displayNameLabel) private var displayNameLabel
var body: some View {
HStack(spacing: 4) {
displayNameLabel(account, .body, 16)
.lineLimit(1)
Text(verbatim: "@\(account.acct)")
.font(.body.weight(.light))
.foregroundColor(.secondary)
.lineLimit(1)
}
}
}
// Separate modifier because we need to observe the Poll itself, not the draft
private struct PollAnimatingModifier: ViewModifier {
@OptionalObservedObject var poll: Poll?
func body(content: Content) -> some View {
content.animation(.snappy, value: poll?.pollOptions.count)
}
}

View File

@ -0,0 +1,163 @@
//
// DraftsView.swift
// ComposeUI
//
// Created by Shadowfacts on 1/27/25.
//
import SwiftUI
import UserAccounts
struct DraftsView: View {
let currentDraft: Draft
@Binding var isShowingDrafts: Bool
let accountInfo: UserAccountInfo
let selectDraft: (Draft) -> Void
@State private var draftForDifferentReply: Draft? = nil
var body: some View {
navigationView
.alertWithData("Different Reply", data: $draftForDifferentReply) { draft in
Button(role: .cancel) {
draftForDifferentReply = nil
} label: {
Text("Cancel")
}
Button {
selectDraft(draft)
} label: {
Text("Restore Draft")
}
} message: { _ in
Text("The selected draft is a reply to a different post, do you wish to use it?")
}
}
@ViewBuilder
private var navigationView: some View {
if #available(iOS 16.0, *) {
NavigationStack {
navigationRoot
}
} else {
NavigationView {
navigationRoot
}
.navigationViewStyle(.stack)
}
}
private var navigationRoot: some View {
DraftsListView(currentDraft: currentDraft, isShowingDrafts: $isShowingDrafts, accountInfo: accountInfo, selectDraft: selectDraft)
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
}
}
private struct DraftsListView: View {
let currentDraft: Draft
@Binding var isShowingDrafts: Bool
let accountInfo: UserAccountInfo
let selectDraft: (Draft) -> Void
@FetchRequest(sortDescriptors: [SortDescriptor(\Draft.lastModified, order: .reverse)]) private var drafts: FetchedResults<Draft>
@Environment(\.composeUIConfig.userActivityForDraft) private var userActivityForDraft
var body: some View {
List {
ForEach(drafts) { draft in
Button {
self.selectDraft(draft)
} label: {
DraftRowView(draft: draft)
}
.contextMenu {
Button(role: .destructive) {
deleteDraft(draft)
} label: {
Label("Delete Draft", systemImage: "trash")
}
}
.onDrag {
userActivityForDraft(draft) ?? NSItemProvider()
}
}
.onDelete { indices in
indices.map { drafts[$0] }.forEach(deleteDraft)
}
}
.listStyle(.plain)
.navigationTitle("Drafts")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
cancelButton
}
}
.onAppear {
drafts.nsPredicate = NSPredicate(
format: "accountID == %@ AND id != %@ AND lastModified != nil",
accountInfo.id,
currentDraft.id as NSUUID
)
}
}
private var cancelButton: some View {
Button {
isShowingDrafts = false
} label: {
Text("Cancel")
}
}
private func deleteDraft(_ draft: Draft) {
DraftsPersistentContainer.shared.viewContext.delete(draft)
}
}
private struct DraftRowView: View {
@ObservedObject var draft: Draft
var body: some View {
HStack {
VStack(alignment: .leading) {
if draft.editedStatusID != nil {
// shouldn't happen unless the app crashed/was killed during an edit
Text("Edit")
.font(.body.bold())
.foregroundStyle(.orange)
}
if draft.contentWarningEnabled && !draft.contentWarning.isEmpty {
Text(draft.contentWarning)
.font(.body.bold())
.foregroundStyle(.secondary)
}
Text(draft.text)
.font(.body)
if draft.pollEnabled {
Text("Poll")
.font(.body.bold())
}
HStack(spacing: 8) {
ForEach(draft.draftAttachments) { attachment in
AttachmentThumbnailView(attachment: attachment, thumbnailSize: CGSize(width: 50, height: 50))
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 5))
.frame(height: 50)
}
}
}
Spacer()
if let lastModified = draft.lastModified {
Text(lastModified.formatted(.abbreviatedTimeAgo))
.font(.body)
.foregroundStyle(.secondary)
}
}
}
}

View File

@ -10,21 +10,20 @@ import SwiftUI
struct EmojiTextField: UIViewRepresentable {
typealias UIViewType = UITextField
@EnvironmentObject private var controller: ComposeController
@Environment(\.composeUIConfig.fillColor) private var fillColor
@Environment(\.colorScheme) private var colorScheme
@Environment(\.composeInputBox) private var inputBox
@Environment(\.inputAccessoryToolbarHost) private var inputAccessoryToolbarHost
@Binding var text: String
let placeholder: String
let maxLength: Int?
let becomeFirstResponder: Binding<Bool>?
let focusNextView: Binding<Bool>?
let focusNextView: (() -> Void)?
init(text: Binding<String>, placeholder: String, maxLength: Int?, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) {
init(text: Binding<String>, placeholder: String, maxLength: Int?, focusNextView: (() -> Void)? = nil) {
self._text = text
self.placeholder = placeholder
self.maxLength = maxLength
self.becomeFirstResponder = becomeFirstResponder
self.focusNextView = focusNextView
}
@ -64,19 +63,16 @@ struct EmojiTextField: UIViewRepresentable {
context.coordinator.focusNextView = focusNextView
#if !os(visionOS)
uiView.backgroundColor = colorScheme == .dark ? UIColor(controller.config.fillColor) : .secondarySystemBackground
uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
#endif
if becomeFirstResponder?.wrappedValue == true {
DispatchQueue.main.async {
uiView.becomeFirstResponder()
becomeFirstResponder!.wrappedValue = false
}
if uiView.inputAccessoryView !== inputAccessoryToolbarHost {
uiView.inputAccessoryView = inputAccessoryToolbarHost
}
}
func makeCoordinator() -> Coordinator {
let coordinator = Coordinator(controller: controller, text: $text, focusNextView: focusNextView)
let coordinator = Coordinator(text: $text, focusNextView: focusNextView)
DispatchQueue.main.async {
inputBox.wrappedValue = coordinator
}
@ -84,9 +80,8 @@ struct EmojiTextField: UIViewRepresentable {
}
class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
let controller: ComposeController
var text: Binding<String>
var focusNextView: Binding<Bool>?
var focusNextView: (() -> Void)?
var maxLength: Int?
@Published var autocompleteState: AutocompleteState?
@ -94,8 +89,7 @@ struct EmojiTextField: UIViewRepresentable {
weak var textField: UITextField?
init(controller: ComposeController, text: Binding<String>, focusNextView: Binding<Bool>?, maxLength: Int? = nil) {
self.controller = controller
init(text: Binding<String>, focusNextView: (() -> Void)?, maxLength: Int? = nil) {
self.text = text
self.focusNextView = focusNextView
self.maxLength = maxLength
@ -106,7 +100,7 @@ struct EmojiTextField: UIViewRepresentable {
}
@objc func returnKeyPressed() {
focusNextView?.wrappedValue = true
focusNextView?()
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
@ -118,16 +112,10 @@ struct EmojiTextField: UIViewRepresentable {
}
func textFieldDidBeginEditing(_ textField: UITextField) {
DispatchQueue.main.async {
self.controller.currentInput = self
}
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
}
func textFieldDidEndEditing(_ textField: UITextField) {
DispatchQueue.main.async {
self.controller.currentInput = nil
}
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
}

View File

@ -1,47 +0,0 @@
//
// HeaderView.swift
// ComposeUI
//
// Created by Shadowfacts on 3/4/23.
//
import SwiftUI
import Pachyderm
import InstanceFeatures
struct HeaderView: View {
let currentAccount: (any AccountProtocol)?
let charsRemaining: Int
var body: some View {
HStack(alignment: .top) {
CurrentAccountView(account: currentAccount)
.accessibilitySortPriority(1)
Spacer()
Text(verbatim: charsRemaining.description)
.foregroundColor(charsRemaining < 0 ? .red : .secondary)
.font(Font.body.monospacedDigit())
.accessibility(label: Text(charsRemaining < 0 ? "\(-charsRemaining) characters too many" : "\(charsRemaining) characters remaining"))
// this should come first, so VO users can back to it from the main compose text view
.accessibilitySortPriority(0)
}.frame(height: 50)
}
}
struct NewHeaderView: View {
@ObservedObject var draft: Draft
@ObservedObject var instanceFeatures: InstanceFeatures
@Environment(\.currentAccount) private var currentAccount
private var charactersRemaining: Int {
let limit = instanceFeatures.maxStatusChars
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
}
var body: some View {
HeaderView(currentAccount: currentAccount, charsRemaining: charactersRemaining)
}
}

View File

@ -1,336 +0,0 @@
//
// MainTextView.swift
// ComposeUI
//
// Created by Shadowfacts on 3/6/23.
//
import SwiftUI
struct MainTextView: View {
@EnvironmentObject private var controller: ComposeController
@EnvironmentObject private var draft: Draft
@Environment(\.colorScheme) private var colorScheme
@ScaledMetric private var fontSize = 20
@State private var hasFirstAppeared = false
@State private var height: CGFloat?
@State private var updateSelection: ((UITextView) -> Void)?
private let minHeight: CGFloat = 150
private var effectiveHeight: CGFloat { height ?? minHeight }
var config: ComposeUIConfig {
controller.config
}
private var placeholderOffset: CGSize {
#if os(visionOS)
CGSize(width: 8, height: 8)
#else
CGSize(width: 4, height: 8)
#endif
}
private var textViewBackgroundColor: UIColor? {
#if os(visionOS)
nil
#else
colorScheme == .dark ? UIColor(config.fillColor) : .secondarySystemBackground
#endif
}
var body: some View {
ZStack(alignment: .topLeading) {
MainWrappedTextViewRepresentable(
text: $draft.text,
backgroundColor: textViewBackgroundColor,
becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder,
updateSelection: $updateSelection,
textDidChange: textDidChange
)
if draft.text.isEmpty {
ControllerView(controller: { PlaceholderController() })
.font(.system(size: fontSize))
.foregroundColor(.secondary)
.offset(placeholderOffset)
.accessibilityHidden(true)
.allowsHitTesting(false)
}
}
.frame(height: effectiveHeight)
.onAppear(perform: becomeFirstResponderOnFirstAppearance)
}
private func becomeFirstResponderOnFirstAppearance() {
if !hasFirstAppeared {
hasFirstAppeared = true
controller.mainComposeTextViewBecomeFirstResponder = true
if config.textSelectionStartsAtBeginning {
updateSelection = { textView in
textView.selectedTextRange = textView.textRange(from: textView.beginningOfDocument, to: textView.beginningOfDocument)
}
}
}
}
private func textDidChange(textView: UITextView) {
height = max(textView.contentSize.height, minHeight)
}
}
fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
typealias UIViewType = UITextView
@Binding var text: String
let backgroundColor: UIColor?
@Binding var becomeFirstResponder: Bool
@Binding var updateSelection: ((UITextView) -> Void)?
let textDidChange: (UITextView) -> Void
@EnvironmentObject private var controller: ComposeController
@Environment(\.isEnabled) private var isEnabled: Bool
func makeUIView(context: Context) -> UITextView {
let textView = WrappedTextView(composeController: controller)
context.coordinator.textView = textView
textView.delegate = context.coordinator
textView.isEditable = true
textView.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20))
textView.adjustsFontForContentSizeCategory = true
textView.textContainer.lineBreakMode = .byWordWrapping
#if os(visionOS)
textView.borderStyle = .roundedRect
// yes, the X inset is 4 less than the placeholder offset
textView.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
#endif
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
if text != uiView.text {
context.coordinator.skipNextSelectionChangedAutocompleteUpdate = true
uiView.text = text
}
uiView.isEditable = isEnabled
uiView.keyboardType = controller.config.useTwitterKeyboard ? .twitter : .default
uiView.backgroundColor = backgroundColor
context.coordinator.text = $text
if let updateSelection {
updateSelection(uiView)
self.updateSelection = nil
}
// 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 {
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 {
Coordinator(controller: controller, text: $text, textDidChange: textDidChange)
}
class WrappedTextView: UITextView {
private let formattingActions = [#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))]
private let composeController: ComposeController
init(composeController: ComposeController) {
self.composeController = composeController
super.init(frame: .zero, textContainer: nil)
}
required init?(coder: NSCoder) {
fatalError()
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if formattingActions.contains(action) {
return composeController.config.contentType != .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),
composeController.config.contentType != .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) {
composeController.paste(itemProviders: UIPasteboard.general.itemProviders)
} else {
super.paste(sender)
}
}
}
class Coordinator: NSObject, UITextViewDelegate, ComposeInput, TextViewCaretScrolling {
weak var textView: UITextView?
let controller: ComposeController
var text: Binding<String>
let textDidChange: (UITextView) -> Void
var caretScrollPositionAnimator: UIViewPropertyAnimator?
@Published var autocompleteState: AutocompleteState?
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { $autocompleteState }
var skipNextSelectionChangedAutocompleteUpdate = false
init(controller: ComposeController, text: Binding<String>, textDidChange: @escaping (UITextView) -> Void) {
self.controller = controller
self.text = text
self.textDidChange = textDidChange
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)
}
// MARK: UITextViewDelegate
func textViewDidChange(_ textView: UITextView) {
text.wrappedValue = textView.text
textDidChange(textView)
ensureCursorVisible(textView: textView)
}
func textViewDidBeginEditing(_ textView: UITextView) {
controller.currentInput = self
updateAutocompleteState()
}
func textViewDidEndEditing(_ textView: UITextView) {
controller.currentInput = nil
updateAutocompleteState()
}
func textViewDidChangeSelection(_ textView: UITextView) {
if skipNextSelectionChangedAutocompleteUpdate {
skipNextSelectionChangedAutocompleteUpdate = false
} else {
updateAutocompleteState()
}
}
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
var actions = suggestedActions
if controller.config.contentType != .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
return UIAction(title: fmt.accessibilityLabel, image: UIImage(systemName: fmt.imageName)) { [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?.controller.shouldEmojiAutocompletionBeginExpanded = true
self?.beginAutocompletingEmoji()
}))
}
return UIMenu(children: actions)
}
// MARK: ComposeInput
var toolbarElements: [ToolbarElement] {
[.emojiPicker, .formattingButtons]
}
var textInputMode: UITextInputMode? {
textView?.textInputMode
}
func autocomplete(with string: String) {
textView?.autocomplete(with: string, permittedModes: .all, autocompleteState: &autocompleteState)
}
func applyFormat(_ format: StatusFormat) {
guard let textView,
textView.isFirstResponder,
let insertionResult = format.insertionResult(for: controller.config.contentType) 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.utf16.index(textView.text.utf16.startIndex, offsetBy: currentSelectedRange.lowerBound)
let end = textView.text.utf16.index(textView.text.utf16.startIndex, offsetBy: currentSelectedRange.upperBound)
let selectedText = textView.text.utf16[start..<end]
textView.insertText(insertionResult.prefix + String(Substring(selectedText)) + insertionResult.suffix)
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: currentSelectedRange.length)
}
}
func beginAutocompletingEmoji() {
guard let 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 ? " " : "") + ":")
}
private func updateAutocompleteState() {
guard let textView else {
autocompleteState = nil
return
}
autocompleteState = textView.updateAutocompleteState(permittedModes: .all)
}
}
}

View File

@ -6,6 +6,8 @@
//
import SwiftUI
import Pachyderm
import TuskerPreferences
struct NewMainTextView: View {
static var minHeight: CGFloat { 150 }
@ -17,6 +19,7 @@ struct NewMainTextView: View {
var body: some View {
NewMainTextViewRepresentable(value: $value, becomeFirstResponder: $becomeFirstResponder, handleAttachmentDrop: handleAttachmentDrop)
.frame(minHeight: Self.minHeight)
.focused($focusedField, equals: .body)
.modifier(FocusedInputModifier())
.overlay(alignment: .topLeading) {
@ -35,13 +38,18 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
@Environment(\.isEnabled) private var isEnabled
@Environment(\.colorScheme) private var colorScheme
@Environment(\.composeUIConfig.fillColor) private var fillColor
@Environment(\.composeUIConfig.useTwitterKeyboard) private var useTwitterKeyboard
// TODO: test textSelectionStartsAtBeginning
@PreferenceObserving(\.$useTwitterKeyboard) private var useTwitterKeyboard
@Environment(\.composeUIConfig.textSelectionStartsAtBeginning) private var textSelectionStartsAtBeginning
@PreferenceObserving(\.$statusContentType) private var statusContentType
@Environment(\.inputAccessoryToolbarHost) private var inputAccessoryToolbarHost
func makeUIView(context: Context) -> UITextView {
func makeUIView(context: Context) -> WrappedTextView {
// TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary
let view = WrappedTextView(usingTextLayoutManager: true)
let view = if #available(iOS 16.0, *) {
WrappedTextView(usingTextLayoutManager: true)
} else {
WrappedTextView()
}
view.addInteraction(UIDropInteraction(delegate: context.coordinator))
view.delegate = context.coordinator
view.adjustsFontForContentSizeCategory = true
@ -51,9 +59,14 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
.foregroundColor: UIColor.label,
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
]
view.backgroundColor = nil
// on iOS 15, this is needed to prevent the text view from growing horizontally
if #unavailable(iOS 16.0) {
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
}
view.layer.cornerRadius = 5
view.layer.cornerCurve = .continuous
// view.layer.cornerRadius = 5
// view.layer.cornerCurve = .continuous
// view.layer.shadowColor = UIColor.black.cgColor
// view.layer.shadowOpacity = 0.15
// view.layer.shadowOffset = .zero
@ -75,9 +88,15 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
uiView.isEditable = isEnabled
uiView.keyboardType = useTwitterKeyboard ? .twitter : .default
#if !os(visionOS)
uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
#endif
uiView.contentType = statusContentType
// #if !os(visionOS)
// uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
// #endif
if uiView.inputAccessoryView !== inputAccessoryToolbarHost {
uiView.inputAccessoryView = inputAccessoryToolbarHost
}
// Trying to set this with the @FocusState binding in onAppear results in the
// keyboard not appearing until after the sheet presentation animation completes :/
@ -135,27 +154,40 @@ private final class WrappedTextViewCoordinator: NSObject {
attributedText.string.replacingOccurrences(of: "\u{FFFC}", with: "")
}
@MainActor
private func attributedTextFromPlain(_ text: String) -> NSAttributedString {
let str = NSMutableAttributedString(string: text)
let mentionMatches = CharacterCounter.mention.matches(in: text, range: NSRange(location: 0, length: str.length))
for match in mentionMatches.reversed() {
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
let range = NSRange(location: match.range.location, length: match.range.length + 1)
str.addAttributes([
.mention: true,
.foregroundColor: UIColor.tintColor,
], range: range)
str.addAttributes([
.foregroundColor: UIColor.label,
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
], range: NSRange(location: 0, length: str.length))
if Preferences.shared.hasFeatureFlag(.composeTextAttributes) {
let mentionMatches = CharacterCounter.mention.matches(in: text, range: NSRange(location: 0, length: str.length))
for match in mentionMatches.reversed() {
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
let range = NSRange(location: match.range.location, length: match.range.length + 1)
str.addAttributes([
.mention: true,
.foregroundColor: UIColor.tintColor,
], range: range)
}
}
return str
}
@MainActor
func updateTextViewTextIfNecessary(_ text: String, textView: UITextView) {
if text != plainTextFromAttributed(textView.attributedText) {
textView.attributedText = attributedTextFromPlain(text)
}
}
@MainActor
private func updateAttributes(in textView: UITextView) {
guard Preferences.shared.hasFeatureFlag(.composeTextAttributes) else {
return
}
let str = NSMutableAttributedString(attributedString: textView.attributedText!)
var changed = false
var cursorOffset = 0
@ -170,7 +202,7 @@ private final class WrappedTextViewCoordinator: NSObject {
if mentionRegex.numberOfMatches(in: substr, range: NSRange(location: 0, length: substr.utf16.count)) == 0 {
changed = true
str.removeAttribute(.mention, range: range)
str.removeAttribute(.foregroundColor, range: range)
str.addAttribute(.foregroundColor, value: UIColor.label, range: range)
if hasTextAttachment {
str.deleteCharacters(in: NSRange(location: range.location, length: 1))
cursorOffset -= 1
@ -230,6 +262,35 @@ extension WrappedTextViewCoordinator: UITextViewDelegate {
func textViewDidChangeSelection(_ textView: UITextView) {
}
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
guard let textView = textView as? WrappedTextView else {
return nil
}
var actions = suggestedActions
if textView.contentType != .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
return UIAction(title: fmt.accessibilityLabel, image: UIImage(systemName: fmt.imageName)) { _ in
// TODO
}
})
actions[index] = newFormatMenu
} else {
actions.remove(at: index)
}
}
// TODO
// if range.length == 0 {
// actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in
// self?.controller.shouldEmojiAutocompletionBeginExpanded = true
// self?.beginAutocompletingEmoji()
// }))
// }
return UIMenu(children: actions)
}
}
//extension WrappedTextViewCoordinator: ComposeInput {
@ -252,6 +313,35 @@ extension WrappedTextViewCoordinator: UIDropInteractionDelegate {
}
private final class WrappedTextView: UITextView {
var contentType: StatusContentType = .plain
private static var formattingActions: [Selector] {
[#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))]
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if Self.formattingActions.contains(action) {
return contentType != .plain
}
return super.canPerformAction(action, withSender: sender)
}
override func toggleBoldface(_ sender: Any?) {
// TODO
}
override func toggleItalics(_ sender: Any?) {
// TODO
}
override func validate(_ command: UICommand) {
super.validate(command)
if Self.formattingActions.contains(command.action),
contentType != .plain {
command.attributes.remove(.disabled)
}
}
}
private extension NSAttributedString.Key {

View File

@ -0,0 +1,29 @@
//
// BackgroundPlatterView.swift
// ComposeUI
//
// Created by Shadowfacts on 1/29/25.
//
import SwiftUI
extension View {
func composePlatterBackground() -> some View {
self.background {
PlatterBackgroundView()
}
}
}
private struct PlatterBackgroundView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.composeUIConfig.fillColor) private var fillColor
var body: some View {
RoundedRectangle(cornerRadius: 5)
// TODO: fillColor is semi-transparent in pure-black dark mode, but it needs to be fully opaque for the poll transition to look right
// .fill(colorScheme == .dark ? fillColor : Color(uiColor: .secondarySystemBackground))
.fill(Color(uiColor: .secondarySystemBackground))
}
}

View File

@ -0,0 +1,182 @@
//
// PollEditor.swift
// ComposeUI
//
// Created by Shadowfacts on 1/29/25.
//
import SwiftUI
import InstanceFeatures
import TuskerComponents
struct PollEditor: View {
@ObservedObject var poll: Poll
@FocusState.Binding var focusedField: FocusableField?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Poll")
.font(.headline)
ForEach(poll.pollOptions) { option in
PollOptionEditor(poll: poll, option: option, focusedField: $focusedField)
.transition(.opacity)
}
AddOptionButton(poll: poll, focusedField: $focusedField)
Toggle("Multiple choice", isOn: $poll.multiple)
.padding(.bottom, -8)
HStack {
Text("Duration")
Spacer()
PollDurationPicker(poll: poll)
.frame(minHeight: 32)
}
}
.padding(.all.subtracting(.bottom), 8)
.padding(.bottom, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.composePlatterBackground()
}
}
private struct AddOptionButton: View {
@ObservedObject var poll: Poll
@FocusState.Binding var focusedField: FocusableField?
@EnvironmentObject private var instanceFeatures: InstanceFeatures
private var canAddOption: Bool {
if let max = instanceFeatures.maxPollOptionsCount {
poll.options.count < max
} else {
true
}
}
var body: some View {
Button(action: addOption) {
Label("Add Option", systemImage: "plus")
}
.buttonStyle(.borderless)
.disabled(!canAddOption)
}
private func addOption() {
let option = PollOption(context: DraftsPersistentContainer.shared.viewContext)
option.poll = poll
poll.options.add(option)
focusedField = .pollOption(option.id)
}
}
private struct PollDurationPicker: View {
@ObservedObject var poll: Poll
@State var duration: PollDuration
init(poll: Poll) {
self.poll = poll
self._duration = State(wrappedValue: .fromTimeInterval(poll.duration) ?? .oneDay)
}
private var options: [MenuPicker<PollDuration>.Option] {
PollDuration.allCases.map {
.init(value: $0, title: PollDuration.formatter.string(from: $0.timeInterval)!)
}
}
var body: some View {
MenuPicker(selection: $duration, options: options)
#if os(visionOS)
.onChange(of: duration) {
poll.duration = duration.timeInterval
}
#else
.onChange(of: duration) { newValue in
poll.duration = newValue.timeInterval
}
#endif
}
}
private struct PollOptionEditor: View {
@ObservedObject var poll: Poll
@ObservedObject var option: PollOption
@FocusState.Binding var focusedField: FocusableField?
@EnvironmentObject private var instanceFeatures: InstanceFeatures
var placeholder: String {
let index = poll.options.index(of: option)
if index != NSNotFound {
return "Option \(index + 1)"
} else {
return ""
}
}
var body: some View {
HStack(spacing: 4) {
Button(role: .destructive, action: removeOption) {
Label("Remove option", systemImage: "minus")
}
.labelStyle(.iconOnly)
.buttonStyle(PollOptionButtonStyle())
.accessibilityLabel("Remove option")
.disabled(poll.options.count == 1)
EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: instanceFeatures.maxPollOptionChars, focusNextView: self.focusNextOption)
.focused($focusedField, equals: .pollOption(option.id))
}
}
private func removeOption() {
let index = poll.options.index(of: option)
if index != NSNotFound && poll.options.count > 1 {
var array = poll.options.array
array.remove(at: index)
poll.options = NSMutableOrderedSet(array: array)
// TODO: does this leave dangling PollOptions in the managed object context?
if case .pollOption(let id) = focusedField,
id == option.id {
let indexToFocus = index > 0 ? index - 1 : 0
focusedField = .pollOption(poll.pollOptions[indexToFocus].id)
}
}
}
private func focusNextOption() {
let index = poll.options.index(of: option)
if index != NSNotFound && index + 1 < poll.options.count {
let nextOption = poll.options.object(at: index + 1) as! PollOption
focusedField = .pollOption(nextOption.objectID)
}
}
}
private struct PollOptionButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(.white)
.font(.body.bold())
.padding(4)
.frame(width: 20, height: 20)
.background {
let color = if isEnabled {
if configuration.role == .destructive {
Color.red
} else {
Color.green
}
} else {
Color.gray
}
let opacity = configuration.isPressed ? 0.8 : 1
Circle()
.fill(color.opacity(opacity))
}
}
}

View File

@ -1,72 +0,0 @@
//
// PollOptionView.swift
// ComposeUI
//
// Created by Shadowfacts on 3/25/23.
//
import SwiftUI
struct PollOptionView: View {
@EnvironmentObject private var controller: PollController
@EnvironmentObject private var poll: Poll
@ObservedObject private var option: PollOption
let remove: () -> Void
init(option: PollOption, remove: @escaping () -> Void) {
self.option = option
self.remove = remove
}
var body: some View {
HStack(spacing: 4) {
Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, background: controller.parent.config.backgroundColor)
.animation(.default, value: poll.multiple)
textField
Button(action: remove) {
Image(systemName: "minus.circle.fill")
}
.accessibilityLabel("Remove option")
.buttonStyle(.plain)
.foregroundColor(poll.options.count == 1 ? .gray : .red)
.disabled(poll.options.count == 1)
.hoverEffect()
}
}
private var textField: some View {
let index = poll.options.index(of: option)
let placeholder = index != NSNotFound ? "Option \(index + 1)" : ""
let maxLength = controller.parent.mastodonController.instanceFeatures.maxPollOptionChars
return EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: maxLength)
}
struct Checkbox: View {
private let radiusFraction: CGFloat
private let size: CGFloat = 20
private let innerSize: CGFloat
private let background: Color
init(radiusFraction: CGFloat, background: Color) {
self.radiusFraction = radiusFraction
self.innerSize = self.size - 4
self.background = background
}
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.gray)
.frame(width: size, height: size)
.cornerRadius(radiusFraction * size)
Rectangle()
.foregroundColor(background)
.frame(width: innerSize, height: innerSize)
.cornerRadius(radiusFraction * innerSize)
}
}
}
}

View File

@ -8,13 +8,17 @@
import SwiftUI
import Pachyderm
import TuskerComponents
import TuskerPreferences
struct ReplyStatusView: View {
let status: any StatusProtocol
let rowTopInset: CGFloat
let globalFrameOutsideList: CGRect
@EnvironmentObject private var controller: ComposeController
@PreferenceObserving(\.$avatarStyle) private var avatarStyle
@Environment(\.composeUIConfig.displayNameLabel) private var displayNameLabel
@Environment(\.composeUIConfig.replyContentView) private var replyContentView
@Environment(\.composeUIConfig.fetchAvatar) private var fetchAvatar
@State private var displayNameHeight: CGFloat?
@State private var contentHeight: CGFloat?
@ -27,7 +31,7 @@ struct ReplyStatusView: View {
VStack(alignment: .leading, spacing: 0) {
HStack {
controller.displayNameLabel(status.account, .body, 17)
displayNameLabel(status.account, .body, 17)
.lineLimit(1)
.layoutPriority(1)
@ -46,7 +50,7 @@ struct ReplyStatusView: View {
}
})
controller.replyContentView(status) { newHeight in
replyContentView(status) { 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 {
@ -80,8 +84,8 @@ struct ReplyStatusView: View {
AvatarImageView(
url: status.account.avatar,
size: 50,
style: controller.config.avatarStyle,
fetchAvatar: controller.fetchAvatar
style: avatarStyle == .circle ? .circle : .roundRect,
fetchAvatar: fetchAvatar
)
}
.frame(width: 50, height: 50)
@ -90,7 +94,7 @@ struct ReplyStatusView: View {
}
private struct DisplayNameHeightPrefKey: PreferenceKey {
private struct DisplayNameHeightPrefKey: SwiftUI.PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()

View File

@ -1,111 +0,0 @@
//
// ZoomableScrollView.swift
// ComposeUI
//
// Created by Shadowfacts on 4/29/23.
//
import SwiftUI
@available(iOS 16.0, *)
struct ZoomableScrollView<Content: View>: UIViewControllerRepresentable {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
func makeUIViewController(context: Context) -> Controller {
return Controller(content: content)
}
func updateUIViewController(_ uiViewController: Controller, context: Context) {
uiViewController.host.rootView = content
}
class Controller: UIViewController, UIScrollViewDelegate {
let scrollView = UIScrollView()
let host: UIHostingController<Content>
private var lastIntrinsicSize: CGSize?
private var contentViewTopConstraint: NSLayoutConstraint!
private var contentViewLeadingConstraint: NSLayoutConstraint!
private var hostBoundsObservation: NSKeyValueObservation?
init(content: Content) {
self.host = UIHostingController(rootView: content)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
scrollView.delegate = self
scrollView.bouncesZoom = true
scrollView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scrollView)
host.sizingOptions = .intrinsicContentSize
host.view.backgroundColor = .clear
host.view.translatesAutoresizingMaskIntoConstraints = false
addChild(host)
scrollView.addSubview(host.view)
host.didMove(toParent: self)
contentViewLeadingConstraint = host.view.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor)
contentViewTopConstraint = host.view.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor)
NSLayoutConstraint.activate([
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
contentViewLeadingConstraint,
contentViewTopConstraint,
])
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if !host.view.intrinsicContentSize.equalTo(.zero),
host.view.intrinsicContentSize != lastIntrinsicSize {
self.lastIntrinsicSize = host.view.intrinsicContentSize
let maxHeight = view.bounds.height - view.safeAreaInsets.top - view.safeAreaInsets.bottom
let maxWidth = view.bounds.width - view.safeAreaInsets.left - view.safeAreaInsets.right
let heightScale = maxHeight / host.view.intrinsicContentSize.height
let widthScale = maxWidth / host.view.intrinsicContentSize.width
let minScale = min(widthScale, heightScale)
let maxScale = minScale >= 1 ? minScale + 2 : 2
scrollView.minimumZoomScale = minScale
scrollView.maximumZoomScale = maxScale
scrollView.zoomScale = minScale
}
centerImage()
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return host.view
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
centerImage()
}
func centerImage() {
let yOffset = max(0, (view.bounds.size.height - host.view.bounds.height * scrollView.zoomScale) / 2)
contentViewTopConstraint.constant = yOffset
let xOffset = max(0, (view.bounds.size.width - host.view.bounds.width * scrollView.zoomScale) / 2)
contentViewLeadingConstraint.constant = xOffset
}
}
}

View File

@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "Duckable",
platforms: [
.iOS(.v16),
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.

View File

@ -14,6 +14,8 @@ class DuckedPlaceholderViewController: UIViewController {
var topConstraint: NSLayoutConstraint!
private var titleObservation: NSKeyValueObservation?
init(for duckableViewController: DuckableViewController, owner: DuckableContainerViewController) {
self.owner = owner
@ -21,8 +23,12 @@ class DuckedPlaceholderViewController: UIViewController {
let item = UINavigationItem()
item.title = duckableViewController.navigationItem.title
item.titleView = duckableViewController.navigationItem.titleView
assert(duckableViewController.navigationItem.titleView == nil)
navBar.setItems([item], animated: false)
titleObservation = duckableViewController.navigationItem.observe(\.title, changeHandler: { _, _ in
item.title = duckableViewController.navigationItem.title
})
}
required init?(coder: NSCoder) {

View File

@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "GalleryVC",
platforms: [
.iOS(.v16),
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
@ -14,11 +14,15 @@ let package = Package(
name: "GalleryVC",
targets: ["GalleryVC"]),
],
dependencies: [
.package(path: "../TuskerComponents"),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "GalleryVC",
dependencies: ["TuskerComponents"],
swiftSettings: [
.swiftLanguageMode(.v5)
]),

View File

@ -1,15 +1,13 @@
//
// FallbackGalleryContentViewController.swift
// Tusker
// GalleryVC
//
// Created by Shadowfacts on 3/18/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
import GalleryVC
import QuickLook
import Pachyderm
private class FallbackGalleryContentViewController: QLPreviewController {
private let previewItem = GalleryPreviewItem()
@ -52,40 +50,40 @@ extension FallbackGalleryContentViewController: QLPreviewControllerDataSource {
}
}
class FallbackGalleryNavigationController: UINavigationController, GalleryContentViewController {
init(url: URL) {
public class FallbackGalleryNavigationController: UINavigationController, GalleryContentViewController {
public init(url: URL) {
super.init(nibName: nil, bundle: nil)
self.viewControllers = [FallbackGalleryContentViewController(url: url)]
}
override func viewDidLoad() {
public override func viewDidLoad() {
super.viewDidLoad()
container?.disableGalleryScrollAndZoom()
}
required init?(coder aDecoder: NSCoder) {
public required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: GalleryContentViewController
weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
public weak var container: (any GalleryContentViewControllerContainer)?
var contentSize: CGSize {
public var contentSize: CGSize {
.zero
}
var activityItemsForSharing: [Any] {
public var activityItemsForSharing: [Any] {
[]
}
var caption: String? {
public var caption: String? {
nil
}
var canAnimateFromSourceView: Bool {
false
public var presentationAnimation: GalleryContentPresentationAnimation {
.fade
}
}

View File

@ -1,22 +1,22 @@
//
// ImageGalleryContentViewController.swift
// Tusker
// GalleryVC
//
// Created by Shadowfacts on 3/17/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
import GalleryVC
import Pachyderm
import TuskerComponents
@preconcurrency import VisionKit
class ImageGalleryContentViewController: UIViewController, GalleryContentViewController {
let url: URL
let caption: String?
let originalData: Data?
let image: UIImage
open class ImageGalleryContentViewController: UIViewController, GalleryContentViewController {
public let caption: String?
public var image: UIImage {
didSet {
imageView?.image = image
}
}
let gifController: GIFController?
private var imageView: GIFImageView!
@ -27,12 +27,8 @@ class ImageGalleryContentViewController: UIViewController, GalleryContentViewCon
@available(iOS 16.0, macCatalyst 17.0, *)
private var analysisInteraction: ImageAnalysisInteraction? { _analysisInteraction as? ImageAnalysisInteraction }
private var isGrayscale = false
init(url: URL, caption: String?, originalData: Data?, image: UIImage, gifController: GIFController?) {
self.url = url
public init(image: UIImage, caption: String?, gifController: GIFController?) {
self.caption = caption
self.originalData = originalData
self.image = image
self.gifController = gifController
@ -41,21 +37,14 @@ class ImageGalleryContentViewController: UIViewController, GalleryContentViewCon
preferredContentSize = image.size
}
required init?(coder: NSCoder) {
public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
public override func viewDidLoad() {
super.viewDidLoad()
isGrayscale = Preferences.shared.grayscaleImages
let maybeGrayscaleImage = if isGrayscale {
ImageGrayscalifier.convert(url: url, image: image) ?? image
} else {
image
}
imageView = GIFImageView(image: maybeGrayscaleImage)
imageView = GIFImageView(image: image)
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFill
imageView.isUserInteractionEnabled = true
@ -86,11 +75,9 @@ class ImageGalleryContentViewController: UIViewController, GalleryContentViewCon
}
}
}
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}
override func viewWillAppear(_ animated: Bool) {
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if let gifController {
@ -98,47 +85,40 @@ class ImageGalleryContentViewController: UIViewController, GalleryContentViewCon
}
}
@objc private func preferencesChanged() {
if isGrayscale != Preferences.shared.grayscaleImages {
isGrayscale = Preferences.shared.grayscaleImages
let image = if isGrayscale {
ImageGrayscalifier.convert(url: url, image: image)
} else {
image
}
if let image {
imageView.image = image
}
}
}
// MARK: GalleryContentViewController
weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
public weak var container: (any GalleryContentViewControllerContainer)?
var contentSize: CGSize {
public var contentSize: CGSize {
image.size
}
var activityItemsForSharing: [Any] {
if let data = originalData ?? image.pngData() {
return [ImageActivityItemSource(data: data, url: url, image: image)]
} else {
return []
}
open var activityItemsForSharing: [Any] {
return [image]
}
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
public var presentationAnimation: GalleryContentPresentationAnimation {
gifController != nil ? .fromSourceViewWithoutSnapshot : .fromSourceView
}
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
if #available(iOS 16.0, macCatalyst 17.0, *),
let analysisInteraction {
analysisInteraction.setSupplementaryInterfaceHidden(!visible, animated: animated)
}
}
public func setInsetForBottomControls(_ inset: CGFloat) {
if #available(iOS 16.0, macCatalyst 17.0, *),
let analysisInteraction {
analysisInteraction.supplementaryInterfaceContentInsets = UIEdgeInsets(top: 0, left: 0, bottom: inset, right: 0)
}
}
}
@available(iOS 16.0, macCatalyst 17.0, *)
extension ImageGalleryContentViewController: ImageAnalysisInteractionDelegate {
func interaction(_ interaction: ImageAnalysisInteraction, shouldBeginAt point: CGPoint, for interactionType: ImageAnalysisInteraction.InteractionTypes) -> Bool {
public func interaction(_ interaction: ImageAnalysisInteraction, shouldBeginAt point: CGPoint, for interactionType: ImageAnalysisInteraction.InteractionTypes) -> Bool {
return container?.galleryControlsVisible ?? true
}
}

View File

@ -7,48 +7,55 @@
//
import UIKit
import GalleryVC
class LoadingGalleryContentViewController: UIViewController, GalleryContentViewController {
public class LoadingGalleryContentViewController: UIViewController, GalleryContentViewController {
private let fallbackCaption: String?
private let provider: () async -> (any GalleryContentViewController)?
private var wrapped: (any GalleryContentViewController)!
weak var container: GalleryContentViewControllerContainer?
public weak var container: GalleryContentViewControllerContainer?
var contentSize: CGSize {
public var contentSize: CGSize {
wrapped?.contentSize ?? .zero
}
var activityItemsForSharing: [Any] {
public var activityItemsForSharing: [Any] {
wrapped?.activityItemsForSharing ?? []
}
var caption: String? {
public var caption: String? {
wrapped?.caption ?? fallbackCaption
}
var canAnimateFromSourceView: Bool {
wrapped?.canAnimateFromSourceView ?? true
public var presentationAnimation: GalleryContentPresentationAnimation {
wrapped?.presentationAnimation ?? .fade
}
init(caption: String?, provider: @escaping () async -> (any GalleryContentViewController)?) {
public var hideControlsOnZoom: Bool {
wrapped?.hideControlsOnZoom ?? true
}
public var showBelowSafeArea: Bool {
wrapped?.showBelowSafeArea ?? true
}
public init(caption: String?, provider: @escaping () async -> (any GalleryContentViewController)?) {
self.fallbackCaption = caption
self.provider = provider
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
public override func viewDidLoad() {
super.viewDidLoad()
container?.setGalleryContentLoading(true)
Task {
Task { @MainActor in
if let wrapped = await provider() {
self.wrapped = wrapped
wrapped.container = container
@ -81,7 +88,7 @@ class LoadingGalleryContentViewController: UIViewController, GalleryContentViewC
let label = UILabel()
label.text = "Error Loading"
label.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)!
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .title1).withSymbolicTraits(.traitBold)!, size: 0)
label.textColor = .secondaryLabel
label.adjustsFontForContentSizeCategory = true
@ -102,16 +109,24 @@ class LoadingGalleryContentViewController: UIViewController, GalleryContentViewC
])
}
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
wrapped?.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
}
func galleryContentDidAppear() {
public func setInsetForBottomControls(_ inset: CGFloat) {
wrapped?.setInsetForBottomControls(inset)
}
public func galleryContentDidAppear() {
wrapped?.galleryContentDidAppear()
}
func galleryContentWillDisappear() {
public func galleryContentWillDisappear() {
wrapped?.galleryContentWillDisappear()
}
public func galleryShouldBeginInteractiveDismiss() -> Bool {
wrapped?.galleryShouldBeginInteractiveDismiss() ?? true
}
}

View File

@ -1,6 +1,6 @@
//
// VideoControlsViewController.swift
// Tusker
// GalleryVC
//
// Created by Shadowfacts on 3/21/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
@ -9,6 +9,15 @@
import UIKit
import AVFoundation
@propertyWrapper
final class Box<T> {
var wrappedValue: T
init(wrappedValue: T) {
self.wrappedValue = wrappedValue
}
}
class VideoControlsViewController: UIViewController {
private static let formatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
@ -18,33 +27,49 @@ class VideoControlsViewController: UIViewController {
}()
private let player: AVPlayer
#if !os(visionOS)
@Box private var playbackSpeed: Float
#endif
private lazy var muteButton = MuteButton().configure {
$0.addTarget(self, action: #selector(muteButtonPressed), for: .touchUpInside)
$0.setMuted(false, animated: false)
}
private lazy var muteButton: MuteButton = {
let button = MuteButton()
button.addTarget(self, action: #selector(muteButtonPressed), for: .touchUpInside)
button.setMuted(false, animated: false)
return button
}()
private let timestampLabel = UILabel().configure {
$0.text = "0:00"
$0.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
}
private let timestampLabel: UILabel = {
let label = UILabel()
label.text = "0:00"
label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
return label
}()
private lazy var scrubbingControl = VideoScrubbingControl().configure {
$0.heightAnchor.constraint(equalToConstant: 44).isActive = true
$0.addTarget(self, action: #selector(scrubbingStarted), for: .editingDidBegin)
$0.addTarget(self, action: #selector(scrubbingChanged), for: .editingChanged)
$0.addTarget(self, action: #selector(scrubbingEnded), for: .editingDidEnd)
}
private lazy var scrubbingControl: VideoScrubbingControl = {
let control = VideoScrubbingControl()
control.heightAnchor.constraint(equalToConstant: 44).isActive = true
control.addTarget(self, action: #selector(scrubbingStarted), for: .editingDidBegin)
control.addTarget(self, action: #selector(scrubbingChanged), for: .editingChanged)
control.addTarget(self, action: #selector(scrubbingEnded), for: .editingDidEnd)
return control
}()
private let timeRemainingLabel = UILabel().configure {
$0.text = "-0:00"
$0.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
}
private let timeRemainingLabel: UILabel = {
let label = UILabel()
label.text = "-0:00"
label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
return label
}()
private lazy var optionsButton = MenuButton { [unowned self] in
let imageName: String
#if os(visionOS)
let playbackSpeed = player.defaultRate
#else
let playbackSpeed = self.playbackSpeed
#endif
if #available(iOS 17.0, *) {
switch player.defaultRate {
switch playbackSpeed {
case 0.5:
imageName = "gauge.with.dots.needle.0percent"
case 1:
@ -60,8 +85,12 @@ class VideoControlsViewController: UIViewController {
imageName = "speedometer"
}
let speedMenu = UIMenu(title: "Playback Speed", image: UIImage(systemName: imageName), children: PlaybackSpeed.allCases.map { speed in
UIAction(title: speed.displayName, state: self.player.defaultRate == speed.rate ? .on : .off) { [unowned self] _ in
UIAction(title: speed.displayName, state: playbackSpeed == speed.rate ? .on : .off) { [unowned self] _ in
#if os(visionOS)
self.player.defaultRate = speed.rate
#else
self.playbackSpeed = speed.rate
#endif
if self.player.rate > 0 {
self.player.rate = speed.rate
}
@ -70,17 +99,19 @@ class VideoControlsViewController: UIViewController {
return UIMenu(children: [speedMenu])
}
private lazy var hStack = UIStackView(arrangedSubviews: [
muteButton,
timestampLabel,
scrubbingControl,
timeRemainingLabel,
optionsButton,
]).configure {
$0.axis = .horizontal
$0.spacing = 8
$0.alignment = .center
}
private lazy var hStack: UIStackView = {
let stack = UIStackView(arrangedSubviews: [
muteButton,
timestampLabel,
scrubbingControl,
timeRemainingLabel,
optionsButton,
])
stack.axis = .horizontal
stack.spacing = 8
stack.alignment = .center
return stack
}()
private var timestampObserverToken: Any?
private var scrubberObserverToken: Any?
@ -89,11 +120,20 @@ class VideoControlsViewController: UIViewController {
private var scrubbingTargetTime: CMTime?
private var isSeeking = false
#if os(visionOS)
init(player: AVPlayer) {
self.player = player
super.init(nibName: nil, bundle: nil)
}
#else
init(player: AVPlayer, playbackSpeed: Box<Float>) {
self.player = player
self._playbackSpeed = playbackSpeed
super.init(nibName: nil, bundle: nil)
}
#endif
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
@ -177,7 +217,11 @@ class VideoControlsViewController: UIViewController {
@objc private func scrubbingEnded() {
scrubbingChanged()
if wasPlayingWhenScrubbingStarted {
#if os(visionOS)
player.play()
#else
player.rate = playbackSpeed
#endif
}
}

View File

@ -1,68 +1,58 @@
//
// VideoGalleryContentViewController.swift
// Tusker
// GalleryVC
//
// Created by Shadowfacts on 3/19/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
import GalleryVC
import AVFoundation
import CoreImage
class VideoGalleryContentViewController: UIViewController, GalleryContentViewController {
private let url: URL
let caption: String?
private var item: AVPlayerItem
let player: AVPlayer
open class VideoGalleryContentViewController: UIViewController, GalleryContentViewController {
public let url: URL
public let caption: String?
public private(set) var item: AVPlayerItem
public let player: AVPlayer
private var isGrayscale: Bool
#if !os(visionOS)
@available(iOS, obsoleted: 16.0, message: "Use AVPlayer.defaultRate")
@Box private var playbackSpeed: Float = 1
#endif
private var presentationSizeObservation: NSKeyValueObservation?
private var statusObservation: NSKeyValueObservation?
private var rateObservation: NSKeyValueObservation?
private var isFirstAppearance = true
private var hideControlsWorkItem: DispatchWorkItem?
private var audioSessionToken: AudioSessionCoordinator.Token?
private var isShowingError = false
init(url: URL, caption: String?) {
public init(url: URL, caption: String?) {
self.url = url
self.caption = caption
self.isGrayscale = Preferences.shared.grayscaleImages
let asset = AVAsset(url: url)
self.item = VideoGalleryContentViewController.createItem(asset: asset)
self.item = Self.createItem(asset: asset)
self.player = AVPlayer(playerItem: item)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private static func createItem(asset: AVAsset) -> AVPlayerItem {
let item = AVPlayerItem(asset: asset)
if Preferences.shared.grayscaleImages {
#if os(visionOS)
#warning("Use async AVVideoComposition CIFilter initializer")
#else
let filter = CIFilter(name: "CIColorMonochrome")!
filter.setValue(CIColor(red: 0.85, green: 0.85, blue: 0.85), forKey: "inputColor")
filter.setValue(1.0, forKey: "inputIntensity")
item.videoComposition = AVVideoComposition(asset: asset, applyingCIFiltersWithHandler: { request in
filter.setValue(request.sourceImage, forKey: "inputImage")
request.finish(with: filter.outputImage!, context: nil)
})
#endif
}
return item
open class func createItem(asset: AVAsset) -> AVPlayerItem {
return AVPlayerItem(asset: asset)
}
override func viewDidLoad() {
public func replaceCurrentItem(with item: AVPlayerItem) {
self.item = item
player.replaceCurrentItem(with: item)
updateItemObservations()
}
public override func viewDidLoad() {
super.viewDidLoad()
container?.setGalleryContentLoading(true)
@ -87,19 +77,17 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
scheduleControlsHide()
}
})
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}
private func updateItemObservations() {
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] item, _ in
MainActor.runUnsafely {
MainActor.assumeIsolated {
self.preferredContentSize = item.presentationSize
self.container?.galleryContentChanged()
}
})
statusObservation = item.observe(\.status, changeHandler: { [unowned self] item, _ in
MainActor.runUnsafely {
MainActor.assumeIsolated {
if item.status == .readyToPlay {
self.container?.setGalleryContentLoading(false)
self.statusObservation = nil
@ -108,19 +96,22 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
self.container?.setGalleryContentLoading(false)
self.showErrorView(error)
self.statusObservation = nil
self.overlayVC.setVisible(false)
}
}
})
}
private func showErrorView(_ error: any Error) {
isShowingError = true
let image = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")!)
image.tintColor = .secondaryLabel
image.contentMode = .scaleAspectFit
let label = UILabel()
label.text = "Error Loading"
label.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)!
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .title1).withSymbolicTraits(.traitBold)!, size: 0)
label.textColor = .secondaryLabel
label.adjustsFontForContentSizeCategory = true
@ -148,22 +139,9 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
])
}
@objc private func preferencesChanged() {
if isGrayscale != Preferences.shared.grayscaleImages {
let isPlaying = player.rate > 0
isGrayscale = Preferences.shared.grayscaleImages
item = VideoGalleryContentViewController.createItem(asset: item.asset)
player.replaceCurrentItem(with: item)
updateItemObservations()
if isPlaying {
player.play()
}
}
}
private func scheduleControlsHide() {
hideControlsWorkItem = DispatchWorkItem { [weak self] in
MainActor.runUnsafely {
MainActor.assumeIsolated {
guard let self,
let container = self.container,
container.galleryControlsVisible else {
@ -177,25 +155,39 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
// MARK: GalleryContentViewController
weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
public weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
var contentSize: CGSize {
public var contentSize: CGSize {
item.presentationSize
}
var activityItemsForSharing: [Any] {
[VideoActivityItemSource(asset: item.asset, url: url)]
open var activityItemsForSharing: [Any] {
[]
}
public var presentationAnimation: GalleryContentPresentationAnimation {
isShowingError ? .fade : .fromSourceViewWithoutSnapshot
}
#if os(visionOS)
private lazy var overlayVC = VideoOverlayViewController(player: player)
var contentOverlayAccessoryViewController: UIViewController? {
#else
private lazy var overlayVC = VideoOverlayViewController(player: player, playbackSpeed: _playbackSpeed)
#endif
public var contentOverlayAccessoryViewController: UIViewController? {
overlayVC
}
private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player)
#if os(visionOS)
public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player)
#else
public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player, playbackSpeed: _playbackSpeed)
#endif
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
overlayVC.setVisible(visible)
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
if !isShowingError {
overlayVC.setVisible(visible)
}
if !visible {
hideControlsWorkItem?.cancel()
@ -205,25 +197,11 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
}
}
func galleryContentDidAppear() {
let wasFirstAppearance = isFirstAppearance
isFirstAppearance = false
audioSessionToken = AudioSessionCoordinator.shared.beginPlayback(mode: .video) {
if wasFirstAppearance {
DispatchQueue.main.async {
self.player.play()
}
}
}
open func galleryContentDidAppear() {
}
func galleryContentWillDisappear() {
open func galleryContentWillDisappear() {
player.pause()
if let audioSessionToken {
AudioSessionCoordinator.shared.endPlayback(token: audioSessionToken)
}
}
}
@ -252,9 +230,9 @@ private class PlayerView: UIView {
playerLayer.player = player
playerLayer.videoGravity = .resizeAspect
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] _, _ in
MainActor.runUnsafely {
self.invalidateIntrinsicContentSize()
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [weak self] _, _ in
MainActor.assumeIsolated {
self?.invalidateIntrinsicContentSize()
}
})
}

View File

@ -1,6 +1,6 @@
//
// VideoOverlayViewController.swift
// Tusker
// GalleryVC
//
// Created by Shadowfacts on 3/26/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
@ -15,6 +15,9 @@ class VideoOverlayViewController: UIViewController {
private static let pauseImage = UIImage(systemName: "pause.fill")!
private let player: AVPlayer
#if !os(visionOS)
@Box private var playbackSpeed: Float
#endif
private var dimmingView: UIView!
private var controlsStack: UIStackView!
@ -23,10 +26,18 @@ class VideoOverlayViewController: UIViewController {
private var rateObservation: NSKeyValueObservation?
#if os(visionOS)
init(player: AVPlayer) {
self.player = player
super.init(nibName: nil, bundle: nil)
}
#else
init(player: AVPlayer, playbackSpeed: Box<Float>) {
self.player = player
self._playbackSpeed = playbackSpeed
super.init(nibName: nil, bundle: nil)
}
#endif
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
@ -79,7 +90,7 @@ class VideoOverlayViewController: UIViewController {
])
rateObservation = player.observe(\.rate, changeHandler: { player, _ in
MainActor.runUnsafely {
MainActor.assumeIsolated {
playPauseButton.image = player.rate > 0 ? VideoOverlayViewController.pauseImage : VideoOverlayViewController.playImage
}
})
@ -98,7 +109,11 @@ class VideoOverlayViewController: UIViewController {
if player.currentTime() >= player.currentItem!.duration {
player.seek(to: .zero)
}
#if os(visionOS)
player.play()
#else
player.rate = playbackSpeed
#endif
}
}

View File

@ -15,11 +15,17 @@ public protocol GalleryContentViewController: UIViewController {
var caption: String? { get }
var contentOverlayAccessoryViewController: UIViewController? { get }
var bottomControlsAccessoryViewController: UIViewController? { get }
var canAnimateFromSourceView: Bool { get }
var insetBottomControlsAccessoryViewControllerToSafeArea: Bool { get }
var presentationAnimation: GalleryContentPresentationAnimation { get }
var hideControlsOnZoom: Bool { get }
var showBelowSafeArea: Bool { get }
func shouldHideControls() -> Bool
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool)
func setInsetForBottomControls(_ inset: CGFloat)
func galleryContentDidAppear()
func galleryContentWillDisappear()
func galleryShouldBeginInteractiveDismiss() -> Bool
}
public extension GalleryContentViewController {
@ -31,16 +37,45 @@ public extension GalleryContentViewController {
nil
}
var canAnimateFromSourceView: Bool {
var insetBottomControlsAccessoryViewControllerToSafeArea: Bool {
true
}
var presentationAnimation: GalleryContentPresentationAnimation {
.fromSourceView
}
var hideControlsOnZoom: Bool {
true
}
var showBelowSafeArea: Bool {
true
}
func shouldHideControls() -> Bool {
true
}
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
}
func setInsetForBottomControls(_ inset: CGFloat) {
}
func galleryContentDidAppear() {
}
func galleryContentWillDisappear() {
}
func galleryShouldBeginInteractiveDismiss() -> Bool {
true
}
}
public enum GalleryContentPresentationAnimation {
case fade
case fromSourceView
case fromSourceViewWithoutSnapshot
}

View File

@ -30,12 +30,37 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
let itemViewController = from.currentItemViewController
if !itemViewController.content.canAnimateFromSourceView || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) {
if itemViewController.content.presentationAnimation == .fade || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) {
animateCrossFadeTransition(using: transitionContext)
return
}
let container = transitionContext.containerView
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
// is in the window's root presentation.
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
// `to.view` is already in the view hierarchy at this point; and adding it to the
// container causees it to be removed when the transition completes.
if to.view.superview == nil {
to.view.frame = container.bounds
container.addSubview(to.view)
}
let sourceSnapshot: UIView? = if itemViewController.content.presentationAnimation == .fromSourceViewWithoutSnapshot {
nil
} else {
sourceView.snapshotView(afterScreenUpdates: false)
}
if let sourceSnapshot {
let snapshotContainer = sourceView.ancestorForInsertingSnapshot
snapshotContainer.addSubview(sourceSnapshot)
let sourceFrameInShapshotContainer = snapshotContainer.convert(sourceView.bounds, from: sourceView)
sourceSnapshot.frame = sourceFrameInShapshotContainer
sourceSnapshot.layer.opacity = 1
self.sourceView.layer.opacity = 0
}
let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView)
let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view)
@ -48,38 +73,39 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
.translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY)
.scaledBy(x: scale, y: scale)
sourceView.transform = sourceToDestTransform
sourceSnapshot?.transform = sourceToDestTransform
} else {
appliedSourceToDestTransform = false
}
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
// is in the window's root presentation.
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
// `to.view` is already in the view hierarchy at this point; and adding it to the
// container causees it to be removed when the transition completes.
if to.view.superview == nil {
to.view.frame = container.bounds
container.addSubview(to.view)
}
from.view.frame = container.bounds
container.addSubview(from.view)
let contentContainer = UIView()
contentContainer.layer.masksToBounds = true
contentContainer.frame = destFrameInContainer
container.addSubview(contentContainer)
let content = itemViewController.takeContent()
content.view.translatesAutoresizingMaskIntoConstraints = true
content.view.layer.masksToBounds = true
container.addSubview(content.view)
content.view.frame = destFrameInContainer
content.view.transform = .identity
content.view.layer.opacity = 1
content.view.frame = contentContainer.bounds
contentContainer.addSubview(content.view)
container.layoutIfNeeded()
// Hide overlaid controls immediately, to prevent the Live Text button's position
// getting caught up in the rest of the animation.
UIView.animate(withDuration: 0.1) {
content.setControlsVisible(false, animated: false, dueToUserInteraction: false)
}
let duration = self.transitionDuration(using: transitionContext)
var initialVelocity: CGVector
if let interactiveVelocity,
let interactiveTranslation,
// very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the springs initial undershoot
// very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the spring's initial undershoot
sqrt(pow(interactiveTranslation.x, 2) + pow(interactiveTranslation.y, 2)) > 100,
sqrt(pow(interactiveVelocity.x, 2) + pow(interactiveVelocity.y, 2)) < 2000 {
let xDistance = sourceFrameInContainer.midX - destFrameInContainer.midX
@ -102,14 +128,34 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
if appliedSourceToDestTransform {
self.sourceView.transform = origSourceTransform
sourceSnapshot?.transform = origSourceTransform
}
content.view.frame = sourceFrameInContainer
content.view.layer.opacity = 0
contentContainer.frame = sourceFrameInContainer
// Using sourceSizeWithDestAspectRatioCenteredInContentContainer does not seem to be necessary here.
// I guess autoresizing takes care of it?
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
}
// Delay fading out the content because if it's still big while it's semi-transparent,
// seeing the stuff behind it looks odd.
animator.addAnimations({
content.view.layer.opacity = 0
}, delayFactor: 0.35)
if let sourceSnapshot {
animator.addAnimations({
self.sourceView.layer.opacity = 1
sourceSnapshot.layer.opacity = 0
}, delayFactor: 0.5)
}
animator.addCompletion { _ in
sourceSnapshot?.removeFromSuperview()
// Having dismissed, we don't need to undo any of the changes to the content VC.
transitionContext.completeTransition(true)
}
@ -122,10 +168,17 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
return
}
let container = transitionContext.containerView
// See comment above.
if toVC.view.superview == nil {
toVC.view.frame = container.bounds
container.addSubview(toVC.view)
}
toVC.view.frame = transitionContext.containerView.bounds
fromVC.view.frame = transitionContext.containerView.bounds
transitionContext.containerView.addSubview(toVC.view)
transitionContext.containerView.addSubview(fromVC.view)
container.addSubview(fromVC.view)
let duration = transitionDuration(using: transitionContext)
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut)

View File

@ -20,6 +20,8 @@ class GalleryDismissInteraction: NSObject {
private(set) var dismissVelocity: CGPoint?
private(set) var dismissTranslation: CGPoint?
private var cancelAnimator: UIViewPropertyAnimator?
init(viewController: GalleryViewController) {
self.viewController = viewController
super.init()
@ -38,6 +40,8 @@ class GalleryDismissInteraction: NSObject {
content = viewController.currentItemViewController.takeContent()
content!.view.translatesAutoresizingMaskIntoConstraints = true
content!.view.frame = origContentFrameInGallery!
// Make sure the context remains behind the controls
content!.view.layer.zPosition = -1000
viewController.view.addSubview(content!.view)
origControlsVisible = viewController.currentItemViewController.controlsVisible
@ -53,12 +57,42 @@ class GalleryDismissInteraction: NSObject {
let translation = recognizer.translation(in: viewController.view)
let velocity = recognizer.velocity(in: viewController.view)
dismissVelocity = velocity
dismissTranslation = translation
viewController.dismiss(animated: true)
let translationMagnitude = sqrt(translation.x.magnitudeSquared + translation.y.magnitudeSquared)
let velocityMagnitude = sqrt(velocity.x.magnitudeSquared + velocity.y.magnitudeSquared)
if translationMagnitude < 150 && velocityMagnitude < 500 {
isActive = false
cancelAnimator?.stopAnimation(true)
let spring = UISpringTimingParameters(mass: 1, stiffness: 439, damping: 42, initialVelocity: .zero)
cancelAnimator = UIViewPropertyAnimator(duration: 0.2, timingParameters: spring)
cancelAnimator!.addAnimations {
self.content!.view.frame = self.origContentFrameInGallery!
self.viewController.currentItemViewController.setControlsVisible(self.origControlsVisible!, animated: false, dueToUserInteraction: false)
}
cancelAnimator!.addCompletion { _ in
guard !self.isActive else {
// bail in case the animation finishing raced with the user's interaction
return
}
self.content!.view.layer.zPosition = 0
self.content!.view.removeFromSuperview()
self.viewController.currentItemViewController.addContent()
self.content = nil
self.origContentFrameInGallery = nil
self.origControlsVisible = nil
}
cancelAnimator!.startAnimation()
// don't unset this until after dismiss is called, so that the dismiss animation controller can read it
isActive = false
} else {
dismissVelocity = velocity
dismissTranslation = translation
viewController.dismiss(animated: true)
// don't unset this until after dismiss is called, so that the dismiss animation controller can read it
isActive = false
}
default:
break
@ -72,12 +106,8 @@ extension GalleryDismissInteraction: UIGestureRecognizerDelegate {
let itemVC = viewController.currentItemViewController
if viewController.galleryDataSource.galleryContentTransitionSourceView(forItemAt: itemVC.itemIndex) == nil {
return false
} else if itemVC.scrollView.zoomScale > itemVC.scrollView.minimumZoomScale {
return false
} else if !itemVC.scrollAndZoomEnabled {
return false
} else {
return true
return itemVC.shouldBeginInteractiveDismiss()
}
}
}

View File

@ -11,6 +11,7 @@ import AVFoundation
@MainActor
protocol GalleryItemViewControllerDelegate: AnyObject {
func isGalleryBeingPresented() -> Bool
func isGalleryBeingDismissed() -> Bool
func addPresentationAnimationCompletion(_ block: @escaping () -> Void)
func galleryItemClose(_ item: GalleryItemViewController)
func galleryItemApplicationActivities(_ item: GalleryItemViewController) -> [UIActivity]?
@ -44,6 +45,13 @@ class GalleryItemViewController: UIViewController {
private(set) var scrollAndZoomEnabled = true
private var scrollViewSizeForLastZoomScaleUpdate: CGSize?
private var skipScrollViewZoomUpdate = false
var showShareButton: Bool = true {
didSet {
shareButton?.isHidden = !showShareButton
}
}
override var prefersHomeIndicatorAutoHidden: Bool {
return !controlsVisible
@ -66,9 +74,18 @@ class GalleryItemViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Need to use the keyboard layout guide in some way in this VC,
// otherwise the keyboardLayoutGuide inside the bottom controls accessory view doesn't animate
_ = view.keyboardLayoutGuide
scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.delegate = self
// We calculate zoom/position ignoring the safe area, so content insets need to not incorporate it either.
// Otherwise, content that fills the screen (extending into the safe area) may still end up scrollable
// (this is readily observable with tall images on a landscape iPad).
// Even if the content is not being shown below the safe area, we still set this to make the calculations more consistent/straightforward.
scrollView.contentInsetAdjustmentBehavior = .never
view.addSubview(scrollView)
@ -105,6 +122,7 @@ class GalleryItemViewController: UIViewController {
return UIPointerStyle(effect: .highlight(effect.preview), shape: .roundedRect(button.frame))
}
shareButton.preferredBehavioralStyle = .pad
shareButton.isHidden = !showShareButton
shareButton.translatesAutoresizingMaskIntoConstraints = false
updateShareButton()
topControlsView.addSubview(shareButton)
@ -137,12 +155,14 @@ class GalleryItemViewController: UIViewController {
bottomControlsView.addArrangedSubview(controlsAccessory.view)
controlsAccessory.didMove(toParent: self)
// Make sure the controls accessory is within the safe area.
let spacer = UIView()
bottomControlsView.addArrangedSubview(spacer)
let spacerTopConstraint = spacer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
spacerTopConstraint.priority = .init(999)
spacerTopConstraint.isActive = true
if content.insetBottomControlsAccessoryViewControllerToSafeArea {
// Make sure the controls accessory is within the safe area.
let spacer = UIView()
bottomControlsView.addArrangedSubview(spacer)
let spacerTopConstraint = spacer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
spacerTopConstraint.priority = .init(999)
spacerTopConstraint.isActive = true
}
}
captionTextView = UITextView()
@ -206,12 +226,21 @@ class GalleryItemViewController: UIViewController {
singleTap.require(toFail: doubleTap)
view.addGestureRecognizer(singleTap)
view.addGestureRecognizer(doubleTap)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillUpdate), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillUpdate), name: UIResponder.keyboardWillHideNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillUpdate), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
}
@objc private func keyboardWillUpdate() {
updateScrollView(resetZoom: true)
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
updateZoomScale(resetZoom: false)
updateScrollView(resetZoom: false)
// Ensure the transform is correct if the controls are hidden
setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
@ -225,7 +254,7 @@ class GalleryItemViewController: UIViewController {
// This might also fix an issue on macOS (Designed for iPad) where the content isn't placed correctly. See #446
if scrollViewSizeForLastZoomScaleUpdate != scrollView.bounds.size {
scrollViewSizeForLastZoomScaleUpdate = scrollView.bounds.size
updateZoomScale(resetZoom: true)
updateScrollView(resetZoom: true)
}
centerContent()
// Ensure the transform is correct if the controls are hidden and their size changed.
@ -263,7 +292,7 @@ class GalleryItemViewController: UIViewController {
contentViewLeadingConstraint!.isActive = true
contentViewTopConstraint = content.view.topAnchor.constraint(equalTo: scrollView.topAnchor)
contentViewTopConstraint!.isActive = true
updateZoomScale(resetZoom: true)
updateScrollView(resetZoom: true)
} else {
// If the content was previously added, deactivate the old constraints.
contentViewLeadingConstraint?.isActive = false
@ -302,6 +331,13 @@ class GalleryItemViewController: UIViewController {
topControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : -topControlsView.bounds.height)
bottomControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : bottomControlsView.bounds.height)
content.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
if !content.showBelowSafeArea {
skipScrollViewZoomUpdate = true
updateScrollView(resetZoom: abs(scrollView.zoomScale - scrollView.minimumZoomScale) < 0.01)
skipScrollViewZoomUpdate = false
scrollView.layoutIfNeeded()
}
}
if animated {
let animator = UIViewPropertyAnimator(duration: 0.2, timingParameters: UISpringTimingParameters())
@ -314,7 +350,7 @@ class GalleryItemViewController: UIViewController {
setNeedsUpdateOfHomeIndicatorAutoHidden()
}
func updateZoomScale(resetZoom: Bool) {
func updateScrollView(resetZoom: Bool) {
scrollView.contentSize = content.contentSize
guard scrollAndZoomEnabled else {
@ -328,7 +364,7 @@ class GalleryItemViewController: UIViewController {
return
}
let heightScale = view.bounds.height / content.contentSize.height
let heightScale = availableHeightForContent() / content.contentSize.height
let widthScale = view.bounds.width / content.contentSize.width
let minScale = min(widthScale, heightScale)
let maxScale = minScale >= 1 ? minScale + 2 : 2
@ -341,6 +377,31 @@ class GalleryItemViewController: UIViewController {
scrollView.zoomScale = max(minScale, min(maxScale, scrollView.zoomScale))
}
let bottomInset: CGFloat
let bottomIndicatorInset: CGFloat
if !content.showBelowSafeArea,
controlsVisible,
let bottomControlsView {
bottomInset = bottomControlsView.bounds.height + view.safeAreaInsets.top
bottomIndicatorInset = bottomControlsView.safeAreaLayoutGuide.layoutFrame.height
} else {
bottomInset = 0
bottomIndicatorInset = 0
}
scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
scrollView.verticalScrollIndicatorInsets = UIEdgeInsets(top: 0, left: 0, bottom: bottomIndicatorInset, right: 0)
let contentBottomControlsInset: CGFloat
if content.showBelowSafeArea,
controlsVisible,
let bottomControlsView {
let contentFrameInSelf = content.view.convert(content.view.bounds, to: view)
contentBottomControlsInset = max(0, contentFrameInSelf.maxY - bottomControlsView.frame.minY - view.safeAreaInsets.bottom)
} else {
contentBottomControlsInset = 0
}
content.setInsetForBottomControls(contentBottomControlsInset)
centerContent()
}
@ -349,15 +410,29 @@ class GalleryItemViewController: UIViewController {
return
}
let additionalYOffset = content.showBelowSafeArea ? 0 : view.safeAreaInsets.top
// Note: use frame for the content.view, because that's in the coordinate space of the scroll view
// which means it's already been scaled by the zoom factor.
let yOffset = max(0, (view.bounds.height - content.view.frame.height) / 2)
contentViewTopConstraint!.constant = yOffset
let yOffset = max(0, (availableHeightForContent() - content.view.frame.height) / 2)
contentViewTopConstraint!.constant = yOffset + additionalYOffset
let xOffset = max(0, (view.bounds.width - content.view.frame.width) / 2)
contentViewLeadingConstraint!.constant = xOffset
}
private func availableHeightForContent() -> CGFloat {
var availableHeight: CGFloat
if content.showBelowSafeArea {
availableHeight = view.bounds.height
} else {
availableHeight = view.safeAreaLayoutGuide.layoutFrame.height
if controlsVisible {
availableHeight -= bottomControlsView?.safeAreaLayoutGuide.layoutFrame.height ?? 0
}
}
return availableHeight
}
private func updateShareButton() {
shareButton.isEnabled = !content.activityItemsForSharing.isEmpty
}
@ -372,13 +447,27 @@ class GalleryItemViewController: UIViewController {
}
private func updateTopControlsInsets() {
guard delegate?.isGalleryBeingDismissed() != true else {
return
}
let notchedDeviceTopInsets: [CGFloat] = [
44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max
48, // iPhone XR, 11
47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus
50, // iPhone 12 mini, 13 mini
]
if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) {
let topInset: CGFloat
switch view.window?.windowScene?.interfaceOrientation {
case .portraitUpsideDown:
topInset = view.safeAreaInsets.bottom
case .landscapeLeft:
topInset = view.safeAreaInsets.right
case .landscapeRight:
topInset = view.safeAreaInsets.left
default:
topInset = view.safeAreaInsets.top
}
if notchedDeviceTopInsets.contains(topInset) {
// the notch width is not the same for the iPhones 13,
// but what we actually want is the same offset from the edges
// since the corner radius didn't change
@ -387,7 +476,7 @@ class GalleryItemViewController: UIViewController {
let offset = (earWidth - (shareButton.imageView?.bounds.width ?? 0)) / 2
shareButtonLeadingConstraint.constant = offset
closeButtonTrailingConstraint.constant = offset
} else if view.safeAreaInsets.top == 0 {
} else if topInset == 0 {
// square corner devices
shareButtonLeadingConstraint.constant = 8
shareButtonTopConstraint.constant = 8
@ -428,7 +517,9 @@ class GalleryItemViewController: UIViewController {
scrollView.zoomScale > scrollView.minimumZoomScale {
animateZoomOut()
} else {
setControlsVisible(!controlsVisible, animated: true, dueToUserInteraction: true)
if content.shouldHideControls() {
setControlsVisible(!controlsVisible, animated: true, dueToUserInteraction: true)
}
}
}
@ -472,6 +563,16 @@ class GalleryItemViewController: UIViewController {
present(activityVC, animated: true)
}
func shouldBeginInteractiveDismiss() -> Bool {
if scrollView.zoomScale > scrollView.minimumZoomScale {
false
} else if !scrollAndZoomEnabled {
false
} else {
content.galleryShouldBeginInteractiveDismiss()
}
}
}
extension GalleryItemViewController: GalleryContentViewControllerContainer {
@ -514,14 +615,14 @@ extension GalleryItemViewController: GalleryContentViewControllerContainer {
}
func galleryContentChanged() {
updateZoomScale(resetZoom: true)
updateScrollView(resetZoom: true)
updateShareButton()
updateCaptionTextView()
}
func disableGalleryScrollAndZoom() {
scrollAndZoomEnabled = false
updateZoomScale(resetZoom: true)
updateScrollView(resetZoom: true)
scrollView.isScrollEnabled = false
// Make sure the content is re-added with the correct constraints
if content.parent == self {
@ -544,10 +645,16 @@ extension GalleryItemViewController: UIScrollViewDelegate {
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
guard !skipScrollViewZoomUpdate else {
return
}
if scrollView.zoomScale <= scrollView.minimumZoomScale {
setControlsVisible(true, animated: true, dueToUserInteraction: true)
} else {
setControlsVisible(false, animated: true, dueToUserInteraction: true)
if content.hideControlsOnZoom {
setControlsVisible(false, animated: true, dueToUserInteraction: true)
}
}
centerContent()

View File

@ -25,18 +25,38 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
let itemViewController = to.currentItemViewController
if !itemViewController.content.canAnimateFromSourceView || UIAccessibility.prefersCrossFadeTransitions {
if itemViewController.content.presentationAnimation == .fade || UIAccessibility.prefersCrossFadeTransitions {
animateCrossFadeTransition(using: transitionContext)
return
}
// Try to effectively "fade out" anything that's on top of the source view.
// The 0.1 duration makes this happen faster than the rest of the animation,
// and so less noticeable.
let sourceSnapshot: UIView? = if itemViewController.content.presentationAnimation == .fromSourceViewWithoutSnapshot {
nil
} else {
sourceView.snapshotView(afterScreenUpdates: false)
}
if let sourceSnapshot {
let snapshotContainer = sourceView.ancestorForInsertingSnapshot
snapshotContainer.addSubview(sourceSnapshot)
let sourceFrameInShapshotContainer = snapshotContainer.convert(sourceView.bounds, from: sourceView)
sourceSnapshot.frame = sourceFrameInShapshotContainer
sourceSnapshot.transform = sourceView.transform
sourceSnapshot.layer.opacity = 0
UIView.animate(withDuration: 0.1) {
sourceSnapshot.layer.opacity = 1
}
}
let container = transitionContext.containerView
to.view.frame = container.bounds
container.addSubview(to.view)
container.layoutIfNeeded()
// Make sure the zoom scale is updated before getting the content view frame, since it needs to take into account the correct transform.
itemViewController.updateZoomScale(resetZoom: true)
itemViewController.updateScrollView(resetZoom: true)
let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView)
let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view)
@ -56,21 +76,70 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
sourceToDestTransform = nil
}
// Grab these before taking the content out and changing the transform.
let origContentTransform = itemViewController.content.view.transform
let origContentFrame = itemViewController.content.view.frame
// The content container provides the clipping for the content view,
// which, in case the source/dest aspect ratios don't match, makes
// it look like the content is expanding out from the source rect.
let contentContainer = UIView()
contentContainer.layer.masksToBounds = true
container.insertSubview(contentContainer, belowSubview: to.view)
let content = itemViewController.takeContent()
content.view.translatesAutoresizingMaskIntoConstraints = true
container.insertSubview(content.view, belowSubview: to.view)
content.view.transform = .identity
// The fade-in makes the aspect ratio handling look a little bit worse,
// but papers over the z-index change and potential corner radius change.
content.view.layer.opacity = 0
contentContainer.addSubview(content.view)
// Use a separate dimming view from to.view, so that the gallery controls can be in front of the moving content.
let dimmingView = UIView()
dimmingView.backgroundColor = .black
dimmingView.frame = container.bounds
dimmingView.layer.opacity = 0
container.insertSubview(dimmingView, belowSubview: content.view)
container.insertSubview(dimmingView, belowSubview: contentContainer)
to.view.backgroundColor = nil
to.view.layer.opacity = 0
content.view.frame = sourceFrameInContainer
content.view.layer.opacity = 0
contentContainer.frame = sourceFrameInContainer
let sourceAspectRatio: CGFloat = if sourceFrameInContainer.height > 0 {
sourceFrameInContainer.width / sourceFrameInContainer.height
} else {
0
}
let destAspectRatio: CGFloat = if destFrameInContainer.height > 0 {
destFrameInContainer.width / destFrameInContainer.height
} else {
0
}
let sourceSizeWithDestAspectRatioCenteredInContentContainer: CGRect
if 0.001 < abs(sourceAspectRatio - destAspectRatio) {
// asepct ratios are effectively equal
sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(origin: .zero, size: sourceFrameInContainer.size)
} else if sourceAspectRatio < destAspectRatio {
// source aspect ratio is narrow/taller than dest
let width = sourceFrameInContainer.height * destAspectRatio
sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(
x: -(width - sourceFrameInContainer.width) / 2,
y: 0,
width: width,
height: sourceFrameInContainer.height
)
} else {
// source aspect ratio is wider/shorter than dest
let height = sourceFrameInContainer.width / destAspectRatio
sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(
x: 0,
y: -(height - sourceFrameInContainer.height) / 2,
width: sourceFrameInContainer.width,
height: height
)
}
content.view.frame = sourceSizeWithDestAspectRatioCenteredInContentContainer
container.layoutIfNeeded()
@ -78,8 +147,14 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
let duration = self.transitionDuration(using: transitionContext)
// rougly equivalent to duration: 0.35, bounce: 0.3
let spring = UISpringTimingParameters(mass: 1, stiffness: 322, damping: 25, initialVelocity: .zero)
// less bounce on bigger screens
let spring = if UIDevice.current.userInterfaceIdiom == .pad {
// roughly equivalent to duration: 0.35, bounce: 0.2
UISpringTimingParameters(mass: 1, stiffness: 322, damping: 28, initialVelocity: .zero)
} else {
// roughly equivalent to duration: 0.35, bounce: 0.3
UISpringTimingParameters(mass: 1, stiffness: 322, damping: 25, initialVelocity: .zero)
}
let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring)
animator.addAnimations {
@ -87,25 +162,35 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
to.view.layer.opacity = 1
content.view.frame = destFrameInContainer
contentContainer.frame = destFrameInContainer
content.view.frame = contentContainer.bounds
content.view.layer.opacity = 1
itemViewController.setControlsVisible(true, animated: false, dueToUserInteraction: false)
if let sourceToDestTransform {
sourceSnapshot?.transform = sourceToDestTransform
self.sourceView.transform = sourceToDestTransform
}
}
animator.addCompletion { _ in
sourceSnapshot?.removeFromSuperview()
self.sourceView.layer.opacity = 1
if sourceToDestTransform != nil {
self.sourceView.transform = origSourceTransform
}
contentContainer.removeFromSuperview()
dimmingView.removeFromSuperview()
to.view.backgroundColor = .black
if sourceToDestTransform != nil {
self.sourceView.transform = origSourceTransform
}
// Reset the properties we changed before re-adding the content to the scroll view.
// (I would expect UIScrollView to effectively do this itself, but w/e.)
content.view.transform = origContentTransform
content.view.frame = origContentFrame
itemViewController.addContent()
transitionContext.completeTransition(true)

View File

@ -26,6 +26,14 @@ public class GalleryViewController: UIPageViewController {
private var dismissInteraction: GalleryDismissInteraction!
private var presentationAnimationCompletionHandlers: [() -> Void] = []
public var showShareButton: Bool = true {
didSet {
if viewControllers?.isEmpty == false {
currentItemViewController.showShareButton = showShareButton
}
}
}
override public var prefersStatusBarHidden: Bool {
true
}
@ -89,7 +97,9 @@ public class GalleryViewController: UIPageViewController {
private func makeItemVC(index: Int) -> GalleryItemViewController {
let content = galleryDataSource.galleryContentViewController(forItemAt: index)
return GalleryItemViewController(delegate: self, itemIndex: index, content: content)
let itemVC = GalleryItemViewController(delegate: self, itemIndex: index, content: content)
itemVC.showShareButton = showShareButton
return itemVC
}
func presentationAnimationCompleted() {
@ -139,6 +149,10 @@ extension GalleryViewController: GalleryItemViewControllerDelegate {
isBeingPresented
}
func isGalleryBeingDismissed() -> Bool {
isBeingDismissed
}
func addPresentationAnimationCompletion(_ block: @escaping () -> Void) {
presentationAnimationCompletionHandlers.append(block)
}

View File

@ -0,0 +1,27 @@
//
// UIView+Utilities.swift
// GalleryVC
//
// Created by Shadowfacts on 11/24/24.
//
import UIKit
extension UIView {
var ancestorForInsertingSnapshot: UIView {
var view = self
while let superview = view.superview {
if superview.layer.masksToBounds {
return superview
} else if let scrollView = superview as? UIScrollView,
scrollView.isScrollEnabled {
return self
} else {
view = superview
}
}
return view
}
}

View File

@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "InstanceFeatures",
platforms: [
.iOS(.v16),
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.

View File

@ -1,9 +0,0 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -1,29 +0,0 @@
// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "MatchedGeometryPresentation",
platforms: [
.iOS(.v16),
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "MatchedGeometryPresentation",
targets: ["MatchedGeometryPresentation"]),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "MatchedGeometryPresentation",
swiftSettings: [
.swiftLanguageMode(.v5)
]),
// .testTarget(
// name: "MatchedGeometryPresentationTests",
// dependencies: ["MatchedGeometryPresentation"]),
]
)

View File

@ -1,125 +0,0 @@
//
// MatchedGeometryModifiers.swift
// MatchGeom
//
// Created by Shadowfacts on 4/24/23.
//
import SwiftUI
extension View {
public func matchedGeometryPresentation<ID: Hashable, Presented: View>(id: Binding<ID?>, backgroundColor: UIColor, @ViewBuilder presenting: () -> Presented) -> some View {
self.modifier(MatchedGeometryPresentationModifier(id: id, backgroundColor: backgroundColor, presented: presenting()))
}
public func matchedGeometrySource<ID: Hashable, ID2: Hashable>(id: ID, presentationID: ID2) -> some View {
self.modifier(MatchedGeometrySourceModifier(id: AnyHashable(id), presentationID: AnyHashable(presentationID), matched: { AnyView(self) }))
}
public func matchedGeometryDestination<ID: Hashable>(id: ID) -> some View {
self.modifier(MatchedGeometryDestinationModifier(id: AnyHashable(id), matched: self))
}
}
private struct MatchedGeometryPresentationModifier<ID: Hashable, Presented: View>: ViewModifier {
@Binding var id: ID?
let backgroundColor: UIColor
let presented: Presented
@StateObject private var state = MatchedGeometryState()
private var isPresented: Binding<Bool> {
Binding {
id != nil
} set: {
if $0 {
fatalError()
} else {
id = nil
}
}
}
func body(content: Content) -> some View {
content
.environmentObject(state)
.backgroundPreferenceValue(MatchedGeometrySourcesKey.self, { sources in
Color.clear
.presentViewController(makeVC(allSources: sources), isPresented: isPresented)
})
}
private func makeVC(allSources: [SourceKey: (AnyView, CGRect)]) -> () -> UIViewController {
return {
// force unwrap is safe, this closure is only called when being presented so we must have an id
let id = AnyHashable(id!)
return MatchedGeometryViewController(
presentationID: id,
content: presented,
state: state,
backgroundColor: backgroundColor
)
}
}
}
private struct MatchedGeometrySourceModifier: ViewModifier {
let id: AnyHashable
let presentationID: AnyHashable
let matched: () -> AnyView
@EnvironmentObject private var state: MatchedGeometryState
func body(content: Content) -> some View {
content
.background(GeometryReader { proxy in
Color.clear
.preference(key: MatchedGeometryDestinationFrameKey.self, value: proxy.frame(in: .global))
.onPreferenceChange(MatchedGeometryDestinationFrameKey.self) { newValue in
if let newValue {
state.sources[SourceKey(presentationID: presentationID, matchedID: id)] = (matched, newValue)
}
}
})
.opacity(state.animating && state.presentationID == presentationID ? 0 : 1)
}
}
private struct MatchedGeometryDestinationModifier<Matched: View>: ViewModifier {
let id: AnyHashable
let matched: Matched
@EnvironmentObject private var state: MatchedGeometryState
func body(content: Content) -> some View {
content
.background(GeometryReader { proxy in
Color.clear
.preference(key: MatchedGeometryDestinationFrameKey.self, value: proxy.frame(in: .global))
.onPreferenceChange(MatchedGeometryDestinationFrameKey.self) { newValue in
if let newValue,
// ignore intermediate layouts that may happen while the dismiss animation is happening
state.mode != .dismissing {
state.destinations[id] = (AnyView(matched), newValue)
}
}
})
.opacity(state.animating ? 0 : 1)
}
}
private struct MatchedGeometryDestinationFrameKey: PreferenceKey {
static let defaultValue: CGRect? = nil
static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
value = nextValue()
}
}
private struct MatchedGeometrySourcesKey: PreferenceKey {
static let defaultValue: [SourceKey: (AnyView, CGRect)] = [:]
static func reduce(value: inout Value, nextValue: () -> Value) {
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
}
}
struct SourceKey: Hashable {
let presentationID: AnyHashable
let matchedID: AnyHashable
}

View File

@ -1,239 +0,0 @@
//
// MatchedGeometryViewController.swift
// MatchGeom
//
// Created by Shadowfacts on 4/24/23.
//
import SwiftUI
import Combine
private let mass: CGFloat = 1
private let presentStiffness: CGFloat = 300
private let presentDamping: CGFloat = 20
private let dismissStiffness: CGFloat = 200
private let dismissDamping: CGFloat = 20
public class MatchedGeometryState: ObservableObject {
@Published var presentationID: AnyHashable?
@Published var animating: Bool = false
@Published public var mode: Mode = .presenting
@Published var sources: [SourceKey: (() -> AnyView, CGRect)] = [:]
@Published var currentFrames: [AnyHashable: CGRect] = [:]
@Published var destinations: [AnyHashable: (AnyView, CGRect)] = [:]
public enum Mode: Equatable {
case presenting
case idle
case dismissing
}
}
class MatchedGeometryViewController<Content: View>: UIViewController, UIViewControllerTransitioningDelegate {
let presentationID: AnyHashable
let content: Content
let state: MatchedGeometryState
let backgroundColor: UIColor
var contentHost: UIHostingController<ContentContainerView>!
var matchedHost: UIHostingController<MatchedContainerView>!
init(presentationID: AnyHashable, content: Content, state: MatchedGeometryState, backgroundColor: UIColor) {
self.presentationID = presentationID
self.content = content
self.state = state
self.backgroundColor = backgroundColor
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .custom
transitioningDelegate = self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
contentHost = UIHostingController(rootView: ContentContainerView(content: content, state: state))
contentHost.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
contentHost.view.frame = view.bounds
contentHost.view.backgroundColor = backgroundColor
addChild(contentHost)
view.addSubview(contentHost.view)
contentHost.didMove(toParent: self)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
state.presentationID = presentationID
}
var currentPresentationSources: [AnyHashable: (() -> AnyView, CGRect)] {
Dictionary(uniqueKeysWithValues: state.sources.filter { $0.key.presentationID == presentationID }.map { ($0.key.matchedID, $0.value) })
}
func addMatchedHostingController() {
let sources = currentPresentationSources.map { (id: $0.key, view: $0.value.0) }
matchedHost = UIHostingController(rootView: MatchedContainerView(sources: sources, state: state))
matchedHost.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
matchedHost.view.frame = view.bounds
matchedHost.view.backgroundColor = .clear
matchedHost.view.layer.zPosition = 100
addChild(matchedHost)
view.addSubview(matchedHost.view)
matchedHost.didMove(toParent: self)
}
struct ContentContainerView: View {
let content: Content
let state: MatchedGeometryState
var body: some View {
content
.environmentObject(state)
}
}
struct MatchedContainerView: View {
let sources: [(id: AnyHashable, view: () -> AnyView)]
@ObservedObject var state: MatchedGeometryState
var body: some View {
ZStack {
ForEach(sources, id: \.id) { (id, view) in
matchedView(id: id, source: view)
}
}
}
@ViewBuilder
func matchedView(id: AnyHashable, source: () -> AnyView) -> some View {
if let frame = state.currentFrames[id],
let dest = state.destinations[id]?.0 {
ZStack {
source()
dest
.opacity(state.mode == .presenting ? (state.animating ? 1 : 0) : (state.animating ? 0 : 1))
}
.frame(width: frame.width, height: frame.height)
.position(x: frame.midX, y: frame.midY)
.ignoresSafeArea()
.animation(.interpolatingSpring(mass: Double(mass), stiffness: Double(state.mode == .presenting ? presentStiffness : dismissStiffness), damping: Double(state.mode == .presenting ? presentDamping : dismissDamping), initialVelocity: 0), value: frame)
}
}
}
// MARK: UIViewControllerTransitioningDelegate
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return MatchedGeometryPresentationAnimationController<Content>()
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return MatchedGeometryDismissAnimationController<Content>()
}
func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
return MatchedGeometryPresentationController(presentedViewController: presented, presenting: presenting)
}
}
class MatchedGeometryPresentationAnimationController<Content: View>: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.8
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let matchedGeomVC = transitionContext.viewController(forKey: .to) as! MatchedGeometryViewController<Content>
let container = transitionContext.containerView
// add the VC to the container, which kicks off layout out the content hosting controller
matchedGeomVC.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
matchedGeomVC.view.frame = container.bounds
container.addSubview(matchedGeomVC.view)
// layout out the content hosting controller and having enough destinations may take a while
// so listen for when it's ready, rather than trying to guess at the timing
let cancellable = matchedGeomVC.state.$destinations
.filter { destinations in matchedGeomVC.currentPresentationSources.allSatisfy { source in destinations.keys.contains(source.key) } }
.first()
.sink { destinations in
matchedGeomVC.addMatchedHostingController()
// setup the initial state for the animation
matchedGeomVC.matchedHost.view.isHidden = true
matchedGeomVC.state.mode = .presenting
matchedGeomVC.state.currentFrames = matchedGeomVC.currentPresentationSources.mapValues(\.1)
// wait one runloop iteration for the matched hosting controller to be setup
DispatchQueue.main.async {
matchedGeomVC.matchedHost.view.isHidden = false
matchedGeomVC.state.animating = true
// get the now-current destinations, in case they've changed since the sunk value was published
matchedGeomVC.state.currentFrames = matchedGeomVC.state.destinations.mapValues(\.1)
}
}
matchedGeomVC.contentHost.view.layer.opacity = 0
let spring = UISpringTimingParameters(mass: mass, stiffness: presentStiffness, damping: presentDamping, initialVelocity: .zero)
let animator = UIViewPropertyAnimator(duration: self.transitionDuration(using: transitionContext), timingParameters: spring)
animator.addAnimations {
matchedGeomVC.contentHost.view.layer.opacity = 1
}
animator.addCompletion { _ in
transitionContext.completeTransition(true)
matchedGeomVC.state.animating = false
matchedGeomVC.state.mode = .idle
matchedGeomVC.matchedHost?.view.removeFromSuperview()
matchedGeomVC.matchedHost?.removeFromParent()
cancellable.cancel()
}
animator.startAnimation()
}
}
class MatchedGeometryDismissAnimationController<Content: View>: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.8
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let matchedGeomVC = transitionContext.viewController(forKey: .from) as! MatchedGeometryViewController<Content>
// recreate the matched host b/c using the current destinations doesn't seem to update the existing one
matchedGeomVC.addMatchedHostingController()
matchedGeomVC.matchedHost.view.isHidden = true
matchedGeomVC.state.mode = .dismissing
matchedGeomVC.state.currentFrames = matchedGeomVC.state.destinations.mapValues(\.1)
DispatchQueue.main.async {
matchedGeomVC.matchedHost.view.isHidden = false
matchedGeomVC.state.animating = true
matchedGeomVC.state.currentFrames = matchedGeomVC.currentPresentationSources.mapValues(\.1)
}
let spring = UISpringTimingParameters(mass: mass, stiffness: dismissStiffness, damping: dismissDamping, initialVelocity: .zero)
let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), timingParameters: spring)
animator.addAnimations {
matchedGeomVC.contentHost.view.layer.opacity = 0
}
animator.addCompletion { _ in
transitionContext.completeTransition(true)
matchedGeomVC.state.animating = false
matchedGeomVC.state.mode = .idle
}
animator.startAnimation()
}
}
class MatchedGeometryPresentationController: UIPresentationController {
override func dismissalTransitionWillBegin() {
super.dismissalTransitionWillBegin()
delegate?.presentationControllerWillDismiss?(self)
}
}

View File

@ -1,62 +0,0 @@
//
// View+PresentViewController.swift
// MatchGeom
//
// Created by Shadowfacts on 4/24/23.
//
import SwiftUI
extension View {
func presentViewController(_ makeVC: @escaping () -> UIViewController, isPresented: Binding<Bool>) -> some View {
self
.background(
ViewControllerPresenter(makeVC: makeVC, isPresented: isPresented)
)
}
}
private struct ViewControllerPresenter: UIViewControllerRepresentable {
let makeVC: () -> UIViewController
@Binding var isPresented: Bool
func makeUIViewController(context: Context) -> UIViewController {
return UIViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
if isPresented {
if uiViewController.presentedViewController == nil {
let presented = makeVC()
presented.presentationController!.delegate = context.coordinator
uiViewController.present(presented, animated: true)
context.coordinator.didPresent = true
}
} else {
if context.coordinator.didPresent,
let presentedViewController = uiViewController.presentedViewController,
!presentedViewController.isBeingDismissed {
uiViewController.dismiss(animated: true)
context.coordinator.didPresent = false
}
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(isPresented: $isPresented)
}
class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
@Binding var isPresented: Bool
var didPresent = false
init(isPresented: Binding<Bool>) {
self._isPresented = isPresented
}
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
isPresented = false
didPresent = false
}
}
}

View File

@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "Pachyderm",
platforms: [
.iOS(.v16),
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.

View File

@ -25,27 +25,30 @@ public struct Client: Sendable {
public var timeoutInterval: TimeInterval = 60
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
formatter.timeZone = TimeZone(abbreviation: "UTC")
formatter.locale = Locale(identifier: "en_US_POSIX")
let iso8601 = ISO8601DateFormatter()
return formatter
}()
private static let iso8601Formatter = ISO8601DateFormatter()
private static func decodeDate(string: String) -> Date? {
// for the next time mastodon accidentally changes date formats >.>
return dateFormatter.date(from: string) ?? iso8601Formatter.date(from: string)
}
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom({ (decoder) in
let container = try decoder.singleValueContainer()
let str = try container.decode(String.self)
// for the next time mastodon accidentally changes date formats >.>
if let date = formatter.date(from: str) {
return date
} else if let date = iso8601.date(from: str) {
if let date = Self.decodeDate(string: str) {
return date
} else {
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
}
})
return decoder
}()
@ -105,18 +108,28 @@ public struct Client: Sendable {
return task
}
private func error(from response: HTTPURLResponse) -> ErrorType {
if response.statusCode == 429,
let date = response.value(forHTTPHeaderField: "X-RateLimit-Reset").flatMap(Self.decodeDate) {
return .rateLimited(date)
} else {
return .unexpectedStatus(response.statusCode)
}
}
@discardableResult
public func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
return try await withCheckedThrowingContinuation { continuation in
public func run<Result: Sendable>(_ request: Request<Result>) async throws(Error) -> (Result, Pagination?) {
let response = await withCheckedContinuation { continuation in
run(request) { response in
switch response {
case .failure(let error):
continuation.resume(throwing: error)
case .success(let result, let pagination):
continuation.resume(returning: (result, pagination))
}
continuation.resume(returning: response)
}
}
switch response {
case .failure(let error):
throw error
case .success(let result, let pagination):
return (result, pagination)
}
}
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
@ -575,6 +588,8 @@ extension Client {
return "Invalid Model"
case .mastodonError(let code, let error):
return "Server Error (\(code)): \(error)"
case .rateLimited(let reset):
return "Rate Limited Until \(reset.formatted(date: .omitted, time: .standard))"
}
}
}
@ -585,6 +600,7 @@ extension Client {
case invalidResponse
case invalidModel(Swift.Error)
case mastodonError(Int, String)
case rateLimited(Date)
}
enum NodeInfoError: LocalizedError {

View File

@ -25,6 +25,7 @@ public struct Card: Codable, Sendable {
public let blurhash: String?
/// Only present when returned from the trending links endpoint
public let history: [History]?
public let authors: [Author]
public init(
url: WebURL,
@ -40,7 +41,8 @@ public struct Card: Codable, Sendable {
width: Int? = nil,
height: Int? = nil,
blurhash: String? = nil,
history: [History]? = nil
history: [History]? = nil,
authors: [Author] = []
) {
self.url = url
self.title = title
@ -56,6 +58,7 @@ public struct Card: Codable, Sendable {
self.height = height
self.blurhash = blurhash
self.history = history
self.authors = authors
}
public init(from decoder: Decoder) throws {
@ -75,6 +78,7 @@ public struct Card: Codable, Sendable {
self.height = try? container.decodeIfPresent(Int.self, forKey: .height)
self.blurhash = try? container.decodeIfPresent(String.self, forKey: .blurhash)
self.history = try? container.decodeIfPresent([History].self, forKey: .history)
self.authors = try container.decodeIfPresent([Author].self, forKey: .authors) ?? []
}
public func encode(to encoder: Encoder) throws {
@ -103,6 +107,7 @@ public struct Card: Codable, Sendable {
case height
case blurhash
case history
case authors
}
}
@ -114,3 +119,29 @@ extension Card {
case rich
}
}
extension Card {
public struct Author: Decodable, Sendable {
public let name: String
public let url: WebURL?
public let account: Account?
enum CodingKeys: CodingKey {
case name
case url
case account
}
public init(from decoder: any Decoder) throws {
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
self.name = try container.decode(String.self, forKey: .name)
let s = try container.decode(String.self, forKey: .url)
if s.isEmpty {
self.url = nil
} else {
self.url = WebURL(s)
}
self.account = try container.decodeIfPresent(Account.self, forKey: .account)
}
}
}

View File

@ -22,7 +22,12 @@ public struct Emoji: Codable, Sendable {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.shortcode = try container.decode(String.self, forKey: .shortcode)
self.url = try container.decode(WebURL.self, forKey: .url)
do {
self.url = try container.decode(WebURL.self, forKey: .url)
} catch {
let s = try? container.decode(String.self, forKey: .url)
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath + [CodingKeys.url], debugDescription: "Could not decode URL '\(s ?? "<failed to decode string>")'", underlyingError: error))
}
self.staticURL = try container.decode(WebURL.self, forKey: .staticURL)
self.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker)
self.category = try container.decodeIfPresent(String.self, forKey: .category)

View File

@ -11,9 +11,15 @@ import Foundation
public struct NodeInfo: Decodable, Sendable, Equatable {
public let version: String
public let software: Software
public let metadata: Metadata
public struct Software: Decodable, Sendable, Equatable {
public let name: String
public let version: String
}
public struct Metadata: Decodable, Sendable, Equatable {
public let nodeName: String
public let nodeDescription: String
}
}

View File

@ -197,72 +197,72 @@ class NotificationGroupTests: XCTestCase {
func testGroupSimple() {
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeA2], only: [.favourite])
XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2])!])
XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!])
}
func testGroupWithOtherGroupableInBetween() {
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeB, likeA2], only: [.favourite])
XCTAssertEqual(groups, [
NotificationGroup(notifications: [likeA1, likeA2])!,
NotificationGroup(notifications: [likeB])!,
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!,
NotificationGroup(notifications: [likeB], kind: .favourite)!,
])
}
func testDontGroupWithUngroupableInBetween() {
let groups = NotificationGroup.createGroups(notifications: [likeA1, mentionB, likeA2], only: [.favourite])
XCTAssertEqual(groups, [
NotificationGroup(notifications: [likeA1])!,
NotificationGroup(notifications: [mentionB])!,
NotificationGroup(notifications: [likeA2])!,
NotificationGroup(notifications: [likeA1], kind: .favourite)!,
NotificationGroup(notifications: [mentionB], kind: .mention)!,
NotificationGroup(notifications: [likeA2], kind: .favourite)!,
])
}
func testMergeSimpleGroups() {
let group1 = NotificationGroup(notifications: [likeA1])!
let group2 = NotificationGroup(notifications: [likeA2])!
let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)!
let group2 = NotificationGroup(notifications: [likeA2], kind: .favourite)!
let merged = NotificationGroup.mergeGroups(first: [group1], second: [group2], only: [.favourite])
XCTAssertEqual(merged, [
NotificationGroup(notifications: [likeA1, likeA2])!
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!
])
}
func testMergeGroupsWithOtherGroupableInBetween() {
let group1 = NotificationGroup(notifications: [likeA1])!
let group2 = NotificationGroup(notifications: [likeB])!
let group3 = NotificationGroup(notifications: [likeA2])!
let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)!
let group2 = NotificationGroup(notifications: [likeB], kind: .favourite)!
let group3 = NotificationGroup(notifications: [likeA2], kind: .favourite)!
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
XCTAssertEqual(merged, [
NotificationGroup(notifications: [likeA1, likeA2])!,
NotificationGroup(notifications: [likeB])!,
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!,
NotificationGroup(notifications: [likeB], kind: .favourite)!,
])
let merged2 = NotificationGroup.mergeGroups(first: [group1], second: [group2, group3], only: [.favourite])
XCTAssertEqual(merged2, [
NotificationGroup(notifications: [likeA1, likeA2])!,
NotificationGroup(notifications: [likeB])!,
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!,
NotificationGroup(notifications: [likeB], kind: .favourite)!,
])
let group4 = NotificationGroup(notifications: [likeB2])!
let group5 = NotificationGroup(notifications: [mentionB])!
let group4 = NotificationGroup(notifications: [likeB2], kind: .favourite)!
let group5 = NotificationGroup(notifications: [mentionB], kind: .mention)!
let merged3 = NotificationGroup.mergeGroups(first: [group1, group5, group2], second: [group4, group3], only: [.favourite])
print(merged3.count)
XCTAssertEqual(merged3, [
group1,
group5,
NotificationGroup(notifications: [likeB, likeB2]),
NotificationGroup(notifications: [likeB, likeB2], kind: .favourite),
group3
])
}
func testDontMergeWithUngroupableInBetween() {
let group1 = NotificationGroup(notifications: [likeA1])!
let group2 = NotificationGroup(notifications: [mentionB])!
let group3 = NotificationGroup(notifications: [likeA2])!
let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)!
let group2 = NotificationGroup(notifications: [mentionB], kind: .mention)!
let group3 = NotificationGroup(notifications: [likeA2], kind: .favourite)!
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
XCTAssertEqual(merged, [
NotificationGroup(notifications: [likeA1])!,
NotificationGroup(notifications: [mentionB])!,
NotificationGroup(notifications: [likeA2])!,
NotificationGroup(notifications: [likeA1], kind: .favourite)!,
NotificationGroup(notifications: [mentionB], kind: .mention)!,
NotificationGroup(notifications: [likeA2], kind: .favourite)!,
])
}

View File

@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "PushNotifications",
platforms: [
.iOS(.v16),
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.

View File

@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "TTTKit",
platforms: [
.iOS(.v16),
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.

View File

@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "TuskerComponents",
platforms: [
.iOS(.v16),
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.

View File

@ -9,14 +9,21 @@ import SwiftUI
public struct AsyncPicker<V: Hashable, Content: View>: View {
let titleKey: LocalizedStringKey
#if !os(visionOS)
@available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent")
let labelHidden: Bool
#endif
let alignment: Alignment
@Binding var value: V
let onChange: (V) async -> Bool
let content: Content
@State private var isLoading = false
public init(_ titleKey: LocalizedStringKey, alignment: Alignment = .center, value: Binding<V>, onChange: @escaping (V) async -> Bool, @ViewBuilder content: () -> Content) {
public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, alignment: Alignment = .center, value: Binding<V>, onChange: @escaping (V) async -> Bool, @ViewBuilder content: () -> Content) {
self.titleKey = titleKey
#if !os(visionOS)
self.labelHidden = labelHidden
#endif
self.alignment = alignment
self._value = value
self.onChange = onChange
@ -24,9 +31,25 @@ public struct AsyncPicker<V: Hashable, Content: View>: View {
}
public var body: some View {
#if os(visionOS)
LabeledContent(titleKey) {
picker
}
#else
if #available(iOS 16.0, *) {
LabeledContent(titleKey) {
picker
}
} else if labelHidden {
picker
} else {
HStack {
Text(titleKey)
Spacer()
picker
}
}
#endif
}
private var picker: some View {

View File

@ -10,19 +10,42 @@ import SwiftUI
public struct AsyncToggle: View {
let titleKey: LocalizedStringKey
#if !os(visionOS)
@available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent")
let labelHidden: Bool
#endif
@Binding var mode: Mode
let onChange: (Bool) async -> Bool
public init(_ titleKey: LocalizedStringKey, mode: Binding<Mode>, onChange: @escaping (Bool) async -> Bool) {
public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, mode: Binding<Mode>, onChange: @escaping (Bool) async -> Bool) {
self.titleKey = titleKey
#if !os(visionOS)
self.labelHidden = labelHidden
#endif
self._mode = mode
self.onChange = onChange
}
public var body: some View {
#if os(visionOS)
LabeledContent(titleKey) {
toggleOrSpinner
}
#else
if #available(iOS 16.0, *) {
LabeledContent(titleKey) {
toggleOrSpinner
}
} else if labelHidden {
toggleOrSpinner
} else {
HStack {
Text(titleKey)
Spacer()
toggleOrSpinner
}
}
#endif
}
@ViewBuilder

View File

@ -47,7 +47,9 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
private func makeConfiguration() -> UIButton.Configuration {
var config = UIButton.Configuration.borderless()
config.indicator = .popup
if #available(iOS 16.0, *) {
config.indicator = .popup
}
if buttonStyle.hasIcon {
config.image = selectedOption.image
}
@ -57,6 +59,7 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
#if targetEnvironment(macCatalyst)
config.macIdiomStyle = .bordered
#endif
config.contentInsets = .zero
return config
}

View File

@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "TuskerPreferences",
platforms: [
.iOS(.v16),
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.

View File

@ -8,8 +8,8 @@
import Foundation
import Pachyderm
struct StatusContentTypeKey: MigratablePreferenceKey {
static var defaultValue: StatusContentType { .plain }
public struct StatusContentTypeKey: MigratablePreferenceKey {
public static var defaultValue: StatusContentType { .plain }
}
struct FeatureFlagsKey: MigratablePreferenceKey, CustomCodablePreferenceKey {

View File

@ -16,8 +16,8 @@ public struct AccentColorKey: MigratablePreferenceKey {
public static var defaultValue: AccentColor { .default }
}
struct AvatarStyleKey: MigratablePreferenceKey {
static var defaultValue: AvatarStyle { .roundRect }
public struct AvatarStyleKey: MigratablePreferenceKey {
public static var defaultValue: AvatarStyle { .roundRect }
}
struct LeadingSwipeActionsKey: MigratablePreferenceKey {

View File

@ -9,6 +9,6 @@ import Foundation
public enum FeatureFlag: String, Codable {
case iPadBrowserNavigation = "ipad-browser-navigation"
case composeRewrite = "compose-rewrite"
case composeTextAttributes = "compose-text-attributes"
case pushNotifCustomEmoji = "push-notif-custom-emoji"
}

View File

@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "UserAccounts",
platforms: [
.iOS(.v16),
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.

View File

@ -12,6 +12,7 @@ import TuskerComponents
import WebURLFoundationExtras
import Combine
import TuskerPreferences
import Pachyderm
class ShareHostingController: UIHostingController<ShareHostingController.View> {
private static func fetchAvatar(_ url: URL) async -> UIImage? {
@ -27,48 +28,21 @@ class ShareHostingController: UIHostingController<ShareHostingController.View> {
return await image.byPreparingThumbnail(ofSize: CGSize(width: size, height: size)) ?? image
}
private let controller: ComposeController
private var mastodonContextPublisher: CurrentValueSubject<ShareMastodonContext, Never>
private var cancellables = Set<AnyCancellable>()
@ObservableObjectBox private var config = ComposeUIConfig()
private let accountSwitchingState: AccountSwitchingState
private let state: ComposeViewState
init(draft: Draft, mastodonContext: ShareMastodonContext) {
let mastodonContextPublisher = CurrentValueSubject<ShareMastodonContext, Never>(mastodonContext)
self.mastodonContextPublisher = mastodonContextPublisher
controller = ComposeController(
draft: draft,
config: ComposeUIConfig(),
mastodonController: mastodonContext,
fetchAvatar: Self.fetchAvatar,
fetchAttachment: { _ in fatalError("edits aren't allowed in share sheet, can't fetch existing attachment") },
fetchStatus: { _ in fatalError("replies aren't allowed in share sheet") },
displayNameLabel: { account, style, _ in AnyView(Text(account.displayName).font(.system(style))) },
currentAccountContainerView: { AnyView(SwitchAccountContainerView(content: $0, mastodonContextPublisher: mastodonContextPublisher)) },
replyContentView: { _, _ in fatalError("replies aren't allowed in share sheet") },
emojiImageView: {
AnyView(AsyncImage(url: URL($0.url)!) {
$0
.resizable()
.aspectRatio(contentMode: .fit)
} placeholder: {
Image(systemName: "smiley.fill")
})
}
self.accountSwitchingState = AccountSwitchingState(mastodonContext: mastodonContext)
self.state = ComposeViewState(draft: draft)
let rootView = View(
accountSwitchingState: self.accountSwitchingState,
state: state,
config: _config
)
super.init(rootView: View(controller: controller))
super.init(rootView: rootView)
updateConfig()
mastodonContextPublisher
.sink { [unowned self] in
self.controller.mastodonController = $0
self.controller.draft.accountID = $0.accountInfo!.id
}
.store(in: &cancellables)
mastodonContextPublisher
.flatMap { $0.$ownAccount }
.sink { [unowned self] in self.controller.currentAccount = $0 }
.store(in: &cancellables)
}
required init?(coder aDecoder: NSCoder) {
@ -84,19 +58,15 @@ class ShareHostingController: UIHostingController<ShareHostingController.View> {
config.groupedBackgroundColor = Color(uiColor: .appGroupedBackground)
config.groupedCellBackgroundColor = Color(uiColor: .appGroupedCellBackground)
config.fillColor = Color(uiColor: .appFill)
switch Preferences.shared.avatarStyle {
case .roundRect:
config.avatarStyle = .roundRect
case .circle:
config.avatarStyle = .circle
}
config.useTwitterKeyboard = Preferences.shared.useTwitterKeyboard
config.contentType = Preferences.shared.statusContentType
config.requireAttachmentDescriptions = Preferences.shared.requireAttachmentDescriptions
config.dismiss = { [unowned self] in self.dismiss(mode: $0) }
config.fetchAvatar = Self.fetchAvatar
config.displayNameLabel = { account, style, _ in
// TODO: move AccountDisplayNameView to TuskerComponents and use that here as well
AnyView(Text(account.displayName).font(.system(style)))
}
controller.config = config
self.config = config
}
private func dismiss(mode: DismissMode) {
@ -104,16 +74,41 @@ class ShareHostingController: UIHostingController<ShareHostingController.View> {
switch mode {
case .cancel:
extensionContext.cancelRequest(withError: Error.cancelled)
case .post:
case .post(_), .edit(_):
extensionContext.completeRequest(returningItems: nil)
}
}
struct View: SwiftUI.View {
let controller: ComposeController
@ObservedObject var accountSwitchingState: AccountSwitchingState
let state: ComposeViewState
@ObservedObject @ObservableObjectBox private var config: ComposeUIConfig
@State private var currentAccount: Account?
fileprivate init(
accountSwitchingState: AccountSwitchingState,
state: ComposeViewState,
config: ObservableObjectBox<ComposeUIConfig>
) {
self.accountSwitchingState = accountSwitchingState
self.state = state
self._config = ObservedObject(wrappedValue: config)
self._currentAccount = State(wrappedValue: accountSwitchingState.currentAccount)
}
var body: some SwiftUI.View {
ControllerView(controller: { controller })
ComposeView(
state: state,
mastodonController: accountSwitchingState.mastodonContext,
currentAccount: currentAccount,
config: config
)
.onReceive(accountSwitchingState.$mastodonContext) {
state.draft.accountID = $0.accountInfo!.id
}
.onReceive(accountSwitchingState.currentAccountPublisher) {
currentAccount = $0
}
}
}
@ -122,6 +117,16 @@ class ShareHostingController: UIHostingController<ShareHostingController.View> {
}
}
// TODO: put this somewhere instead of just copying it from ComposeHostingController
@MainActor
@propertyWrapper
private final class ObservableObjectBox<T>: ObservableObject {
@Published var wrappedValue: T
init(wrappedValue: T) {
self.wrappedValue = wrappedValue
}
}
// todo: shouldn't just copy this from the main Colors.swift
extension UIColor {
static let appBackground = UIColor { traitCollection in

View File

@ -45,17 +45,8 @@ final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Send
// MARK: ComposeMastodonContext
func run<Result: Decodable & Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
return try await withCheckedThrowingContinuation({ continuation in
client.run(request) { response in
switch response {
case .success(let result, let pagination):
continuation.resume(returning: (result, pagination))
case .failure(let error):
continuation.resume(throwing: error)
}
}
})
func run<Result: Decodable & Sendable>(_ request: Request<Result>) async throws(Client.Error) -> (Result, Pagination?) {
return try await client.run(request)
}
@MainActor
@ -81,9 +72,6 @@ final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Send
return []
}
func storeCreatedStatus(_ status: Status) {
}
func fetchStatus(id: String) -> (any StatusProtocol)? {
return nil
}

View File

@ -13,9 +13,28 @@ import Pachyderm
import Combine
import ComposeUI
@MainActor
class AccountSwitchingState: ObservableObject {
@Published var mastodonContext: ShareMastodonContext
var currentAccount: Account? {
mastodonContext.ownAccount
}
var currentAccountPublisher: some Publisher<Account, Never> {
$mastodonContext
.flatMap { $0.$ownAccount }
.compactMap { $0 }
}
init(mastodonContext: ShareMastodonContext) {
self.mastodonContext = mastodonContext
}
}
struct SwitchAccountContainerView: View {
let content: AnyView
let mastodonContextPublisher: CurrentValueSubject<ShareMastodonContext, Never>
@ObservedObject var state: AccountSwitchingState
var accounts: [UserAccountInfo] {
UserAccountsManager.shared.accounts
@ -50,7 +69,7 @@ struct SwitchAccountContainerView: View {
}
private func selectAccount(_ account: UserAccountInfo) {
mastodonContextPublisher.send(ShareMastodonContext(accountInfo: account))
state.mastodonContext = ShareMastodonContext(accountInfo: account)
}
}

View File

@ -141,6 +141,7 @@
D64B96812BC3279D002C8990 /* PrefsAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96802BC3279D002C8990 /* PrefsAccountView.swift */; };
D64B96842BC3893C002C8990 /* PushSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */; };
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */; };
D6531DEE246B81C9000F9538 /* GifvPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvPlayerView.swift */; };
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
@ -203,20 +204,16 @@
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */; };
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */; };
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */; };
D69261232BB3AEFB0023152C /* VideoOverlayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69261222BB3AEFB0023152C /* VideoOverlayViewController.swift */; };
D69261272BB3BA610023152C /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69261262BB3BA610023152C /* Box.swift */; };
D6934F2C2BA7AD32002B1C8D /* GalleryVC in Frameworks */ = {isa = PBXBuildFile; productRef = D6934F2B2BA7AD32002B1C8D /* GalleryVC */; };
D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */; };
D6934F302BA7AF91002B1C8D /* ImageGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */; };
D6934F322BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F312BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift */; };
D6934F302BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F2F2BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift */; };
D6934F342BA8D65A002B1C8D /* ImageGalleryDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F332BA8D65A002B1C8D /* ImageGalleryDataSource.swift */; };
D6934F362BA8E020002B1C8D /* GifvGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F352BA8E020002B1C8D /* GifvGalleryContentViewController.swift */; };
D6934F382BA8E2B7002B1C8D /* GifvController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F372BA8E2B7002B1C8D /* GifvController.swift */; };
D6934F3A2BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F392BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift */; };
D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */; };
D6934F3C2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3B2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift */; };
D6934F3E2BAA19D5002B1C8D /* ImageActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3D2BAA19D5002B1C8D /* ImageActivityItemSource.swift */; };
D6934F402BAA19EC002B1C8D /* VideoActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3F2BAA19EC002B1C8D /* VideoActivityItemSource.swift */; };
D6934F422BAC7D6E002B1C8D /* VideoControlsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F412BAC7D6E002B1C8D /* VideoControlsViewController.swift */; };
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */; };
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */; };
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5823FE24300061E07D /* InteractivePushTransition.swift */; };
@ -302,6 +299,7 @@
D6C4532D2BCB86AC00E26A0E /* AppearancePrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4532C2BCB86AC00E26A0E /* AppearancePrefsView.swift */; };
D6C4532F2BCB873400E26A0E /* MockStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4532E2BCB873400E26A0E /* MockStatusView.swift */; };
D6C453372BCE1CEF00E26A0E /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D6C4532A2BCAD7F900E26A0E /* PrivacyInfo.xcprivacy */; };
D6C5F0642D6AEC0A0019F85B /* MastodonController+Resolve.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C5F0632D6AEC050019F85B /* MastodonController+Resolve.swift */; };
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; };
@ -336,7 +334,7 @@
D6D79F592A13293200AB2315 /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F582A13293200AB2315 /* BackgroundManager.swift */; };
D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; };
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; };
D6D9498F298EB79400C59229 /* CopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLabel.swift */; };
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; };
D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; };
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; };
D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */; };
@ -368,6 +366,7 @@
D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */; };
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; };
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; };
D6F3BE142D6E133E00F5E92D /* StatusCardMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F3BE132D6E133C00F5E92D /* StatusCardMO.swift */; };
D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */; };
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54F291F058600F496A8 /* CreateListService.swift */; };
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A551291F098700F496A8 /* RenameListService.swift */; };
@ -571,6 +570,7 @@
D64B96802BC3279D002C8990 /* PrefsAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsAccountView.swift; sourceTree = "<group>"; };
D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscriptionView.swift; sourceTree = "<group>"; };
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = "<group>"; };
D6531DED246B81C9000F9538 /* GifvPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvPlayerView.swift; sourceTree = "<group>"; };
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; };
@ -637,19 +637,15 @@
D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileNoContentCollectionViewCell.swift; sourceTree = "<group>"; };
D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderMovedOverlayView.swift; sourceTree = "<group>"; };
D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityHandlingContext.swift; sourceTree = "<group>"; };
D69261222BB3AEFB0023152C /* VideoOverlayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoOverlayViewController.swift; sourceTree = "<group>"; };
D69261262BB3BA610023152C /* Box.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Box.swift; sourceTree = "<group>"; };
D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAttachmentsGalleryDataSource.swift; sourceTree = "<group>"; };
D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryContentViewController.swift; sourceTree = "<group>"; };
D6934F312BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingGalleryContentViewController.swift; sourceTree = "<group>"; };
D6934F2F2BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrayscalableImageGalleryContentViewController.swift; sourceTree = "<group>"; };
D6934F332BA8D65A002B1C8D /* ImageGalleryDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryDataSource.swift; sourceTree = "<group>"; };
D6934F352BA8E020002B1C8D /* GifvGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvGalleryContentViewController.swift; sourceTree = "<group>"; };
D6934F372BA8E2B7002B1C8D /* GifvController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvController.swift; sourceTree = "<group>"; };
D6934F392BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FallbackGalleryContentViewController.swift; sourceTree = "<group>"; };
D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoGalleryContentViewController.swift; sourceTree = "<group>"; };
D6934F3B2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrayscalableVideoGalleryContentViewController.swift; sourceTree = "<group>"; };
D6934F3D2BAA19D5002B1C8D /* ImageActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageActivityItemSource.swift; sourceTree = "<group>"; };
D6934F3F2BAA19EC002B1C8D /* VideoActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoActivityItemSource.swift; sourceTree = "<group>"; };
D6934F412BAC7D6E002B1C8D /* VideoControlsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoControlsViewController.swift; sourceTree = "<group>"; };
D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = "<group>"; };
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedNavigationViewController.swift; sourceTree = "<group>"; };
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractivePushTransition.swift; sourceTree = "<group>"; };
@ -691,7 +687,6 @@
D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = "<group>"; };
D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentHistoryTokenStore.swift; sourceTree = "<group>"; };
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = MatchedGeometryPresentation; sourceTree = "<group>"; };
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = "<group>"; };
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = "<group>"; };
D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusCollectionViewCell.swift; sourceTree = "<group>"; };
@ -735,6 +730,7 @@
D6C4532A2BCAD7F900E26A0E /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
D6C4532C2BCB86AC00E26A0E /* AppearancePrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePrefsView.swift; sourceTree = "<group>"; };
D6C4532E2BCB873400E26A0E /* MockStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStatusView.swift; sourceTree = "<group>"; };
D6C5F0632D6AEC050019F85B /* MastodonController+Resolve.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonController+Resolve.swift"; sourceTree = "<group>"; };
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; };
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; };
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
@ -776,7 +772,7 @@
D6D79F582A13293200AB2315 /* BackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundManager.swift; sourceTree = "<group>"; };
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 /* CopyableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLabel.swift; sourceTree = "<group>"; };
D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; };
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = "<group>"; };
D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewController.swift; sourceTree = "<group>"; };
D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfilesViewController.swift; sourceTree = "<group>"; };
@ -811,6 +807,7 @@
D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokenSuggestionCollectionViewCell.swift; sourceTree = "<group>"; };
D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = "<group>"; };
D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = "<group>"; };
D6F3BE132D6E133C00F5E92D /* StatusCardMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardMO.swift; sourceTree = "<group>"; };
D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBackgroundConfiguration+AppColors.swift"; sourceTree = "<group>"; };
D6F6A54F291F058600F496A8 /* CreateListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateListService.swift; sourceTree = "<group>"; };
D6F6A551291F098700F496A8 /* RenameListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameListService.swift; sourceTree = "<group>"; };
@ -899,13 +896,9 @@
children = (
D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */,
D6934F332BA8D65A002B1C8D /* ImageGalleryDataSource.swift */,
D6934F312BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift */,
D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */,
D6934F2F2BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift */,
D6934F352BA8E020002B1C8D /* GifvGalleryContentViewController.swift */,
D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */,
D69261222BB3AEFB0023152C /* VideoOverlayViewController.swift */,
D6934F412BAC7D6E002B1C8D /* VideoControlsViewController.swift */,
D6934F392BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift */,
D6934F3B2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift */,
);
path = Gallery;
sourceTree = "<group>";
@ -1041,6 +1034,7 @@
children = (
D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */,
D60E2F232442372B005F8713 /* StatusMO.swift */,
D6F3BE132D6E133C00F5E92D /* StatusCardMO.swift */,
D60E2F252442372B005F8713 /* AccountMO.swift */,
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */,
D6B9366E2828452F00237D0E /* SavedHashtag.swift */,
@ -1251,7 +1245,6 @@
D6BD395C29B789D5005FFD2B /* TuskerComponents */,
D6BD395729B6441F005FFD2B /* ComposeUI */,
D6CA6ED029EF6060003EC5DF /* TuskerPreferences */,
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */,
D642E83D2BA7AD0F004BFD6A /* GalleryVC */,
D65A26242BC39A02005EB5D8 /* PushNotifications */,
);
@ -1488,7 +1481,7 @@
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
D620483523D38075008A63EF /* ContentTextView.swift */,
D6D9498E298EB79400C59229 /* CopyableLabel.swift */,
D6D9498E298EB79400C59229 /* CopyableLable.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */,
@ -1625,6 +1618,7 @@
D6DEBA8C2B6579830008629A /* MainThreadBox.swift */,
D6B81F432560390300F6E31D /* MenuController.swift */,
D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */,
D6895DE828D962C2006341DA /* TimelineLikeController.swift */,
@ -1752,6 +1746,7 @@
children = (
D6F953EF21251A2900CF0F2B /* MastodonController.swift */,
D630C3C92BC59FF500208903 /* MastodonController+Push.swift */,
D6C5F0632D6AEC050019F85B /* MastodonController+Resolve.swift */,
D61ABEFD28F1C92600B29151 /* FavoriteService.swift */,
D621733228F1D5ED004C7DB1 /* ReblogService.swift */,
D6F6A54F291F058600F496A8 /* CreateListService.swift */,
@ -2122,7 +2117,6 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D6934F422BAC7D6E002B1C8D /* VideoControlsViewController.swift in Sources */,
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */,
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */,
@ -2169,11 +2163,11 @@
D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */,
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */,
D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */,
D69261232BB3AEFB0023152C /* VideoOverlayViewController.swift in Sources */,
D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */,
D6934F3C2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift in Sources */,
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
D698F46B2BD079F00054DB14 /* AnnouncementListRow.swift in Sources */,
D6C5F0642D6AEC0A0019F85B /* MastodonController+Resolve.swift in Sources */,
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */,
D6934F362BA8E020002B1C8D /* GifvGalleryContentViewController.swift in Sources */,
@ -2190,7 +2184,6 @@
D6D94955298963A900C59229 /* Colors.swift in Sources */,
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */,
D6934F322BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift in Sources */,
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
D64A50482C739DEA009D7193 /* BaseMainTabBarViewController.swift in Sources */,
@ -2218,7 +2211,6 @@
D630C3CC2BC5FD4600208903 /* GetAuthorizationTokenService.swift in Sources */,
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */,
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
D6934F3A2BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift in Sources */,
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */,
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
@ -2290,6 +2282,7 @@
D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */,
D659F36229541065002D944A /* TTTView.swift in Sources */,
D65B4B6229771A3F00DABDFB /* FetchStatusService.swift in Sources */,
D6F3BE142D6E133E00F5E92D /* StatusCardMO.swift in Sources */,
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */,
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
D6945C2F23AC47C3005C403C /* SavedDataManager.swift in Sources */,
@ -2303,7 +2296,7 @@
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */,
D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */,
D6D9498F298EB79400C59229 /* CopyableLabel.swift in Sources */,
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */,
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
@ -2341,7 +2334,7 @@
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
D64A50BE2C752247009D7193 /* AdaptableNavigationController.swift in Sources */,
D6934F302BA7AF91002B1C8D /* ImageGalleryContentViewController.swift in Sources */,
D6934F302BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift in Sources */,
D6934F3E2BAA19D5002B1C8D /* ImageActivityItemSource.swift in Sources */,
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */,
@ -2387,6 +2380,7 @@
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */,
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */,
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */,
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */,
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
@ -2543,7 +2537,6 @@
INFOPLIST_FILE = NotificationExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2576,7 +2569,6 @@
INFOPLIST_FILE = NotificationExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2608,7 +2600,6 @@
INFOPLIST_FILE = NotificationExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2699,7 +2690,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2743,7 +2733,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = TuskerUITests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2766,7 +2756,6 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2794,7 +2783,6 @@
INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2823,7 +2811,6 @@
INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2852,7 +2839,6 @@
INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -3008,7 +2994,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -3041,7 +3026,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -3106,7 +3090,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = TuskerUITests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -3126,7 +3110,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = TuskerUITests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -3149,7 +3133,6 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -3174,7 +3157,6 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

Some files were not shown because too many files have changed in this diff Show More