Compare commits

..

No commits in common. "06f761bf56b188458b3b05f93abd602e7493f373" and "ee630cf9df26915787c354b7cbaf8dafac3e7d7b" have entirely different histories.

319 changed files with 10497 additions and 13143 deletions

View File

@ -1,57 +1,3 @@
## 2023.5
This update adds new several Compose-related features, including the ability to edit posts, a share sheet extension, and a post language picker. See below for the full list of improvements and bugfixes.
Features/Improvements:
- Edit posts
- Indicate edited posts in timestamp
- Show post edit history from Conversation screen
- Add Share Sheet extension
- Add expanded attachment view on Compose screen
- Add an attachment, select the description text field, then tap the expand button
- Expanded view allows you to see the attachment while writing the description
- Allows playing back videos while writing description
- iOS 16: Allows zooming in to the attachment
- Add language picker to the Compose screen
- Improve Compose screen ducking behavior
- Show reblogger's avatar on reblogged posts
- Use system photo picker instead of custom interface
- Improve hashtag search UI in Customize Timelines
- Improve status collapse/expand animation on Notifications screen
- Apply filters to Notifications screen
- Improve performance when scrolling through timeline
- Improve error messages when editing filters
- Change favorite/reblog button order to match Mastodon UI
- Gracefully handle unknown attachment types
- iPadOS: Persist sidebar visibility across
Bugfixes:
- Fix scroll-to-top not working in in-app Safari
- Fix inaccruate titles in certain error popups
- Fix error decoding post HTML
- Fix replied-to account not being the first @-mention
- Fix "No Content" message on profiles using wrong background color
- Fix reblogged posts appearing in Bookmarks
- Fix spurious errors when loading timeline
- Fix crash when displaying certain profiles
- Fix crash when the server returns invalid notifications
- Fix link previews not appearing in Notifications
- Fix Notifications screen taking a long time to load
- Fix deleted posts not being removed from Notifications screen
- Fix crashes when switching between sidebar/tab-bar modes
- Fix instance features not being detected on IDNA domains
- Fix list/hashtag timelines missing controls when opened in new window
- Fix reblog button being enabled on the user's own direct posts
- Fix main post in Conversation flickering
- Fix link card images not loading on Mastodon
- Fix crash when editing filter with the Hide action
- Fix certain remote status links not being resolved
- Fix Handoff to iPad/Mac presenting new screen modally
- GoToSocial: Fix decoding certain posts
- Calckey: Fix decoding certain posts
- iPadOS: Fix Compose window lacking a title
- iPadOS: Fix keyboard focus highlight not showing
- macOS: Fix sidebar keyboard shortcuts not working
## 2023.4 ## 2023.4
Features/Improvements: Features/Improvements:
- Add preference for non-pure-black dark mode - Add preference for non-pure-black dark mode

View File

@ -1,161 +1,5 @@
# Changelog # Changelog
## 2023.5 (98)
Bugfixes:
- Fix broken animation when opening/closing expanded attachment view on Compose screen
## 2023.5 (97)
Features/Improvements:
- Change favorite/reblog button order to match Mastodon
- Use QuickLook as a fallback for uknown attachment types
Bugfixes:
- Fix crash when adding drawing attachment
## 2023.5 (96)
Features/Improvements:
- Resolve Mastodon's remote status links
Bugfixes:
- Fix handoff to iPad/Mac presenting new screen modally rather than navigating
- Fix crash if timeline gap cell is accessibility-activated after leaking
- Fix various crashes when multiple Compose/Drafts screens are opened
- Delete orphaned draft attachments
- Fix deleted posts not getting removed from Notifications screen
- Fix replied-to status not changing when selecting draft
## 2023.5 (94)
Features/Improvements:
- Apply filters to Notifications screen
Bugfixes:
- Fix editing posts not working on Akkoma
- Fix editing Markdown/HTML posts
- Fix crash when editing filter with Hide action
## 2023.5 (91)
Features/Improvements:
- Improve performance when scrolling through timeline
- Improve error messages when editing filters
- Enable editing posts on Pleroma 2.5+
Bugfixes:
- Fix share sheet extension not working with Apple News
- Fix crash when sharing certain photos with share extension
- Fix reblog button being enabled on Direct posts
- Fix expanded statuses collapsing when opening Conversation
- Fix main post in Conversation flickering when context loaded
- Fix link card images not loading on Mastodon
## 2023.5 (89)
This build is a hotfix for an issue loading notifications in certain circumstances. The changelong for the previous build (adding post editing) is included below.
## 2023.5 (85)
This build adds support for editing posts and showing edit timestamps and history.
Features/Improvements:
- Post editing
- Show post edit history
- Improve rate limit exceeded error message
- Shorten hashtag save/follow action subtitles so they fit in the context menu
- Remove Hide/Show Reblogs action for accounts the user isn't following
Bugfixes:
- Fix nodeinfo not being fetched on instances with punycode domains
- Fix potential crash with interactive push gesture
- Fix list timelines opened in new window lacking Edit button
- Fix hashtag timelines opened in new window lacking save/follow actions
- Fix being able to scroll to top while fast account switcher is active
- Fix decoding statuses lacking emojis on Calckey
- Fix decoding polls on Calckey
## 2023.5 (84)
Bugfixes:
- Fix notifications scrolling to top when refreshing
- Fix decoding statuses failing on GoToSocial
- Fix assorted issues when collapsing/expanding between sidebar and tab bar modes
## 2023.5 (83)
This build contains significant refactors to the notifications screen, please report any issues you encounter.
Features/Improvements:
- Tweak appearance of profile fields
- Make language picker sheet half-height
Bugfixes:
- Fix crash when laying out profile fields on certain accounts
- Fix other presented screens getting dismissed when opened after closing expanded attachment view
- Fix janky status collapse/expand animation on notifications screen
- Fix link previews not appearing in notifications
## 2023.5 (81)
Further improvements and fixes to the Compose screen, see below. Features are frozen for the upcoming release, please report any bugs you encounter!
Features/Improvements:
- Add expanded attachment view on Compose screen
- Add an attachment, select the description text field, and tap on the expand button on the attachment thumbnail
- Expanded attachment view allows you to view the attachment larger while writing the description
- Plays back videos while writing the description
- iOS 16: Allow zooming in to expanded attachment view
- Add language picker to Compose screen
- Persist sidebar visibility across app launches
- Align link verification checkmarks to link rather than creen edge
- Fully dismiss, rather than ducking, the Compose screen when swiped down with no content
- Remove Automatically Save Drafts preference
- Drafts are always saved automatically, and the save/delete sheet is now always shown on dismiss
Bugfixes:
- Fix share sheet extension being unavailable on iOS 15
- Fix crash when loading draft with poll from share sheet extension
- Fix active draft being deleted when Compose screen ducked
- Fix restored, ducked Compose screen lacking title
- Fix error when reloading empty profile
- Fix local attachments not being deleted upon draft deletion
- Fix GIFs being converted to still images on upload
- Fix crash on deleting draft with attachments in share extension
- Fix deleted attachments in Compose screen reappearing
- Fix spinner on Send Report button being misplaced
- Fix crash on launch loop when migrating from previous version in certain circumstances
## 2023.5 (80)
This build adds a Share Sheet extension and introduces further Compose screen refactors.
Features/Improvements:
- Add Share Sheet extension
- Show reblogger's avatar on reblogged posts
Bugfixes:
- Fix not being able to close Compose screen when Automatically Save Drafts preference is off
- Fix Post button always being disabled when Require Attachment Descriptions preference is on
- Fix crash when pasting screenshots
- Fix not being able to paste gifs
- Don't consider HTTP 206 responses to timeline requests to be errors
- Fix crash when displaying menu for statuses missing URLs
- Fix errors while posting not displaying useful error messages
## 2023.5 (77)
The Compose screen has been substantially refactored in this build, in preparation for upcoming features, so please report any issues you encounter!
Features/Improvements:
- Use system photo picker instead of custom interface
- Improve Customize Timelines hashtag search UI
Bugfixes:
- Fix scroll-to-top not working in in-app Safari
- Fix crash when decoding pinned timelines fails
- Fix inaccurate titles in certain error popups
- Fix crash when comments present in status HTML
- Fix replied-to account not being the first mention
- Fix Compose window not having title set initially
- Fix crash when the API returns notifications that are missing statuses
- Fix "No Content" cell on profiles not using non-pure-black background
- Fix reblogged statuses appearing in the Bookmarks list
- Fix keyboard focus highlight not showing
- macOS: Fix sidebar item keyboard shortcuts not working
## 2023.4 (76)
App Store release
## 2023.4 (75) ## 2023.4 (75)
This build contains tweaks to automatic error reporting for the timeline marker API. The previous build's changelog is included below. This build contains tweaks to automatic error reporting for the timeline marker API. The previous build's changelog is included below.

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,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -1,23 +0,0 @@
{
"pins" : [
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "025bcb1165deab2e20d4eaba79967ce73013f496",
"version" : "1.2.1"
}
},
{
"identity" : "swift-url",
"kind" : "remoteSourceControl",
"location" : "https://github.com/karwa/swift-url.git",
"state" : {
"branch" : "main",
"revision" : "6f45f3cd6606f39c3753b302fe30aea980067b30"
}
}
],
"version" : 2
}

View File

@ -1,34 +0,0 @@
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "ComposeUI",
platforms: [
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "ComposeUI",
targets: ["ComposeUI"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
.package(path: "../Pachyderm"),
.package(path: "../InstanceFeatures"),
.package(path: "../TuskerComponents"),
.package(path: "../MatchedGeometryPresentation"),
],
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"]),
.testTarget(
name: "ComposeUITests",
dependencies: ["ComposeUI"]),
]
)

View File

@ -1,3 +0,0 @@
# ComposeUI
A description of this package.

View File

@ -1,186 +0,0 @@
//
// PostService.swift
// ComposeUI
//
// Created by Shadowfacts on 4/27/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
import UniformTypeIdentifiers
@MainActor
class PostService: ObservableObject {
private let mastodonController: ComposeMastodonContext
private let config: ComposeUIConfig
private let draft: Draft
@Published var currentStep = 1
@Published private(set) var totalSteps = 2
init(mastodonController: ComposeMastodonContext, config: ComposeUIConfig, draft: Draft) {
self.mastodonController = mastodonController
self.config = config
self.draft = draft
}
func post() async throws {
guard draft.hasContent || draft.editedStatusID != nil else {
return
}
// save before posting, so if a crash occurs during network request, the status won't be lost
DraftsPersistentContainer.shared.save()
let uploadedAttachments = try await uploadAttachments()
let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : ""
let sensitive = !contentWarning.isEmpty
let request: Request<Status>
if let editedStatusID = draft.editedStatusID {
if mastodonController.instanceFeatures.needsEditAttachmentsInSeparateRequest {
await updateEditedAttachments()
}
request = Client.editStatus(
id: editedStatusID,
text: textForPosting(),
contentType: config.contentType,
spoilerText: contentWarning,
sensitive: sensitive,
language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
mediaIDs: uploadedAttachments,
mediaAttributes: draft.draftAttachments.compactMap {
if let id = $0.editedAttachmentID {
return EditStatusMediaAttributes(id: id, description: $0.attachmentDescription, focus: nil)
} else {
return nil
}
},
poll: draft.poll.map {
EditPollParameters(options: $0.pollOptions.map(\.text), expiresIn: Int($0.duration), multiple: $0.multiple)
}
)
} else {
request = Client.createStatus(
text: textForPosting(),
contentType: config.contentType,
inReplyTo: draft.inReplyToID,
mediaIDs: uploadedAttachments,
sensitive: sensitive,
spoilerText: contentWarning,
visibility: draft.visibility,
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,
localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil
)
}
do {
let (status, _) = try await mastodonController.run(request)
currentStep += 1
mastodonController.storeCreatedStatus(status)
} catch let error as Client.Error {
throw Error.posting(error)
}
}
private func uploadAttachments() async throws -> [String] {
// 2 steps (request data, then upload) for each attachment
self.totalSteps += 2 * draft.attachments.count
var attachments: [String] = []
attachments.reserveCapacity(draft.attachments.count)
for (index, attachment) in draft.draftAttachments.enumerated() {
// if this attachment already exists and is being edited, we don't do anything
// edits to the description are handled as part of the edit status request
if let editedAttachmentID = attachment.editedAttachmentID {
attachments.append(editedAttachmentID)
currentStep += 2
continue
}
let data: Data
let utType: UTType
do {
(data, utType) = try await getData(for: attachment)
currentStep += 1
} catch let error as AttachmentData.Error {
throw Error.attachmentData(index: index, cause: error)
}
do {
let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription)
attachments.append(uploaded.id)
currentStep += 1
} catch let error as Client.Error {
throw Error.attachmentUpload(index: index, cause: error)
}
}
return attachments
}
private func getData(for attachment: DraftAttachment) async throws -> (Data, UTType) {
return try await withCheckedThrowingContinuation { 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)
}
}
}
}
private func uploadAttachment(data: Data, utType: UTType, description: String?) async throws -> Attachment {
let formAttachment = FormAttachment(mimeType: utType.preferredMIMEType!, data: data, fileName: "file.\(utType.preferredFilenameExtension!)")
let req = Client.upload(attachment: formAttachment, description: description)
return try await mastodonController.run(req).0
}
private func textForPosting() -> String {
var text = draft.text
// when using dictation, iOS sometimes leaves a U+FFFC OBJECT REPLACEMENT CHARACTER behind in the text,
// which we want to strip out before actually posting the status
text = text.replacingOccurrences(of: "\u{fffc}", with: "")
if draft.localOnly && mastodonController.instanceFeatures.needsLocalOnlyEmojiHack {
text += " 👁"
}
return text
}
// only needed for akkoma, not used on regular mastodon
private func updateEditedAttachments() async {
for attachment in draft.draftAttachments {
guard let id = attachment.editedAttachmentID else {
continue
}
let req = Client.updateAttachment(id: id, description: attachment.attachmentDescription, focus: nil)
_ = try? await mastodonController.run(req)
}
}
enum Error: Swift.Error, LocalizedError {
case attachmentData(index: Int, cause: AttachmentData.Error)
case attachmentUpload(index: Int, cause: Client.Error)
case posting(Client.Error)
var localizedDescription: String {
switch self {
case let .attachmentData(index: index, cause: cause):
return "Attachment \(index + 1): \(cause.localizedDescription)"
case let .attachmentUpload(index: index, cause: cause):
return "Attachment \(index + 1): \(cause.localizedDescription)"
case let .posting(error):
return error.localizedDescription
}
}
}
}

View File

@ -1,29 +0,0 @@
//
// ComposeInput.swift
// ComposeUI
//
// Created by Shadowfacts on 3/5/23.
//
import Foundation
import Combine
import UIKit
protocol ComposeInput: AnyObject, ObservableObject {
var toolbarElements: [ToolbarElement] { get }
var textInputMode: UITextInputMode? { get }
var autocompleteState: AutocompleteState? { get }
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { get }
func autocomplete(with string: String)
func applyFormat(_ format: StatusFormat)
func beginAutocompletingEmoji()
}
enum ToolbarElement {
case emojiPicker
case formattingButtons
}

View File

@ -1,28 +0,0 @@
//
// ComposeMastodonContext.swift
// ComposeUI
//
// Created by Shadowfacts on 3/5/23.
//
import Foundation
import Pachyderm
import InstanceFeatures
import UserAccounts
public protocol ComposeMastodonContext {
var accountInfo: UserAccountInfo? { get }
var instanceFeatures: InstanceFeatures { get }
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?)
func getCustomEmojis(completion: @escaping ([Emoji]) -> Void)
@MainActor
func searchCachedAccounts(query: String) -> [AccountProtocol]
@MainActor
func cachedRelationship(for accountID: String) -> RelationshipProtocol?
@MainActor
func searchCachedHashtags(query: String) -> [Hashtag]
func storeCreatedStatus(_ status: Status)
}

View File

@ -1,42 +0,0 @@
//
// ComposeUIConfig.swift
// ComposeUI
//
// Created by Shadowfacts on 3/4/23.
//
import SwiftUI
import Pachyderm
import PhotosUI
import PencilKit
import TuskerComponents
public struct ComposeUIConfig {
// Config
public var allowSwitchingDrafts = true
public var textSelectionStartsAtBeginning = false
// 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 init() {
}
}
extension ComposeUIConfig {
}

View File

@ -1,217 +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 {
parent.draft.attachments.remove(attachment)
}
}
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
DispatchQueue.global(qos: .userInitiated).async {
self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
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: 80, height: 80)
.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")
}
} previewIfAvailable: {
ControllerView(controller: { controller.thumbnailController })
}
switch controller.descriptionMode {
case .allowEntry:
InlineAttachmentDescriptionView(attachment: attachment, minHeight: 80)
.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)
.onChange(of: textEditorFocused) { newValue in
if !newValue && controller.focusAttachmentOnTextEditorUnfocus {
controller.focusAttachment()
}
}
}
@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
}
}
private extension View {
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func contextMenu<M: View, P: View>(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View {
if #available(iOS 16.0, *) {
self.contextMenu(menuItems: menuItems, preview: preview)
} else {
self.contextMenu(menuItems: menuItems)
}
}
}

View File

@ -1,191 +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)
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
self.image = UIImage(cgImage: cgImage)
}
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)
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
self.image = UIImage(cgImage: cgImage)
}
} 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 }
}
}
private 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,248 +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 {
guard 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
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
Group {
attachmentsList
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))
}
}
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(iOS, obsoleted: 16.0)
@ViewBuilder
func sheetOrPopover(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> some View) -> some View {
if #available(iOS 16.0, *) {
self.modifier(SheetOrPopover(isPresented: isPresented, view: content))
} else {
self.popover(isPresented: isPresented, content: content)
}
}
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func withSheetDetentsIfAvailable() -> some View {
if #available(iOS 16.0, *) {
self
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
} else {
self
}
}
}
@available(iOS 16.0, *)
fileprivate struct SheetOrPopover<V: View>: ViewModifier {
@Binding var isPresented: Bool
@ViewBuilder let view: () -> V
@Environment(\.horizontalSizeClass) var sizeClass
func body(content: Content) -> some View {
if sizeClass == .compact {
content.sheet(isPresented: $isPresented, content: view)
} else {
content.popover(isPresented: $isPresented, content: view)
}
}
}

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,196 +0,0 @@
//
// AutocompleteEmojisController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/26/23.
//
import SwiftUI
import Pachyderm
import Combine
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] = []
var emojisBySection: [String: [Emoji]] {
var values: [String: [Emoji]] = [:]
for emoji in emojis {
let key = emoji.category ?? ""
if !values.keys.contains(key) {
values[key] = [emoji]
} else {
values[key]!.append(emoji)
}
}
return values
}
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 {
await self.queryChanged(query)
}
}
}
@MainActor
private func queryChanged(_ query: String) async {
var emojis = await withCheckedContinuation { continuation in
composeController.mastodonController.getCustomEmojis {
continuation.resume(returning: $0)
}
}
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]()
for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
newEmojis.append(emoji)
shortcodes.insert(emoji.shortcode)
}
self.emojis = newEmojis
}
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) {
HStack(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)
Spacer(minLength: emojiSize)
}
.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,124 +0,0 @@
//
// AutocompleteHashtagsController.swift
// ComposeUI
//
// Created by Shadowfacts on 4/1/23.
//
import SwiftUI
import Combine
import Pachyderm
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 {
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,178 +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()
self.searchTask = Task {
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,499 +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
@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
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
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)
if #available(iOS 16.0, *) {
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 {
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
@StateObject private var keyboardReader = KeyboardReader()
@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)
ControllerView(controller: { controller.toolbarController })
}
// on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
.padding(.bottom, keyboardInset)
.transition(.move(edge: .bottom))
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) { cancelButton }
ToolbarItem(placement: .confirmationAction) { postButton }
}
.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)
.scrollDismissesKeyboardInteractivelyIfAvailable()
.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))
}
.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 postButton: some View {
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
Button(action: controller.postStatus) {
Text(draft.editedStatusID == nil ? "Post" : "Edit")
}
.keyboardShortcut(.return, modifiers: .command)
.disabled(!controller.postButtonEnabled)
} else {
Button(action: controller.showDrafts) {
Text("Drafts")
}
}
}
@available(iOS, obsoleted: 16.0)
private var keyboardInset: CGFloat {
if #unavailable(iOS 16.0),
UIDevice.current.userInterfaceIdiom == .pad,
keyboardReader.isVisible {
return ToolbarController.height
} else {
return 0
}
}
}
}
private extension View {
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
if #available(iOS 16.0, *) {
self.scrollDismissesKeyboard(.interactively)
} else {
self
}
}
}
private struct GlobalFrameOutsideListPrefKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
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,122 +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 if #available(iOS 16.0, *) {
ZoomableScrollView {
attachmentView
.matchedGeometryDestination(id: attachment.id)
}
} else {
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,48 +0,0 @@
//
// PlaceholderController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/6/23.
//
import SwiftUI
final class PlaceholderController: ViewController, PlaceholderViewProvider {
private let placeholderView: PlaceholderView = PlaceholderController.makePlaceholderView()
static func makePlaceholderView() -> some View {
let components = Calendar.current.dateComponents([.month, .day], from: Date())
if components.month == 3 && components.day == 14,
Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
Text("Happy π day!")
} else if components.month == 4 && components.day == 1 {
Text("April Fool's!").rotationEffect(.radians(.pi), anchor: .center)
} else if components.month == 9 && components.day == 5 {
// https://weirder.earth/@noracodes/109276419847254552
// https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
Text("This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing.").italic()
} else if components.month == 9 && components.day == 21 {
Text("Do you remember?")
} else if components.month == 10 && components.day == 31 {
if .random() {
Text("Post something spooky!")
} else {
Text("Any questions?")
}
} else {
Text("What's on your mind?")
}
}
var view: some View {
placeholderView
}
}
// exists to provide access to the type alias since the @State property needs it to be explicit
private protocol PlaceholderViewProvider {
associatedtype PlaceholderView: View
@ViewBuilder
static func makePlaceholderView() -> PlaceholderView
}

View File

@ -1,184 +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) {
poll.options.moveObjects(at: indices, to: newIndex)
}
private func removeOption(_ option: PollOption) {
poll.options.remove(option)
}
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)
.scrollDisabledIfAvailable(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)
)
.onChange(of: controller.duration) { newValue in
poll.duration = newValue.timeInterval
}
}
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,167 +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
private static let visibilityOptions: [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)")
}
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
@State private var minWidth: CGFloat?
@State private var realWidth: CGFloat?
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
cwButton
MenuPicker(selection: $draft.visibility, options: ToolbarController.visibilityOptions, buttonStyle: .iconOnly)
// the button has a bunch of extra space by default, but combined with what we add it's too much
.padding(.horizontal, -8)
.disabled(draft.editedStatusID != nil)
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
localOnlyPicker
.padding(.horizontal, -8)
.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 #available(iOS 16.0, *),
composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
}
}
.padding(.horizontal, 16)
.frame(minWidth: minWidth)
.background(GeometryReader { proxy in
Color.clear
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
realWidth = width
}
})
}
.scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
.frame(height: ToolbarController.height)
.frame(maxWidth: .infinity)
.background(.regularMaterial, ignoresSafeAreaEdges: .bottom)
.overlay(alignment: .top) {
Divider()
}
.background(GeometryReader { proxy in
Color.clear
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
minWidth = width
}
})
}
private var cwButton: some View {
Button("CW", action: controller.parent.toggleContentWarning)
.accessibilityLabel(draft.contentWarningEnabled ? "Remove content warning" : "Add content warning")
.padding(5)
.hoverEffect()
}
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)) {
if let imageName = format.imageName {
Image(systemName: imageName)
.font(.system(size: imageSize))
} else if let (str, attrs) = format.title {
let container = try! AttributeContainer(attrs, including: \.uiKit)
Text(AttributedString(str, attributes: container))
}
}
.accessibilityLabel(format.accessibilityLabel)
.padding(5)
.hoverEffect()
.transition(.opacity.animation(.linear(duration: 0.2)))
}
}
}
}
private struct ToolbarWidthPrefKey: PreferenceKey {
static var defaultValue: CGFloat? = nil
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
value = nextValue()
}
}

View File

@ -1,72 +0,0 @@
//
// Draft.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import CoreData
import Pachyderm
@objc
public class Draft: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Draft> {
return NSFetchRequest<Draft>(entityName: "Draft")
}
@nonobjc public class func fetchRequest(id: UUID) -> NSFetchRequest<Draft> {
let req = NSFetchRequest<Draft>(entityName: "Draft")
req.predicate = NSPredicate(format: "id = %@", id as NSUUID)
return req
}
@NSManaged public var accountID: String
@NSManaged public var contentWarning: String
@NSManaged public var contentWarningEnabled: Bool
@NSManaged public var editedStatusID: String?
@NSManaged public var id: UUID
@NSManaged public var initialText: String
@NSManaged public var inReplyToID: String?
@NSManaged public var language: String? // ISO 639 language code
@NSManaged public var lastModified: Date!
@NSManaged public var localOnly: Bool
@NSManaged public var text: String
@NSManaged private var visibilityStr: String
@NSManaged internal var attachments: NSMutableOrderedSet
@NSManaged public var poll: Poll?
public var visibility: Visibility {
get {
Visibility(rawValue: visibilityStr) ?? .public
}
set {
visibilityStr = newValue.rawValue
}
}
public var draftAttachments: [DraftAttachment] {
get {
attachments.array as! [DraftAttachment]
}
set {
attachments = NSMutableOrderedSet(array: newValue)
}
}
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
lastModified = Date()
}
}
extension Draft {
public var hasContent: Bool {
(!text.isEmpty && text != initialText) ||
(contentWarningEnabled && !contentWarning.isEmpty) ||
attachments.count > 0 ||
poll?.hasContent == true
}
}

View File

@ -1,317 +0,0 @@
//
// DraftAttachment.swift
// CoreData
//
// Created by Shadowfacts on 4/22/23.
//
import CoreData
import PencilKit
import UniformTypeIdentifiers
import Photos
import InstanceFeatures
import Pachyderm
private let decoder = PropertyListDecoder()
private let encoder = PropertyListEncoder()
@objc
public final class DraftAttachment: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<DraftAttachment> {
return NSFetchRequest<DraftAttachment>(entityName: "DraftAttachment")
}
@NSManaged internal var assetID: String?
@NSManaged public var attachmentDescription: String
@NSManaged internal private(set) var drawingData: Data?
@NSManaged public var editedAttachmentID: String?
@NSManaged private var editedAttachmentKindString: String?
@NSManaged public var editedAttachmentURL: URL?
@NSManaged public var fileURL: URL?
@NSManaged internal var fileType: String?
@NSManaged public var id: UUID!
@NSManaged internal var draft: Draft
public var drawing: PKDrawing? {
get {
if let drawingData,
let drawing = try? decoder.decode(PKDrawing.self, from: drawingData) {
return drawing
} else {
return nil
}
}
set {
drawingData = try! encoder.encode(newValue)
}
}
public var data: AttachmentData {
if let editedAttachmentID {
return .editing(editedAttachmentID, editedAttachmentKind!, editedAttachmentURL!)
} else if let assetID {
return .asset(assetID)
} else if let drawing {
return .drawing(drawing)
} else if let fileURL, let fileType {
return .file(fileURL, UTType(fileType)!)
} else {
return .none
}
}
public var editedAttachmentKind: Attachment.Kind? {
get {
editedAttachmentKindString.flatMap(Attachment.Kind.init(rawValue:))
}
set {
editedAttachmentKindString = newValue?.rawValue
}
}
public enum AttachmentData {
case asset(String)
case drawing(PKDrawing)
case file(URL, UTType)
case editing(String, Attachment.Kind, URL)
case none
}
public override func prepareForDeletion() {
super.prepareForDeletion()
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
}
}
extension DraftAttachment {
var type: AttachmentType {
if let editedAttachmentKind {
switch editedAttachmentKind {
case .image:
return .image
case .video:
return .video
case .gifv:
return .video
case .audio, .unknown:
return .unknown
}
} else if let assetID {
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else {
return .unknown
}
switch asset.mediaType {
case .image:
return .image
case .video:
return .video
default:
return .unknown
}
} else if drawingData != nil {
return .image
} else if let fileType,
let type = UTType(fileType) {
if type.conforms(to: .image) {
return .image
} else if type.conforms(to: .movie) {
return .video
} else {
return .unknown
}
} else {
return .unknown
}
}
enum AttachmentType {
case image, video, unknown
}
}
//private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment"
private let jpegType = UTType.jpeg.identifier
private let pngType = UTType.png.identifier
private let mp4Type = UTType.mpeg4Movie.identifier
private let quickTimeType = UTType.quickTimeMovie.identifier
private let gifType = UTType.gif.identifier
extension DraftAttachment: NSItemProviderReading {
public static var readableTypeIdentifiersForItemProvider: [String] {
// todo: is there a better way of handling movies than manually adding all possible UTI types?
// just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension
// without the file extension, getting the thumbnail and exporting the video for attachment upload fails
[/*typeIdentifier, */gifType, jpegType, pngType, mp4Type, quickTimeType]
}
public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment {
let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil)
attachment.id = UUID()
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: UTType(typeIdentifier)!)
attachment.fileType = typeIdentifier
attachment.attachmentDescription = ""
return attachment
}
static var attachmentsDirectory: URL {
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
return containerURL.appendingPathComponent("Documents").appendingPathComponent("attachments")
}
static func writeDataToFile(_ data: Data, id: UUID, type: UTType) throws -> URL {
let directoryURL = attachmentsDirectory
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
let attachmentURL = directoryURL.appendingPathComponent(id.uuidString, conformingTo: type)
try data.write(to: attachmentURL)
return attachmentURL
}
}
// MARK: Exporting
extension DraftAttachment {
func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
if let assetID {
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else {
completion(.failure(.noAsset))
return
}
if asset.mediaType == .image {
let options = PHImageRequestOptions()
options.version = .current
options.deliveryMode = .highQualityFormat
options.resizeMode = .none
options.isNetworkAccessAllowed = true
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { data, dataUTI, orientation, info in
guard let data, let dataUTI else {
completion(.failure(.missingAssetData))
return
}
let processed = Self.processImageData(data, type: UTType(dataUTI)!, features: features, skipAllConversion: skipAllConversion)
completion(.success(processed))
}
} else if asset.mediaType == .video {
let options = PHVideoRequestOptions()
options.version = .current
options.deliveryMode = .automatic
options.isNetworkAccessAllowed = true
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { exportSession, info in
if let exportSession {
Self.exportVideoData(session: exportSession, completion: completion)
} else if let error = info?[PHImageErrorKey] as? Error {
completion(.failure(.videoExport(error)))
} else {
completion(.failure(.noVideoExportSession))
}
}
} else {
completion(.failure(.unknownAssetType))
}
} else if let drawingData {
guard let drawing = try? decoder.decode(PKDrawing.self, from: drawingData) else {
completion(.failure(.loadingDrawing))
return
}
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
completion(.success((image.pngData()!, .png)))
} else if let fileURL, let fileType {
let type = UTType(fileType)!
if type.conforms(to: .movie) {
let asset = AVURLAsset(url: fileURL)
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
completion(.failure(.noVideoExportSession))
return
}
Self.exportVideoData(session: session, completion: completion)
} else {
let fileData: Data
do {
fileData = try Data(contentsOf: fileURL)
} catch {
completion(.failure(.loadingData))
return
}
if type != .gif,
type.conforms(to: .image) {
let result = Self.processImageData(fileData, type: type, features: features, skipAllConversion: skipAllConversion)
completion(.success(result))
} else {
completion(.success((fileData, type)))
}
}
} else {
completion(.failure(.noData))
}
}
private static func processImageData(_ data: Data, type: UTType, features: InstanceFeatures, skipAllConversion: Bool) -> (Data, UTType) {
guard !skipAllConversion else {
return (data, type)
}
var data = data
var type = type
if type != .png && type != .jpeg,
let image = UIImage(data: data) {
// The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future.
data = image.jpegData(compressionQuality: 0.8)!
type = .jpeg
}
let image = CIImage(data: data)!
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
if needsColorSpaceConversion || type == .heic {
let context = CIContext()
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
if type == .png {
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)!
} else {
data = context.jpegRepresentation(of: image, colorSpace: colorSpace)!
type = .jpeg
}
}
return (data, type)
}
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
session.outputFileType = .mp4
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
session.exportAsynchronously {
guard session.status == .completed else {
completion(.failure(.videoExport(session.error!)))
return
}
do {
let data = try Data(contentsOf: session.outputURL!)
completion(.success((data, .mpeg4Movie)))
} catch {
completion(.failure(.videoExport(error)))
}
}
}
enum ExportError: Error {
case noAsset
case unknownAssetType
case missingAssetData
case videoExport(Error)
case noVideoExportSession
case loadingDrawing
case loadingData
case noData
}
}

View File

@ -1,44 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Draft" representedClassName="ComposeUI.Draft" syncable="YES">
<attribute name="accountID" attributeType="String"/>
<attribute name="contentWarning" attributeType="String" defaultValueString=""/>
<attribute name="contentWarningEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="editedStatusID" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="initialText" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
<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="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"/>
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Poll" inverseName="draft" inverseEntity="Poll"/>
</entity>
<entity name="DraftAttachment" representedClassName="ComposeUI.DraftAttachment" syncable="YES">
<attribute name="assetID" optional="YES" attributeType="String"/>
<attribute name="attachmentDescription" attributeType="String" defaultValueString=""/>
<attribute name="drawingData" optional="YES" attributeType="Binary"/>
<attribute name="editedAttachmentID" optional="YES" attributeType="String"/>
<attribute name="editedAttachmentKindString" optional="YES" attributeType="String"/>
<attribute name="editedAttachmentURL" optional="YES" attributeType="URI"/>
<attribute name="fileType" optional="YES" attributeType="String"/>
<attribute name="fileURL" optional="YES" attributeType="URI"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<relationship name="draft" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Draft" inverseName="attachments" inverseEntity="Draft"/>
</entity>
<entity name="Poll" representedClassName="ComposeUI.Poll" syncable="YES">
<attribute name="duration" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="multiple" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="draft" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Draft" inverseName="poll" inverseEntity="Draft"/>
<relationship name="options" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PollOption" inverseName="poll" inverseEntity="PollOption"/>
</entity>
<entity name="PollOption" representedClassName="ComposeUI.PollOption" syncable="YES">
<attribute name="text" attributeType="String" defaultValueString=""/>
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/>
</entity>
<entity name="TestEntity" representedClassName="TestEntity" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
</entity>
</model>

View File

@ -1,210 +0,0 @@
//
// DraftsPersistentContainer.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import Foundation
import CoreData
import OSLog
import Pachyderm
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsPersistentContainer")
public class DraftsPersistentContainer: NSPersistentContainer {
public static let shared = DraftsPersistentContainer()
private static let managedObjectModel: NSManagedObjectModel = {
let url = Bundle.module.url(forResource: "Drafts", withExtension: "momd")!
return NSManagedObjectModel(contentsOf: url)!
}()
private var lastHistoryToken: NSPersistentHistoryToken!
init() {
super.init(name: "Drafts", managedObjectModel: DraftsPersistentContainer.managedObjectModel)
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
let documentsURL = containerURL.appendingPathComponent("Documents")
let storeDesc = NSPersistentStoreDescription(url: documentsURL.appendingPathComponent("drafts").appendingPathExtension("sqlite"))
storeDesc.type = NSSQLiteStoreType
storeDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
storeDesc.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
persistentStoreDescriptions = [
storeDesc
]
loadPersistentStores { _, error in
if let error {
fatalError("Loading persistent store: \(error)")
}
}
viewContext.automaticallyMergesChangesFromParent = true
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
lastHistoryToken = persistentStoreCoordinator.currentPersistentHistoryToken(fromStores: nil)
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges(_:)), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
}
public func save() {
guard viewContext.hasChanges else {
return
}
do {
try viewContext.save()
} catch {
logger.error("Failed to save: \(String(describing: error))")
}
}
public func migrate(from url: URL, completion: @escaping (Result<(), any Error>) -> Void) {
performBackgroundTask { context in
let result = DraftsMigrator.migrate(from: url, to: context)
completion(result)
try! context.save()
}
}
public func getDraft(id: UUID) -> Draft? {
let req = Draft.fetchRequest(id: id)
return try? viewContext.fetch(req).first
}
public func createDraft(
accountID: String,
text: String,
contentWarning: String,
inReplyToID: String?,
visibility: Visibility,
localOnly: Bool
) -> Draft {
let draft = Draft(context: viewContext)
draft.accountID = accountID
draft.text = text
draft.initialText = text
draft.contentWarning = contentWarning
draft.contentWarningEnabled = !contentWarning.isEmpty
draft.inReplyToID = inReplyToID
draft.visibility = visibility
draft.localOnly = localOnly
save()
return draft
}
public func createEditDraft(
accountID: String,
source: StatusSource,
inReplyToID: String?,
visibility: Visibility,
localOnly: Bool,
attachments: [Attachment],
poll: Pachyderm.Poll?
) -> Draft {
let draft = Draft(context: viewContext)
draft.accountID = accountID
draft.editedStatusID = source.id
draft.text = source.text
draft.initialText = source.text
draft.contentWarning = source.spoilerText
draft.contentWarningEnabled = !source.spoilerText.isEmpty
draft.inReplyToID = inReplyToID
draft.visibility = visibility
draft.localOnly = localOnly
for attachment in attachments {
createEditDraftAttachment(attachment, in: draft)
}
if let existingPoll = poll {
let poll = Poll(context: viewContext)
poll.draft = draft
draft.poll = poll
if let expiresAt = existingPoll.expiresAt,
!existingPoll.effectiveExpired {
poll.duration = PollController.Duration.allCases.max(by: {
(expiresAt.timeIntervalSinceNow - $0.timeInterval) < (expiresAt.timeIntervalSinceNow - $1.timeInterval)
})!.timeInterval
} else {
poll.duration = PollController.Duration.oneDay.timeInterval
}
poll.multiple = existingPoll.multiple
// rmeove default empty options
for opt in poll.pollOptions {
viewContext.delete(opt)
}
for existingOpt in existingPoll.options {
let opt = PollOption(context: viewContext)
opt.poll = poll
poll.options.add(opt)
opt.text = existingOpt.title
}
}
save()
return draft
}
private func createEditDraftAttachment(_ attachment: Attachment, in draft: Draft) {
let draftAttachment = DraftAttachment(context: viewContext)
draftAttachment.id = UUID()
draftAttachment.attachmentDescription = attachment.description ?? ""
draftAttachment.editedAttachmentID = attachment.id
draftAttachment.editedAttachmentKind = attachment.kind
draftAttachment.editedAttachmentURL = attachment.url
draftAttachment.draft = draft
draft.attachments.add(draftAttachment)
}
public func removeOrphanedAttachments(completion: @escaping () -> Void) {
guard let files = try? FileManager.default.contentsOfDirectory(at: DraftAttachment.attachmentsDirectory, includingPropertiesForKeys: nil),
!files.isEmpty else {
return
}
performBackgroundTask { context in
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 {
do {
try FileManager.default.removeItem(at: url)
} catch {
logger.error("Failed to remove orphaned attachment: \(String(describing: error), privacy: .public)")
}
}
completion()
}
}
@objc private func remoteChanges(_ notification: Foundation.Notification) {
guard let newHistoryToken = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
return
}
// todo: should this be on a background context?
let context = viewContext
context.perform {
let predicate = NSPredicate(format: "(%@ < token) AND (token <= %@)", self.lastHistoryToken, newHistoryToken)
let historyRequest = NSPersistentHistoryTransaction.fetchRequest!
historyRequest.predicate = predicate
let request = NSPersistentHistoryChangeRequest.fetchHistory(withFetch: historyRequest)
if let result = try? context.execute(request) as? NSPersistentHistoryResult,
let transactions = result.result as? [NSPersistentHistoryTransaction] {
for transaction in transactions {
guard let userInfo = transaction.objectIDNotification().userInfo else {
continue
}
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: userInfo, into: [context])
}
}
self.lastHistoryToken = newHistoryToken
}
}
}

View File

@ -1,46 +0,0 @@
//
// Poll.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import CoreData
@objc
public class Poll: NSManagedObject {
@NSManaged public var duration: TimeInterval
@NSManaged public var multiple: Bool
@NSManaged public var draft: Draft
@NSManaged public var options: NSMutableOrderedSet
public var pollOptions: [PollOption] {
get {
options.array as! [PollOption]
}
set {
options = NSMutableOrderedSet(array: newValue)
}
}
public override func awakeFromInsert() {
super.awakeFromInsert()
self.multiple = false
self.duration = 24 * 60 * 60 // 1 day
if let managedObjectContext {
self.options = [
PollOption(context: managedObjectContext),
PollOption(context: managedObjectContext),
]
}
}
}
extension Poll {
public var hasContent: Bool {
pollOptions.allSatisfy { !$0.text.isEmpty }
}
}

View File

@ -1,21 +0,0 @@
//
// PollOption.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import CoreData
@objc
public class PollOption: NSManagedObject, Identifiable {
public var id: NSManagedObjectID {
objectID
}
@NSManaged public var text: String
@NSManaged public var poll: Poll
}

View File

@ -1,255 +0,0 @@
//
// DraftsMigrator.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import Foundation
import OSLog
import UniformTypeIdentifiers
import Pachyderm
import PencilKit
import CoreData
struct DraftsMigrator {
private init() {}
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsMigrator")
private static let decoder = PropertyListDecoder()
static func migrate(from url: URL, to context: NSManagedObjectContext) -> Result<(), any Error> {
do {
let data = try Data(contentsOf: url)
let container = try decoder.decode(DraftsContainer.self, from: data)
for old in container.drafts.values {
let new = Draft(context: context)
new.id = old.id
new.lastModified = old.lastModified
new.accountID = old.accountID
new.text = old.text
new.contentWarningEnabled = old.contentWarningEnabled
new.contentWarning = old.contentWarning
new.inReplyToID = old.inReplyToID
new.visibility = old.visibility
new.localOnly = old.localOnly
new.initialText = old.initialText
if let oldPoll = old.poll {
let newPoll = Poll(context: context)
newPoll.draft = new
new.poll = newPoll
newPoll.multiple = oldPoll.multiple
newPoll.duration = oldPoll.duration
for oldOption in oldPoll.options {
let newOption = PollOption(context: context)
newOption.text = oldOption.text
newOption.poll = newPoll
newPoll.options.add(newOption)
}
}
for oldAttachment in old.attachments {
let newAttachment = DraftAttachment(context: context)
newAttachment.draft = new
new.attachments.add(newAttachment)
newAttachment.id = oldAttachment.id
newAttachment.attachmentDescription = oldAttachment.attachmentDescription
switch oldAttachment.data {
case .asset(let assetID):
newAttachment.assetID = assetID
case .image(let data, originalType: let type):
newAttachment.fileURL = try? DraftAttachment.writeDataToFile(data, id: newAttachment.id, type: type)
newAttachment.fileType = type.identifier
case .video(_):
fatalError("unreachable, video attachments weren't encodable")
case .drawing(let drawing):
newAttachment.drawing = drawing
case .gif(let data):
newAttachment.fileURL = try? DraftAttachment.writeDataToFile(data, id: newAttachment.id, type: .gif)
newAttachment.fileType = UTType.gif.identifier
}
}
}
try FileManager.default.removeItem(at: url)
} catch {
logger.error("Error migrating: \(String(describing: error))")
return .failure(error)
}
return .success(())
}
// MARK: Supporting Types
struct DraftsContainer: Decodable {
let drafts: [UUID: OldDraft]
init(drafts: [UUID: OldDraft]) {
self.drafts = drafts
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.drafts = try container.decode([UUID: SafeDraft].self, forKey: .drafts).compactMapValues(\.draft)
}
enum CodingKeys: CodingKey {
case drafts
}
}
// a container that always succeeds at decoding
// so if a single draft can't be decoded, we don't lose all drafts
struct SafeDraft: Decodable {
let draft: OldDraft?
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
self.draft = try? container.decode(OldDraft.self)
}
}
struct OldDraft: Decodable {
let id: UUID
let lastModified: Date
let accountID: String
let text: String
let contentWarningEnabled: Bool
let contentWarning: String
let attachments: [OldDraftAttachment]
let inReplyToID: String?
let visibility: Visibility
let poll: OldPoll?
let localOnly: Bool
let initialText: String
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(UUID.self, forKey: .id)
self.lastModified = try container.decode(Date.self, forKey: .lastModified)
self.accountID = try container.decode(String.self, forKey: .accountID)
self.text = try container.decode(String.self, forKey: .text)
self.contentWarningEnabled = try container.decode(Bool.self, forKey: .contentWarningEnabled)
self.contentWarning = try container.decode(String.self, forKey: .contentWarning)
self.attachments = try container.decode([OldDraftAttachment].self, forKey: .attachments)
self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID)
self.visibility = try container.decode(Visibility.self, forKey: .visibility)
self.poll = try container.decode(OldPoll?.self, forKey: .poll)
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false
self.initialText = try container.decode(String.self, forKey: .initialText)
}
enum CodingKeys: String, CodingKey {
case id
case lastModified
case accountID
case text
case contentWarningEnabled
case contentWarning
case attachments
case inReplyToID
case visibility
case poll
case localOnly
case initialText
}
}
struct OldDraftAttachment: Decodable {
let id: UUID
let data: OldDraftAttachmentData
let attachmentDescription: String
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(UUID.self, forKey: .id)
self.data = try container.decode(OldDraftAttachmentData.self, forKey: .data)
self.attachmentDescription = try container.decode(String.self, forKey: .attachmentDescription)
}
enum CodingKeys: String, CodingKey {
case id
case data
case attachmentDescription
}
}
enum OldDraftAttachmentData: Decodable {
case asset(String)
case image(Data, originalType: UTType)
case video(URL)
case drawing(PKDrawing)
case gif(Data)
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
switch try container.decode(String.self, forKey: .type) {
case "asset":
let identifier = try container.decode(String.self, forKey: .assetIdentifier)
self = .asset(identifier)
case "image":
let data = try container.decode(Data.self, forKey: .imageData)
if let type = try container.decodeIfPresent(UTType.self, forKey: .imageType) {
self = .image(data, originalType: type)
} else {
guard let image = UIImage(data: data) else {
throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "CompositionAttachment data could not be decoded into UIImage")
}
let jpegData = image.jpegData(compressionQuality: 1)!
self = .image(jpegData, originalType: .jpeg)
}
case "drawing":
let drawingData = try container.decode(Data.self, forKey: .drawing)
let drawing = try PKDrawing(data: drawingData)
self = .drawing(drawing)
default:
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of image, asset, or drawing")
}
}
enum CodingKeys: CodingKey {
case type
case imageData
case imageType
/// The local identifier of the PHAsset for this attachment
case assetIdentifier
/// The PKDrawing object for this attachment.
case drawing
}
}
struct OldPoll: Decodable {
let options: [OldPollOption]
let multiple: Bool
let duration: TimeInterval
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.options = try container.decode([OldPollOption].self, forKey: .options)
self.multiple = try container.decode(Bool.self, forKey: .multiple)
self.duration = try container.decode(TimeInterval.self, forKey: .duration)
}
enum CodingKeys: String, CodingKey {
case options
case multiple
case duration
}
}
struct OldPollOption: Decodable {
let text: String
init(from decoder: Decoder) throws {
self.text = try decoder.singleValueContainer().decode(String.self)
}
}
}

View File

@ -1,62 +0,0 @@
//
// FuzzyMatcher.swift
// ComposeUI
//
// Created by Shadowfacts on 10/10/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
struct FuzzyMatcher {
private init() {}
/// Rudimentary string fuzzy matching algorithm.
///
/// Operates on UTF-8 code points, so attempting to match strings which include characters composed of
/// multiple code points may produce unexpected results.
///
/// Scoring is as follows:
/// +2 points for every char in `pattern` that occurs in `str` sequentially
/// -2 points for every char in `pattern` that does not occur in `str` sequentially
/// -1 point for every char in `str` skipped between matching chars from the `pattern`
static func match(pattern: String, str: String) -> (matched: Bool, score: Int) {
let pattern = pattern.lowercased()
let str = str.lowercased()
var patternIndex = pattern.utf8.startIndex
var lastStrMatchIndex: String.UTF8View.Index?
var strIndex = str.utf8.startIndex
var score = 0
while patternIndex < pattern.utf8.endIndex && strIndex < str.utf8.endIndex {
let patternChar = pattern.utf8[patternIndex]
let strChar = str.utf8[strIndex]
if patternChar == strChar {
let distance = str.utf8.distance(from: lastStrMatchIndex ?? str.utf8.startIndex, to: strIndex)
if distance > 1 {
score -= distance - 1
}
patternIndex = pattern.utf8.index(after: patternIndex)
lastStrMatchIndex = strIndex
strIndex = str.utf8.index(after: strIndex)
score += 2
} else {
strIndex = str.utf8.index(after: strIndex)
if strIndex >= str.utf8.endIndex {
patternIndex = pattern.utf8.index(after: patternIndex)
strIndex = str.utf8.index(after: lastStrMatchIndex ?? str.utf8.startIndex)
score -= 2
}
}
}
return (score > 0, score)
}
}

View File

@ -1,39 +0,0 @@
//
// KeyboardReader.swift
// ComposeUI
//
// Created by Shadowfacts on 3/7/23.
//
import UIKit
import Combine
@available(iOS, obsoleted: 16.0)
class KeyboardReader: ObservableObject {
// @Published var isVisible = false
@Published var keyboardHeight: CGFloat = 0
var isVisible: Bool {
// when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible"
keyboardHeight > 72
}
init() {
NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(willHide), name: UIResponder.keyboardWillHideNotification, object: nil)
}
@objc func willShow(_ notification: Foundation.Notification) {
let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
// isVisible = endFrame.height > 72
keyboardHeight = endFrame.height
}
@objc func willHide() {
// sometimes willHide is called during a SwiftUI view update
DispatchQueue.main.async {
// self.isVisible = false
self.keyboardHeight = 0
}
}
}

View File

@ -1,12 +0,0 @@
//
// DismissMode.swift
// ComposeUI
//
// Created by Shadowfacts on 3/7/23.
//
import Foundation
public enum DismissMode {
case cancel, post
}

View File

@ -1,33 +0,0 @@
//
// OptionalObservedObject.swift
// ComposeUI
//
// Created by Shadowfacts on 4/15/23.
//
import SwiftUI
import Combine
@propertyWrapper
struct OptionalObservedObject<T: ObservableObject>: DynamicProperty {
private class Republisher: ObservableObject {
var cancellable: AnyCancellable?
var wrapped: T? {
didSet {
cancellable?.cancel()
cancellable = wrapped?.objectWillChange
.receive(on: RunLoop.main)
.sink { [unowned self] _ in
self.objectWillChange.send()
}
}
}
}
@StateObject private var republisher = Republisher()
var wrappedValue: T?
func update() {
republisher.wrapped = wrappedValue
}
}

View File

@ -1,33 +0,0 @@
//
// PKDrawing+Render.swift
// ComposeUI
//
// Created by Shadowfacts on 5/9/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import PencilKit
extension PKDrawing {
func imageInLightMode(from rect: CGRect, scale: CGFloat = UIScreen.main.scale) -> UIImage {
let lightTraitCollection = UITraitCollection(userInterfaceStyle: .light)
var drawingImage: UIImage!
lightTraitCollection.performAsCurrent {
drawingImage = self.image(from: rect, scale: scale)
}
let imageRect = CGRect(origin: .zero, size: rect.size)
let format = UIGraphicsImageRendererFormat()
format.opaque = false
format.scale = scale
let renderer = UIGraphicsImageRenderer(size: rect.size, format: format)
return renderer.image { (context) in
UIColor.white.setFill()
context.fill(imageRect)
drawingImage.draw(in: imageRect)
}
}
}

View File

@ -1,36 +0,0 @@
//
// File.swift
//
//
// Created by Shadowfacts on 4/22/23.
//
import SwiftUI
struct TestView: View {
@State var manager = DraftsPersistentContainer()
var body: some View {
VStack {
Button("Add") {
let entity = TestEntity(context: manager.viewContext)
entity.id = UUID()
try! manager.viewContext.save()
}
InnerView()
.environment(\.managedObjectContext, manager.viewContext)
}
}
}
struct InnerView: View {
@FetchRequest(sortDescriptors: []) var results: FetchedResults<TestEntity>
var body: some View {
List {
ForEach(results) { result in
Text(result.id?.uuidString ?? "<nil>")
}
}
}
}

View File

@ -1,183 +0,0 @@
//
// UITextInput+Autocomplete.swift
// ComposeUI
//
// Created by Shadowfacts on 3/5/23.
//
import UIKit
import SwiftUI
extension UITextInput {
func autocomplete(with string: String, permittedModes: AutocompleteModes, autocompleteState: inout AutocompleteState?) {
guard let selectedTextRange,
let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument),
let text = self.text(in: wholeDocumentRange),
let (lastWordStartIndex, _) = findAutocompleteLastWord() else {
return
}
let distanceToEnd = self.offset(from: selectedTextRange.start, to: self.endOfDocument)
let selectedRangeStartUTF16 = self.offset(from: self.beginningOfDocument, to: selectedTextRange.start)
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
let insertSpace: Bool
if distanceToEnd > 0 {
let charAfterCursor = text[characterBeforeCursorIndex]
insertSpace = charAfterCursor != " " && charAfterCursor != "\n"
} else {
insertSpace = true
}
let string = insertSpace ? string + " " : string
let startPosition = self.position(from: self.beginningOfDocument, offset: text.utf16.distance(from: text.startIndex, to: lastWordStartIndex))!
let lastWordRange = self.textRange(from: startPosition, to: selectedTextRange.start)!
replace(lastWordRange, withText: string)
autocompleteState = updateAutocompleteState(permittedModes: permittedModes)
// keep the cursor at the same position in the text, immediately after what was inserted
// if we inserted a space, move the cursor 1 farther so it's immediately after the pre-existing space
let insertSpaceOffset = insertSpace ? 0 : 1
let newCursorPosition = self.position(from: self.endOfDocument, offset: -distanceToEnd + insertSpaceOffset)!
self.selectedTextRange = self.textRange(from: newCursorPosition, to: newCursorPosition)
}
func updateAutocompleteState(permittedModes: AutocompleteModes) -> AutocompleteState? {
guard let selectedTextRange,
let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument),
let text = self.text(in: wholeDocumentRange),
!text.isEmpty,
let (lastWordStartIndex, foundFirstAtSign) = findAutocompleteLastWord() else {
return nil
}
let triggerChars = permittedModes.triggerChars
if lastWordStartIndex > text.startIndex {
// if the character before the "word" beginning is a valid part of a "word",
// we aren't able to autocomplete
let c = text[text.index(before: lastWordStartIndex)]
if isPermittedForAutocomplete(c) || triggerChars.contains(c) {
return nil
}
}
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: self.offset(from: self.beginningOfDocument, to: selectedTextRange.start))
if lastWordStartIndex >= text.startIndex {
let lastWord = text[lastWordStartIndex..<characterBeforeCursorIndex]
let exceptFirst = lastWord[lastWord.index(after: lastWord.startIndex)...]
// periods are only allowed in mentions in the domain part
if lastWord.contains(".") {
if lastWord.first == "@" && foundFirstAtSign && permittedModes.contains(.mentions) {
return .mention(String(exceptFirst))
} else {
return nil
}
}
switch lastWord.first {
case "@" where permittedModes.contains(.mentions):
return .mention(String(exceptFirst))
case ":" where permittedModes.contains(.emojis):
return .emoji(String(exceptFirst))
case "#" where permittedModes.contains(.hashtags):
return .hashtag(String(exceptFirst))
default:
return nil
}
} else {
return nil
}
}
private func findAutocompleteLastWord() -> (index: String.Index, foundFirstAtSign: Bool)? {
guard (self as? UIView)?.isFirstResponder == true,
let selectedTextRange,
selectedTextRange.isEmpty,
let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument),
let text = self.text(in: wholeDocumentRange),
!text.isEmpty else {
return nil
}
let selectedRangeStartUTF16 = self.offset(from: self.beginningOfDocument, to: selectedTextRange.start)
let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
guard cursorIndex != text.startIndex else {
return nil
}
var lastWordStartIndex = text.index(before: cursorIndex)
var foundFirstAtSign = false
while true {
let c = text[lastWordStartIndex]
if !isPermittedForAutocomplete(c) {
if foundFirstAtSign {
if c != "@" {
// move the index forward by 1, so that the first char of the substring is the 1st @ instead of whatever comes before it
lastWordStartIndex = text.index(after: lastWordStartIndex)
}
break
} else {
if c == "@" {
foundFirstAtSign = true
} else if c != "." {
// periods are allowed for domain names in mentions
break
}
}
}
guard lastWordStartIndex > text.startIndex else {
break
}
lastWordStartIndex = text.index(before: lastWordStartIndex)
}
return (lastWordStartIndex, foundFirstAtSign)
}
}
enum AutocompleteState: Equatable {
case mention(String)
case emoji(String)
case hashtag(String)
}
struct AutocompleteModes: OptionSet {
static let mentions = AutocompleteModes(rawValue: 1 << 0)
static let hashtags = AutocompleteModes(rawValue: 1 << 2)
static let emojis = AutocompleteModes(rawValue: 1 << 3)
static let all: AutocompleteModes = [
.mentions,
.hashtags,
.emojis,
]
let rawValue: Int
var triggerChars: [Character] {
var chars: [Character] = []
if contains(.mentions) {
chars.append("@")
}
if contains(.hashtags) {
chars.append("#")
}
if contains(.emojis) {
chars.append(":")
}
return chars
}
}
private func isPermittedForAutocomplete(_ c: Character) -> Bool {
return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_"
}

View File

@ -1,20 +0,0 @@
//
// View+ForwardsCompat.swift
// ComposeUI
//
// Created by Shadowfacts on 3/25/23.
//
import SwiftUI
extension View {
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
if #available(iOS 16.0, *) {
self.scrollDisabled(disabled)
} else {
self
}
}
}

View File

@ -1,29 +0,0 @@
//
// ViewController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/4/23.
//
import SwiftUI
import Combine
public protocol ViewController: ObservableObject {
associatedtype ContentView: View
@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,139 +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
}
var body: some View {
ZStack(alignment: .topLeading) {
if attachment.attachmentDescription.isEmpty {
placeholder
.font(.body)
.foregroundColor(.secondary)
.offset(x: 4, y: 8)
}
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
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,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

@ -1,141 +0,0 @@
//
// EmojiTextField.swift
// ComposeUI
//
// Created by Shadowfacts on 3/5/23.
//
import SwiftUI
struct EmojiTextField: UIViewRepresentable {
typealias UIViewType = UITextField
@EnvironmentObject private var controller: ComposeController
@Environment(\.colorScheme) private var colorScheme
@Binding var text: String
let placeholder: String
let maxLength: Int?
let becomeFirstResponder: Binding<Bool>?
let focusNextView: Binding<Bool>?
init(text: Binding<String>, placeholder: String, maxLength: Int?, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) {
self._text = text
self.placeholder = placeholder
self.maxLength = maxLength
self.becomeFirstResponder = becomeFirstResponder
self.focusNextView = focusNextView
}
func makeUIView(context: Context) -> UITextField {
let view = UITextField()
view.borderStyle = .roundedRect
view.font = .preferredFont(forTextStyle: .body)
view.adjustsFontForContentSizeCategory = true
view.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: [
.foregroundColor: UIColor.secondaryLabel,
])
context.coordinator.textField = view
view.delegate = context.coordinator
view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged)
view.addTarget(context.coordinator, action: #selector(Coordinator.returnKeyPressed), for: .primaryActionTriggered)
// otherwise when the text gets too wide it starts expanding the ComposeView
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return view
}
func updateUIView(_ uiView: UITextField, context: Context) {
if text != uiView.text {
uiView.text = text
}
context.coordinator.text = $text
context.coordinator.maxLength = maxLength
context.coordinator.focusNextView = focusNextView
uiView.backgroundColor = colorScheme == .dark ? UIColor(controller.config.fillColor) : .secondarySystemBackground
if becomeFirstResponder?.wrappedValue == true {
DispatchQueue.main.async {
uiView.becomeFirstResponder()
becomeFirstResponder!.wrappedValue = false
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(controller: controller, text: $text, focusNextView: focusNextView)
}
class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
let controller: ComposeController
var text: Binding<String>
var focusNextView: Binding<Bool>?
var maxLength: Int?
@Published var autocompleteState: AutocompleteState?
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { $autocompleteState }
weak var textField: UITextField?
init(controller: ComposeController, text: Binding<String>, focusNextView: Binding<Bool>?, maxLength: Int? = nil) {
self.controller = controller
self.text = text
self.focusNextView = focusNextView
self.maxLength = maxLength
}
@objc func didChange(_ textField: UITextField) {
text.wrappedValue = textField.text ?? ""
}
@objc func returnKeyPressed() {
focusNextView?.wrappedValue = true
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if let maxLength {
return ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string).count <= maxLength
} else {
return true
}
}
func textFieldDidBeginEditing(_ textField: UITextField) {
controller.currentInput = self
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
}
func textFieldDidEndEditing(_ textField: UITextField) {
controller.currentInput = nil
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
}
func textFieldDidChangeSelection(_ textField: UITextField) {
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
}
// MARK: ComposeInput
var toolbarElements: [ToolbarElement] { [.emojiPicker] }
var textInputMode: UITextInputMode? {
textField?.textInputMode
}
func applyFormat(_ format: StatusFormat) {
}
func beginAutocompletingEmoji() {
textField?.insertText(":")
}
func autocomplete(with string: String) {
textField?.autocomplete(with: string, permittedModes: .emojis, autocompleteState: &autocompleteState)
}
}
}

View File

@ -1,31 +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)
}
}

View File

@ -1,201 +0,0 @@
//
// LanguagePicker.swift
// ComposeUI
//
// Created by Shadowfacts on 5/4/23.
//
import SwiftUI
@available(iOS 16.0, *)
struct LanguagePicker: View {
@Binding var draftLanguage: String?
@Binding var hasChangedSelection: Bool
@State private var isShowingSheet = false
private var codeFromDraft: Locale.LanguageCode? {
draftLanguage.map(Locale.LanguageCode.init(_:))
}
private var codeFromActiveInputMode: Locale.LanguageCode? {
UITextInputMode.activeInputModes.first.flatMap(Self.codeFromInputMode(_:))
}
static func codeFromInputMode(_ mode: UITextInputMode) -> Locale.LanguageCode? {
guard let bcp47Lang = mode.primaryLanguage else {
return nil
}
var maybeIso639Code = bcp47Lang[..<bcp47Lang.index(bcp47Lang.startIndex, offsetBy: 3)]
if maybeIso639Code.last == "-" {
maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)]
}
let code = Locale.LanguageCode(String(maybeIso639Code))
if code.isISOLanguage {
return code
} else {
return nil
}
}
private var codeFromPreferredLanguages: Locale.LanguageCode? {
if let identifier = Locale.preferredLanguages.first {
let code = Locale.LanguageCode(identifier)
if code.isISOLanguage {
return code
} else {
return nil
}
} else {
return nil
}
}
private var languageCode: Binding<Locale.LanguageCode> {
Binding {
return codeFromDraft ?? codeFromActiveInputMode ?? codeFromPreferredLanguages ?? .english
} set: { newValue in
draftLanguage = newValue.identifier
}
}
var body: some View {
Button {
isShowingSheet = true
} label: {
Text((languageCode.wrappedValue.identifier(.alpha2) ?? languageCode.wrappedValue.identifier).uppercased())
}
.accessibilityLabel("Post Language")
.sheet(isPresented: $isShowingSheet) {
NavigationStack {
LanguagePickerList(languageCode: languageCode, hasChangedSelection: $hasChangedSelection, isPresented: $isShowingSheet)
}
.presentationDetents([.large, .medium])
}
}
}
@available(iOS 16.0, *)
private struct LanguagePickerList: View {
@Binding var languageCode: Locale.LanguageCode
@Binding var hasChangedSelection: Bool
@Binding var isPresented: Bool
@Environment(\.composeUIConfig.groupedBackgroundColor) private var groupedBackgroundColor
@Environment(\.composeUIConfig.groupedCellBackgroundColor) private var groupedCellBackgroundColor
@State private var recentLangs: [Lang] = []
@State private var langs: [Lang] = []
@State private var filteredLangs: [Lang]?
@State private var query = ""
private var defaults: UserDefaults {
UserDefaults(suiteName: "group.space.vaccor.Tusker") ?? .standard
}
private var recentIdentifiers: [String] {
get {
defaults.object(forKey: "LanguagePickerRecents") as? [String] ?? []
}
nonmutating set {
defaults.set(newValue, forKey: "LanguagePickerRecents")
}
}
var body: some View {
List {
Section {
ForEach(recentLangs) { lang in
button(for: lang)
}
.listRowBackground(groupedCellBackgroundColor)
} header: {
Text("Recently Used")
}
Section {
ForEach(filteredLangs ?? langs) { lang in
button(for: lang)
}
.listRowBackground(groupedCellBackgroundColor)
} header: {
Text("All Languages")
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.background(groupedBackgroundColor.edgesIgnoringSafeArea(.all))
.searchable(text: $query)
.scrollDismissesKeyboard(.interactively)
.navigationTitle("Post Language")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
isPresented = false
}
}
}
.onAppear {
// make sure recents always contains the currently selected lang
let recents = addRecentLang(languageCode)
recentLangs = recents
.map { Lang(code: .init($0)) }
.sorted { $0.name < $1.name }
langs = Locale.LanguageCode.isoLanguageCodes
.map { Lang(code: $0) }
.sorted { $0.name < $1.name }
}
.onChange(of: query) { newValue in
if newValue.isEmpty {
filteredLangs = nil
} else {
filteredLangs = langs.filter {
$0.name.localizedCaseInsensitiveContains(newValue) || $0.code.identifier.localizedCaseInsensitiveContains(newValue)
}
}
}
}
@discardableResult
private func addRecentLang(_ code: Locale.LanguageCode) -> [String] {
var recents = recentIdentifiers
if !recents.contains(languageCode.identifier) {
recents.insert(languageCode.identifier, at: 0)
if recents.count > 5 {
recents = Array(recents[..<5])
}
recentIdentifiers = recents
}
return recents
}
private func button(for lang: Lang) -> some View {
Button {
languageCode = lang.code
hasChangedSelection = true
isPresented = false
addRecentLang(lang.code)
} label: {
HStack {
Text(lang.name)
Spacer()
if lang.code == languageCode {
Image(systemName: "checkmark")
}
}
}
}
struct Lang: Identifiable {
let code: Locale.LanguageCode
let name: String
var id: String {
code.identifier
}
init(code: Locale.LanguageCode) {
self.code = code
self.name = Locale.current.localizedString(forLanguageCode: code.identifier) ?? code.identifier
}
}
}

View File

@ -1,309 +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
}
var body: some View {
ZStack(alignment: .topLeading) {
colorScheme == .dark ? config.fillColor : Color(uiColor: .secondarySystemBackground)
if draft.text.isEmpty {
ControllerView(controller: { PlaceholderController() })
.font(.system(size: fontSize))
.foregroundColor(.secondary)
.offset(x: 4, y: 8)
.accessibilityHidden(true)
}
MainWrappedTextViewRepresentable(text: $draft.text, becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder, updateSelection: $updateSelection, textDidChange: textDidChange)
}
.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
@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.backgroundColor = .clear
textView.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20))
textView.adjustsFontForContentSizeCategory = true
textView.textContainer.lineBreakMode = .byWordWrapping
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
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
var image: UIImage?
if let imageName = fmt.imageName {
image = UIImage(systemName: imageName)
}
return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in
self?.applyFormat(fmt)
}
})
actions[index] = newFormatMenu
} else {
actions.remove(at: index)
}
}
if range.length == 0 {
actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in
self?.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

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

@ -1,29 +0,0 @@
//
// WrappedProgressView.swift
// Tusker
//
// Created by Shadowfacts on 8/30/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
struct WrappedProgressView: UIViewRepresentable {
typealias UIViewType = UIProgressView
let value: Int
let total: Int
func makeUIView(context: Context) -> UIProgressView {
return UIProgressView(progressViewStyle: .bar)
}
func updateUIView(_ uiView: UIProgressView, context: Context) {
if total > 0 {
let progress = Float(value) / Float(total)
uiView.setProgress(progress, animated: true)
} else {
uiView.setProgress(0, animated: true)
}
}
}

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

@ -1,25 +0,0 @@
//
// FuzzyMatcherTests.swift
// ComposeUITests
//
// Created by Shadowfacts on 10/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import XCTest
@testable import ComposeUI
class FuzzyMatcherTests: XCTestCase {
func testExample() throws {
XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "foo").score, 6)
XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "faoao").score, 4)
XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "aaa").score, -6)
XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "baz").score, 2)
XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "bur").score, 1)
XCTAssertGreaterThan(FuzzyMatcher.match(pattern: "sir", str: "sir").score, FuzzyMatcher.match(pattern: "sir", str: "georgespolitzer").score)
}
}

View File

@ -10,8 +10,6 @@ import UIKit
public protocol DuckableViewController: UIViewController { public protocol DuckableViewController: UIViewController {
var duckableDelegate: DuckableViewControllerDelegate? { get set } var duckableDelegate: DuckableViewControllerDelegate? { get set }
func duckableViewControllerShouldDuck() -> DuckAttemptAction
func duckableViewControllerMayAttemptToDuck() func duckableViewControllerMayAttemptToDuck()
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat)
@ -20,7 +18,6 @@ public protocol DuckableViewController: UIViewController {
} }
extension DuckableViewController { extension DuckableViewController {
public func duckableViewControllerShouldDuck() -> DuckAttemptAction { .duck }
public func duckableViewControllerMayAttemptToDuck() {} public func duckableViewControllerMayAttemptToDuck() {}
public func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {} public func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {}
public func duckableViewControllerDidFinishAnimatingDuck() {} public func duckableViewControllerDidFinishAnimatingDuck() {}
@ -30,12 +27,6 @@ public protocol DuckableViewControllerDelegate: AnyObject {
func duckableViewControllerWillDismiss(animated: Bool) func duckableViewControllerWillDismiss(animated: Bool)
} }
public enum DuckAttemptAction {
case duck
case dismiss
case block
}
extension UIViewController { extension UIViewController {
@available(iOS 16.0, *) @available(iOS 16.0, *)
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool { public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool {

View File

@ -63,9 +63,6 @@ class DuckAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) { let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
presented.view.layer.opacity = 0 presented.view.layer.opacity = 0
} }
fadeAnimator.addCompletion { _ in
presented.view.layer.opacity = 1
}
fadeAnimator.startAnimation(afterDelay: 0.3) fadeAnimator.startAnimation(afterDelay: 0.3)
} else { } else {
@ -83,7 +80,6 @@ class DuckAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
presented.view.layer.opacity = 0 presented.view.layer.opacity = 0
} }
fadeAnimator.addCompletion { _ in fadeAnimator.addCompletion { _ in
presented.view.layer.opacity = 1
duckable.duckableViewControllerDidFinishAnimatingDuck() duckable.duckableViewControllerDidFinishAnimatingDuck()
transitionContext.completeTransition(true) transitionContext.completeTransition(true)
} }

View File

@ -88,9 +88,10 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) { private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
viewController.duckableDelegate = self viewController.duckableDelegate = self
viewController.modalPresentationStyle = .custom let nav = UINavigationController(rootViewController: viewController)
viewController.transitioningDelegate = self nav.modalPresentationStyle = .custom
present(viewController, animated: animated) { nav.transitioningDelegate = self
present(nav, animated: animated) {
self.configureChildForDuckedPlaceholder() self.configureChildForDuckedPlaceholder()
completion?() completion?()
} }
@ -135,18 +136,10 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
guard case .presentingDucked(let viewController, isFirstPresentation: _) = state else { guard case .presentingDucked(let viewController, isFirstPresentation: _) = state else {
return return
} }
switch viewController.duckableViewControllerShouldDuck() {
case .duck:
let placeholder = createPlaceholderForDuckedViewController(viewController) let placeholder = createPlaceholderForDuckedViewController(viewController)
state = .ducked(viewController, placeholder: placeholder) state = .ducked(viewController, placeholder: placeholder)
configureChildForDuckedPlaceholder() configureChildForDuckedPlaceholder()
dismiss(animated: true) dismiss(animated: true)
case .block:
viewController.sheetPresentationController!.selectedDetentIdentifier = .large
case .dismiss:
duckableViewControllerWillDismiss(animated: true)
dismiss(animated: true)
}
} }
private func configureChildForDuckedPlaceholder() { private func configureChildForDuckedPlaceholder() {
@ -155,7 +148,6 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
bottomConstraint.isActive = true bottomConstraint.isActive = true
child.view.layer.cornerRadius = duckedCornerRadius child.view.layer.cornerRadius = duckedCornerRadius
child.view.layer.cornerCurve = .continuous
child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
child.view.layer.masksToBounds = true child.view.layer.masksToBounds = true
} }
@ -244,3 +236,4 @@ extension DuckableContainerViewController: UISheetPresentationControllerDelegate
} }
} }
} }

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,23 +0,0 @@
{
"pins" : [
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "025bcb1165deab2e20d4eaba79967ce73013f496",
"version" : "1.2.1"
}
},
{
"identity" : "swift-url",
"kind" : "remoteSourceControl",
"location" : "https://github.com/karwa/swift-url.git",
"state" : {
"branch" : "main",
"revision" : "220f6ab9d8a7e0742f85eb9f21b745942e001ae6"
}
}
],
"version" : 2
}

View File

@ -1,31 +0,0 @@
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "InstanceFeatures",
platforms: [
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "InstanceFeatures",
targets: ["InstanceFeatures"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
.package(path: "../Pachyderm"),
],
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: "InstanceFeatures",
dependencies: ["Pachyderm"]),
.testTarget(
name: "InstanceFeaturesTests",
dependencies: ["InstanceFeatures"]),
]
)

View File

@ -1,3 +0,0 @@
# InstanceFeatures
A description of this package.

View File

@ -1,80 +0,0 @@
//
// Version.swift
// InstanceFeatures
//
// Created by Shadowfacts on 5/14/23.
//
import Foundation
@_spi(InstanceType) public struct Version: Equatable, Comparable, CustomStringConvertible {
private static let regex = try! NSRegularExpression(pattern: "^(\\d+)\\.(\\d+)\\.(\\d+).*$")
let major: Int
let minor: Int
let patch: Int
init(_ major: Int, _ minor: Int, _ patch: Int) {
self.major = major
self.minor = minor
self.patch = patch
}
init?(string: String) {
guard let match = Version.regex.firstMatch(in: string, range: NSRange(location: 0, length: string.utf16.count)),
match.numberOfRanges == 4 else {
return nil
}
let majorStr = (string as NSString).substring(with: match.range(at: 1))
let minorStr = (string as NSString).substring(with: match.range(at: 2))
let patchStr = (string as NSString).substring(with: match.range(at: 3))
guard let major = Int(majorStr),
let minor = Int(minorStr),
let patch = Int(patchStr) else {
return nil
}
self.major = major
self.minor = minor
self.patch = patch
}
public var description: String {
"\(major).\(minor).\(patch)"
}
public static func ==(lhs: Version, rhs: Version) -> Bool {
return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch
}
public static func <(lhs: Version, rhs: Version) -> Bool {
if lhs.major < rhs.major {
return true
} else if lhs.major > rhs.major {
return false
} else if lhs.minor < rhs.minor {
return true
} else if lhs.minor > rhs.minor {
return false
} else if lhs.patch < rhs.patch {
return true
} else {
return false
}
}
}
func <(lhs: Version?, rhs: Version) -> Bool {
guard let lhs else {
// nil is less than or equal to everything
return true
}
return lhs < rhs
}
func >=(lhs: Version?, rhs: Version) -> Bool {
guard let lhs else {
// nil is less than or equal to everything
return false
}
return lhs >= rhs
}

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,26 +0,0 @@
// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "MatchedGeometryPresentation",
platforms: [
.iOS(.v15),
],
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"),
// .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,238 +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
}
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

@ -7,7 +7,6 @@
// //
import Foundation import Foundation
import WebURL
/** /**
The base Mastodon API client. The base Mastodon API client.
@ -84,7 +83,7 @@ public class Client {
completion(.failure(Error(request: request, type: .invalidResponse))) completion(.failure(Error(request: request, type: .invalidResponse)))
return return
} }
guard response.statusCode == 200 || request.additionalAcceptableHTTPCodes.contains(response.statusCode) else { guard response.statusCode == 200 else {
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data) let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
let type: ErrorType = mastodonError.flatMap { .mastodonError(response.statusCode, $0.description) } ?? .unexpectedStatus(response.statusCode) let type: ErrorType = mastodonError.flatMap { .mastodonError(response.statusCode, $0.description) } ?? .unexpectedStatus(response.statusCode)
completion(.failure(Error(request: request, type: type))) completion(.failure(Error(request: request, type: type)))
@ -140,14 +139,13 @@ public class Client {
} }
} }
public func getAccessToken(authorizationCode: String, redirectURI: String, scopes: [Scope], completion: @escaping Callback<LoginSettings>) { public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([ let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([
"client_id" => clientID, "client_id" => clientID,
"client_secret" => clientSecret, "client_secret" => clientSecret,
"grant_type" => "authorization_code", "grant_type" => "authorization_code",
"code" => authorizationCode, "code" => authorizationCode,
"redirect_uri" => redirectURI, "redirect_uri" => redirectURI
"scope" => scopes.scopeString,
])) ]))
run(request) { result in run(request) { result in
defer { completion(result) } defer { completion(result) }
@ -187,9 +185,9 @@ public class Client {
case let .success(wellKnown, _): case let .success(wellKnown, _):
if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }), if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
let href = WebURL(url.href), let components = URLComponents(string: url.href),
href.host == WebURL(self.baseURL)?.host { components.host == self.baseURL.host {
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path)) let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: components.path))
self.run(nodeInfo, completion: completion) self.run(nodeInfo, completion: completion)
} }
} }
@ -315,13 +313,6 @@ public class Client {
], attachment)) ], attachment))
} }
public static func updateAttachment(id: String, description: String?, focus: (Float, Float)?) -> Request<Attachment> {
return Request(method: .put, path: "/api/v1/media/\(id)", body: FormDataBody([
"description" => description,
"focus" => focus
], nil))
}
// MARK: - Mutes // MARK: - Mutes
public static func getMutes(range: RequestRange) -> Request<[Account]> { public static func getMutes(range: RequestRange) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/mutes") var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
@ -389,11 +380,11 @@ public class Client {
public static func createStatus(text: String, public static func createStatus(text: String,
contentType: StatusContentType = .plain, contentType: StatusContentType = .plain,
inReplyTo: String? = nil, inReplyTo: String? = nil,
mediaIDs: [String]? = nil, media: [Attachment]? = nil,
sensitive: Bool? = nil, sensitive: Bool? = nil,
spoilerText: String? = nil, spoilerText: String? = nil,
visibility: Visibility? = nil, visibility: Status.Visibility? = nil,
language: String? = nil, // language supported by mastodon and akkoma language: String? = nil,
pollOptions: [String]? = nil, pollOptions: [String]? = nil,
pollExpiresIn: Int? = nil, pollExpiresIn: Int? = nil,
pollMultiple: Bool? = nil, pollMultiple: Bool? = nil,
@ -409,32 +400,7 @@ public class Client {
"poll[expires_in]" => pollExpiresIn, "poll[expires_in]" => pollExpiresIn,
"poll[multiple]" => pollMultiple, "poll[multiple]" => pollMultiple,
"local_only" => localOnly, "local_only" => localOnly,
] + "media_ids" => mediaIDs + "poll[options]" => pollOptions)) ] + "media_ids" => media?.map { $0.id } + "poll[options]" => pollOptions))
}
public static func editStatus(
id: String,
text: String,
contentType: StatusContentType = .plain,
spoilerText: String?,
sensitive: Bool,
language: String?,
mediaIDs: [String],
mediaAttributes: [EditStatusMediaAttributes],
poll: EditPollParameters?
) -> Request<Status> {
let params = EditStatusParameters(
id: id,
text: text,
contentType: contentType,
spoilerText: spoilerText,
sensitive: sensitive,
language: language,
mediaIDs: mediaIDs,
mediaAttributes: mediaAttributes,
poll: poll
)
return Request(method: .put, path: "/api/v1/statuses/\(id)", body: JsonBody(params))
} }
// MARK: - Timelines // MARK: - Timelines
@ -515,12 +481,6 @@ public class Client {
]) ])
} }
// MARK: - Hashtags
/// Requires Mastodon 4.0.0+
public static func getHashtag(name: String) -> Request<Hashtag> {
return Request(method: .get, path: "/api/v1/tags/\(name)")
}
} }
extension Client { extension Client {
@ -539,15 +499,13 @@ extension Client {
self.type = type self.type = type
} }
public var errorDescription: String? { public var localizedDescription: String {
switch type { switch type {
case .networkError(let error): case .networkError(let error):
return "Network Error: \(error.localizedDescription)" return "Network Error: \(error.localizedDescription)"
// todo: support more status codes // todo: support more status codes
case .unexpectedStatus(413): case .unexpectedStatus(413):
return "HTTP 413: Payload Too Large" return "HTTP 413: Payload Too Large"
case .unexpectedStatus(429):
return "HTTP 429: Rate Limit Exceeded"
case .unexpectedStatus(let code): case .unexpectedStatus(let code):
return "HTTP Code \(code)" return "HTTP Code \(code)"
case .invalidRequest: case .invalidRequest:

View File

@ -70,12 +70,12 @@ public final class Account: AccountProtocol, Decodable, Sendable {
} }
} }
public static func authorizeFollowRequest(_ accountID: String) -> Request<Relationship> { public static func authorizeFollowRequest(_ account: Account) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(accountID)/authorize") return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(account.id)/authorize")
} }
public static func rejectFollowRequest(_ accountID: String) -> Request<Relationship> { public static func rejectFollowRequest(_ account: Account) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(accountID)/reject") return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(account.id)/reject")
} }
public static func removeFromFollowRequests(_ account: Account) -> Request<Empty> { public static func removeFromFollowRequests(_ account: Account) -> Request<Empty> {

View File

@ -1,97 +0,0 @@
//
// EditStatusParameters.swift
// Pachyderm
//
// Created by Shadowfacts on 5/10/23.
//
import Foundation
struct EditStatusParameters: Encodable, Sendable {
let id: String
let text: String
let contentType: StatusContentType
let spoilerText: String?
let sensitive: Bool
let language: String?
let mediaIDs: [String]
let mediaAttributes: [EditStatusMediaAttributes]
let poll: EditPollParameters?
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.id, forKey: .id)
try container.encode(self.text, forKey: .text)
try container.encode(self.contentType.mimeType, forKey: .contentType)
try container.encodeIfPresent(self.spoilerText, forKey: .spoilerText)
try container.encode(self.sensitive, forKey: .sensitive)
try container.encodeIfPresent(self.language, forKey: .language)
try container.encode(self.mediaIDs, forKey: .mediaIDs)
try container.encode(self.mediaAttributes, forKey: .mediaAttributes)
try container.encodeIfPresent(self.poll, forKey: .poll)
}
enum CodingKeys: String, CodingKey {
case id
case text = "status"
case contentType = "content_type"
case spoilerText = "spoiler_text"
case sensitive
case language
case mediaIDs = "media_ids"
case mediaAttributes = "media_attributes"
case poll
}
}
public struct EditPollParameters: Encodable, Sendable {
let options: [String]
let expiresIn: Int
let multiple: Bool
public init(options: [String], expiresIn: Int, multiple: Bool) {
self.options = options
self.expiresIn = expiresIn
self.multiple = multiple
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.options, forKey: .options)
try container.encode(self.expiresIn, forKey: .expiresIn)
try container.encode(self.multiple, forKey: .multiple)
}
enum CodingKeys: String, CodingKey {
case options
case expiresIn = "expires_in"
case multiple
}
}
public struct EditStatusMediaAttributes: Encodable, Sendable {
let id: String
let description: String
let focus: (Float, Float)?
public init(id: String, description: String, focus: (Float, Float)?) {
self.id = id
self.description = description
self.focus = focus
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(description, forKey: .description)
if let focus {
try container.encode("\(focus.0),\(focus.1)", forKey: .focus)
}
}
enum CodingKeys: String, CodingKey {
case id
case description
case focus
}
}

View File

@ -64,6 +64,6 @@ extension Hashtag: Equatable, Hashable {
} }
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {
hasher.combine(name) hasher.combine(url)
} }
} }

View File

@ -24,13 +24,6 @@ public struct Mention: Codable, Sendable {
self.url = try container.decode(WebURL.self, forKey: .url) self.url = try container.decode(WebURL.self, forKey: .url)
} }
public init(url: WebURL, username: String, acct: String, id: String) {
self.url = url
self.username = username
self.acct = acct
self.id = id
}
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case url case url
case username case username

View File

@ -30,7 +30,9 @@ public struct Notification: Decodable, Sendable {
} }
public static func dismiss(id notificationID: String) -> Request<Empty> { public static func dismiss(id notificationID: String) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/notifications/\(notificationID)/dismiss") return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: ParametersBody([
"id" => notificationID
]))
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {

View File

@ -24,20 +24,6 @@ public struct Poll: Codable, Sendable {
expired || (expiresAt != nil && expiresAt! < Date()) expired || (expiresAt != nil && expiresAt! < Date())
} }
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id)
self.expiresAt = try container.decodeIfPresent(Date.self, forKey: .expiresAt)
self.expired = try container.decode(Bool.self, forKey: .expired)
self.multiple = try container.decode(Bool.self, forKey: .multiple)
self.votesCount = try container.decode(Int.self, forKey: .votesCount)
self.votersCount = try container.decodeIfPresent(Int.self, forKey: .votersCount)
self.voted = try container.decodeIfPresent(Bool.self, forKey: .voted)
self.ownVotes = try container.decodeIfPresent([Int].self, forKey: .ownVotes)
self.options = try container.decode([Poll.Option].self, forKey: .options)
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
}
public static func vote(_ pollID: String, choices: [Int]) -> Request<Poll> { public static func vote(_ pollID: String, choices: [Int]) -> Request<Poll> {
return Request<Poll>(method: .post, path: "/api/v1/polls/\(pollID)/votes", body: FormDataBody("choices" => choices, nil)) return Request<Poll>(method: .post, path: "/api/v1/polls/\(pollID)/votes", body: FormDataBody("choices" => choices, nil))
} }

View File

@ -9,6 +9,7 @@
import Foundation import Foundation
public protocol AccountProtocol { public protocol AccountProtocol {
associatedtype Account: AccountProtocol
var id: String { get } var id: String { get }
var username: String { get } var username: String { get }
@ -26,7 +27,7 @@ public protocol AccountProtocol {
var moved: Bool? { get } var moved: Bool? { get }
var bot: Bool? { get } var bot: Bool? { get }
var movedTo: Self? { get } var movedTo: Account? { get }
var emojis: [Emoji] { get } var emojis: [Emoji] { get }
var fields: [Pachyderm.Account.Field] { get } var fields: [Pachyderm.Account.Field] { get }
} }

View File

@ -1,21 +0,0 @@
//
// RelationshipProtocol.swift
// Pachyderm
//
// Created by Shadowfacts on 3/26/23.
//
import Foundation
public protocol RelationshipProtocol {
var accountID: String { get }
var following: Bool { get }
var followedBy: Bool { get }
var blocking: Bool { get }
var muting: Bool { get }
var mutingNotifications: Bool { get }
var followRequested: Bool { get }
var domainBlocking: Bool { get }
var showingReblogs: Bool { get }
var endorsed: Bool { get }
}

View File

@ -25,7 +25,7 @@ public protocol StatusProtocol {
// var favourited: Bool { get } // var favourited: Bool { get }
var sensitive: Bool { get } var sensitive: Bool { get }
var spoilerText: String { get } var spoilerText: String { get }
var visibility: Visibility { get } var visibility: Pachyderm.Status.Visibility { get }
var applicationName: String? { get } var applicationName: String? { get }
var pinned: Bool? { get } var pinned: Bool? { get }
var bookmarked: Bool? { get } var bookmarked: Bool? { get }

View File

@ -8,8 +8,8 @@
import Foundation import Foundation
public struct Relationship: RelationshipProtocol, Decodable, Sendable { public struct Relationship: Decodable, Sendable {
public let accountID: String public let id: String
public let following: Bool public let following: Bool
public let followedBy: Bool public let followedBy: Bool
public let blocking: Bool public let blocking: Bool
@ -18,21 +18,7 @@ public struct Relationship: RelationshipProtocol, Decodable, Sendable {
public let followRequested: Bool public let followRequested: Bool
public let domainBlocking: Bool public let domainBlocking: Bool
public let showingReblogs: Bool public let showingReblogs: Bool
public let endorsed: Bool public let endorsed: Bool?
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.accountID = try container.decode(String.self, forKey: .id)
self.following = try container.decode(Bool.self, forKey: .following)
self.followedBy = try container.decode(Bool.self, forKey: .followedBy)
self.blocking = try container.decode(Bool.self, forKey: .blocking)
self.muting = try container.decode(Bool.self, forKey: .muting)
self.mutingNotifications = try container.decode(Bool.self, forKey: .mutingNotifications)
self.followRequested = try container.decode(Bool.self, forKey: .followRequested)
self.domainBlocking = try container.decode(Bool.self, forKey: .domainBlocking)
self.showingReblogs = try container.decode(Bool.self, forKey: .showingReblogs)
self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false
}
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id case id

View File

@ -41,7 +41,6 @@ public final class Status: StatusProtocol, Decodable, Sendable {
public let poll: Poll? public let poll: Poll?
// Hometown, Glitch only // Hometown, Glitch only
public let localOnly: Bool? public let localOnly: Bool?
public let editedAt: Date?
public var applicationName: String? { application?.name } public var applicationName: String? { application?.name }
@ -49,23 +48,14 @@ public final class Status: StatusProtocol, Decodable, Sendable {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id) self.id = try container.decode(String.self, forKey: .id)
self.uri = try container.decode(String.self, forKey: .uri) self.uri = try container.decode(String.self, forKey: .uri)
do {
self.url = try container.decodeIfPresent(WebURL.self, forKey: .url) self.url = try container.decodeIfPresent(WebURL.self, forKey: .url)
} catch {
let s = try? container.decode(String.self, forKey: .url)
if s == "" {
self.url = nil
} else {
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath + [CodingKeys.url], debugDescription: "Could not decode URL '\(s ?? "<failed to decode string>")'", underlyingError: error))
}
}
self.account = try container.decode(Account.self, forKey: .account) self.account = try container.decode(Account.self, forKey: .account)
self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID) self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID)
self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID) self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID)
self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog) self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog)
self.content = try container.decode(String.self, forKey: .content) self.content = try container.decode(String.self, forKey: .content)
self.createdAt = try container.decode(Date.self, forKey: .createdAt) self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? [] self.emojis = try container.decode([Emoji].self, forKey: .emojis)
self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount) self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount)
self.favouritesCount = try container.decode(Int.self, forKey: .favouritesCount) self.favouritesCount = try container.decode(Int.self, forKey: .favouritesCount)
self.reblogged = try container.decodeIfPresent(Bool.self, forKey: .reblogged) self.reblogged = try container.decodeIfPresent(Bool.self, forKey: .reblogged)
@ -73,7 +63,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.muted = try container.decodeIfPresent(Bool.self, forKey: .muted) self.muted = try container.decodeIfPresent(Bool.self, forKey: .muted)
self.sensitive = try container.decode(Bool.self, forKey: .sensitive) self.sensitive = try container.decode(Bool.self, forKey: .sensitive)
self.spoilerText = try container.decode(String.self, forKey: .spoilerText) self.spoilerText = try container.decode(String.self, forKey: .spoilerText)
if let visibility = try? container.decode(Visibility.self, forKey: .visibility) { if let visibility = try? container.decode(Status.Visibility.self, forKey: .visibility) {
self.visibility = visibility self.visibility = visibility
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly)
} else if let s = try? container.decode(String.self, forKey: .visibility), } else if let s = try? container.decode(String.self, forKey: .visibility),
@ -93,7 +83,6 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.bookmarked = try container.decodeIfPresent(Bool.self, forKey: .bookmarked) self.bookmarked = try container.decodeIfPresent(Bool.self, forKey: .bookmarked)
self.card = try container.decodeIfPresent(Card.self, forKey: .card) self.card = try container.decodeIfPresent(Card.self, forKey: .card)
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll) self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
self.editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt)
} }
public static func getContext(_ statusID: String) -> Request<ConversationContext> { public static func getContext(_ statusID: String) -> Request<ConversationContext> {
@ -165,14 +154,6 @@ public final class Status: StatusProtocol, Decodable, Sendable {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unmute") return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unmute")
} }
public static func source(_ statusID: String) -> Request<StatusSource> {
return Request(method: .get, path: "/api/v1/statuses/\(statusID)/source")
}
public static func history(_ statusID: String) -> Request<[StatusEdit]> {
return Request(method: .get, path: "/api/v1/statuses/\(statusID)/history")
}
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id case id
case uri case uri
@ -203,7 +184,15 @@ public final class Status: StatusProtocol, Decodable, Sendable {
case card case card
case poll case poll
case localOnly = "local_only" case localOnly = "local_only"
case editedAt = "edited_at" }
}
extension Status {
public enum Visibility: String, Codable, CaseIterable, Sendable {
case `public`
case unlisted
case `private`
case direct
} }
} }

View File

@ -1,38 +0,0 @@
//
// StatusEdit.swift
// Pachyderm
//
// Created by Shadowfacts on 5/11/23.
//
import Foundation
public struct StatusEdit: Decodable {
public let content: String
public let spoilerText: String
public let sensitive: Bool
public let createdAt: Date
public let account: Account
public let poll: Poll?
public let attachments: [Attachment]
public let emojis: [Emoji]
enum CodingKeys: String, CodingKey {
case content
case spoilerText = "spoiler_text"
case sensitive
case createdAt = "created_at"
case account = "account"
case poll
case attachments = "media_attachments"
case emojis
}
public struct Poll: Decodable {
public let options: [Option]
public struct Option: Decodable {
public let title: String
}
}
}

View File

@ -1,20 +0,0 @@
//
// StatusSource.swift
// Pachyderm
//
// Created by Shadowfacts on 5/10/23.
//
import Foundation
public struct StatusSource: Decodable {
public let id: String
public let text: String
public let spoilerText: String
enum CodingKeys: String, CodingKey {
case id
case text
case spoilerText = "spoiler_text"
}
}

View File

@ -38,8 +38,6 @@ extension Timeline {
request.queryParameters.append("local" => true) request.queryParameters.append("local" => true)
} }
request.range = range request.range = range
// 206 can happen when the timeline is being regenerated and therefore is incomplete
request.additionalAcceptableHTTPCodes = [206]
return request return request
} }
} }

View File

@ -33,13 +33,8 @@ public struct Endpoint: ExpressibleByStringInterpolation, CustomStringConvertibl
switch $0 { switch $0 {
case .literal(let s): case .literal(let s):
return s return s
#if DEBUG
case .interpolated(let s):
return s
#else
case .interpolated(_): case .interpolated(_):
return "<redacted>" return "<redacted>"
#endif
} }
}.joined(separator: "") }.joined(separator: "")
} }

View File

@ -13,7 +13,6 @@ public struct Request<ResultType: Decodable>: Sendable {
let endpoint: Endpoint let endpoint: Endpoint
let body: Body let body: Body
var queryParameters: [Parameter] var queryParameters: [Parameter]
var additionalAcceptableHTTPCodes: [Int] = []
init(method: Method, path: Endpoint, body: Body = EmptyBody(), queryParameters: [Parameter] = []) { init(method: Method, path: Endpoint, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
self.method = method self.method = method

View File

@ -1,25 +1,24 @@
// //
// CharacterCounter.swift // CharacterCounter.swift
// ComposeUI // Pachyderm
// //
// Created by Shadowfacts on 9/29/18. // Created by Shadowfacts on 9/29/18.
// Copyright © 2018 Shadowfacts. All rights reserved. // Copyright © 2018 Shadowfacts. All rights reserved.
// //
import Foundation import Foundation
import InstanceFeatures
public struct CharacterCounter { public struct CharacterCounter {
private static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
private static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive) static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
public static func count(text: String, for instanceFeatures: InstanceFeatures) -> Int { public static func count(text: String, for instance: Instance? = nil) -> Int {
let mentionsRemoved = removeMentions(in: text) let mentionsRemoved = removeMentions(in: text)
var count = mentionsRemoved.count var count = mentionsRemoved.count
for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) { for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) {
count -= match.range.length count -= match.range.length
count += instanceFeatures.charsReservedPerURL count += instance?.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length
} }
return count return count
} }

View File

@ -12,7 +12,6 @@ import Foundation
public final class CollapseState: Sendable { public final class CollapseState: Sendable {
public var collapsible: Bool? public var collapsible: Bool?
public var collapsed: Bool? public var collapsed: Bool?
public var statusPropertiesHash: Int?
public var unknown: Bool { public var unknown: Bool {
collapsible == nil || collapsed == nil collapsible == nil || collapsed == nil
@ -24,9 +23,7 @@ public final class CollapseState: Sendable {
} }
public func copy() -> CollapseState { public func copy() -> CollapseState {
let new = CollapseState(collapsible: self.collapsible, collapsed: self.collapsed) return CollapseState(collapsible: self.collapsible, collapsed: self.collapsed)
new.statusPropertiesHash = self.statusPropertiesHash
return new
} }
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {

View File

@ -8,16 +8,24 @@
import Foundation import Foundation
public struct NotificationGroup: Identifiable, Hashable, Sendable { public struct NotificationGroup: Identifiable, Hashable {
public private(set) var notifications: [Notification] public private(set) var notifications: [Notification]
public let id: String public let id: String
public let kind: Notification.Kind public let kind: Notification.Kind
public let statusState: CollapseState?
public init?(notifications: [Notification]) { @MainActor
init?(notifications: [Notification]) {
guard !notifications.isEmpty else { return nil } guard !notifications.isEmpty else { return nil }
self.notifications = notifications self.notifications = notifications
self.id = notifications.first!.id self.id = notifications.first!.id
self.kind = notifications.first!.kind self.kind = notifications.first!.kind
switch kind {
case .mention, .status:
self.statusState = .unknown
default:
self.statusState = nil
}
} }
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool { public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {

View File

@ -1,14 +1,13 @@
// //
// CharacterCounterTests.swift // CharacterCounterTests.swift
// ComposeUITests // PachydermTests
// //
// Created by Shadowfacts on 9/29/18. // Created by Shadowfacts on 9/29/18.
// Copyright © 2018 Shadowfacts. All rights reserved. // Copyright © 2018 Shadowfacts. All rights reserved.
// //
import XCTest import XCTest
@testable import ComposeUI @testable import Pachyderm
import InstanceFeatures
class CharacterCounterTests: XCTestCase { class CharacterCounterTests: XCTestCase {
@ -18,33 +17,31 @@ class CharacterCounterTests: XCTestCase {
override func tearDown() { override func tearDown() {
} }
let features = InstanceFeatures()
func testCountEmpty() { func testCountEmpty() {
XCTAssertEqual(CharacterCounter.count(text: "", for: features), 0) XCTAssertEqual(CharacterCounter.count(text: ""), 0)
} }
func testCountPlainText() { func testCountPlainText() {
XCTAssertEqual(CharacterCounter.count(text: "This is an example message", for: features), 26) XCTAssertEqual(CharacterCounter.count(text: "This is an example message"), 26)
XCTAssertEqual(CharacterCounter.count(text: "This is an example message with an Emoji: 😄", for: features), 43) XCTAssertEqual(CharacterCounter.count(text: "This is an example message with an Emoji: 😄"), 43)
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄", for: features), 7) XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄"), 7)
} }
func testCountLinks() { func testCountLinks() {
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://example.com", for: features), 55) XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://example.com"), 55)
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link 😄: https://example.com", for: features), 57) XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link 😄: https://example.com"), 57)
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄: https://example.com", for: features), 32) XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄: https://example.com"), 32)
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://a.much.longer.example.com/link?foo=bar#baz", for: features), 55) XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://a.much.longer.example.com/link?foo=bar#baz"), 55)
} }
func testCountLocalMentions() { func testCountLocalMentions() {
XCTAssertEqual(CharacterCounter.count(text: "hello @example", for: features), 14) XCTAssertEqual(CharacterCounter.count(text: "hello @example"), 14)
XCTAssertEqual(CharacterCounter.count(text: "@some_really_long_name", for: features), 22) XCTAssertEqual(CharacterCounter.count(text: "@some_really_long_name"), 22)
} }
func testCountRemoteMentions() { func testCountRemoteMentions() {
XCTAssertEqual(CharacterCounter.count(text: "hello @example@some.remote.social", for: features), 14) XCTAssertEqual(CharacterCounter.count(text: "hello @example@some.remote.social"), 14)
XCTAssertEqual(CharacterCounter.count(text: "hello @some_really_long_name@some-long.remote-instance.social", for: features), 28) XCTAssertEqual(CharacterCounter.count(text: "hello @some_really_long_name@some-long.remote-instance.social"), 28)
} }
} }

View File

@ -8,7 +8,6 @@
import XCTest import XCTest
@testable import Pachyderm @testable import Pachyderm
@MainActor
class NotificationGroupTests: XCTestCase { class NotificationGroupTests: XCTestCase {
let decoder: JSONDecoder = { let decoder: JSONDecoder = {

View File

@ -1,53 +0,0 @@
//
// StatusTests.swift
//
//
// Created by Shadowfacts on 5/8/23.
//
import XCTest
@testable import Pachyderm
final class StatusTests: XCTestCase {
func testDecode() {
let data = """
{
"id": "1",
"uri": "https://example.com/a/1",
"url": "",
"account": {
"id": "2",
"username": "a",
"acct": "a",
"display_name": "",
"locked": false,
"created_at": 0,
"followers_count": 0,
"following_count": 0,
"statuses_count": 0,
"note": "",
"url": "https://example.com/a"
},
"content": "",
"created_at": 0,
"emojis": [],
"reblogs_count": 0,
"favourites_count": 0,
"sensitive": false,
"spoiler_text": "",
"visibility": "public",
"media_attachments": [],
"mentions": [],
"tags": []
}
""".data(using: .utf8)!
do {
_ = try JSONDecoder().decode(Status.self, from: data)
} catch {
print(error)
XCTFail()
}
}
}

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,31 +0,0 @@
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "TuskerComponents",
platforms: [
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "TuskerComponents",
targets: ["TuskerComponents"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
],
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: "TuskerComponents",
dependencies: []),
// .testTarget(
// name: "TuskerComponentsTests",
// dependencies: ["TuskerComponents"]),
]
)

View File

@ -1,3 +0,0 @@
# TuskerComponents
A description of this package.

View File

@ -1,70 +0,0 @@
//
// AbbreviatedTimeAgoFormatStyle.swift
//
//
// Created by Shadowfacts on 4/9/23.
//
import Foundation
public struct AbbreviatedTimeAgoFormatStyle: FormatStyle {
public typealias FormatInput = Date
public typealias FormatOutput = String
public func format(_ value: Date) -> String {
let (amount, component) = timeAgo(value: value)
switch component {
case .year:
return "\(amount)y"
case .month:
return "\(amount)mo"
case .weekOfYear:
return "\(amount)w"
case .day:
return "\(amount)d"
case .hour:
return "\(amount)h"
case .minute:
return "\(amount)m"
case .second:
if amount >= 3 {
return "\(amount)s"
} else {
return "Now"
}
default:
fatalError("Unexpected component: \(component)")
}
}
private static let unitFlags = Set<Calendar.Component>([.second, .minute, .hour, .day, .weekOfYear, .month, .year])
private func timeAgo(value: Date) -> (Int, Calendar.Component) {
let calendar = NSCalendar.current
let components = calendar.dateComponents(Self.unitFlags, from: value, to: Date())
if components.year! >= 1 {
return (components.year!, .year)
} else if components.month! >= 1 {
return (components.month!, .month)
} else if components.weekOfYear! >= 1 {
return (components.weekOfYear!, .weekOfYear)
} else if components.day! >= 1 {
return (components.day!, .day)
} else if components.hour! >= 1 {
return (components.hour!, .hour)
} else if components.minute! >= 1 {
return (components.minute!, .minute)
} else {
return (components.second!, .second)
}
}
}
public extension FormatStyle where Self == AbbreviatedTimeAgoFormatStyle {
static var abbreviatedTimeAgo: Self {
Self()
}
}

View File

@ -1,66 +0,0 @@
//
// AvatarImageView.swift
// ComposeUI
//
// Created by Shadowfacts on 3/4/23.
//
import SwiftUI
public struct AvatarImageView: View {
public typealias FetchAvatar = (URL) async -> UIImage?
let url: URL?
let size: CGFloat
let style: Style
let fetchAvatar: FetchAvatar
@State private var image: UIImage?
public init(url: URL?, size: CGFloat, style: Style, fetchAvatar: @escaping FetchAvatar) {
self.url = url
self.size = size
self.style = style
self.fetchAvatar = fetchAvatar
}
public var body: some View {
imageView
.resizable()
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: style.cornerRadiusFraction * size, style: .continuous))
.task { @MainActor in
image = nil
if let url {
image = await fetchAvatar(url)
}
}
// tell swiftui that this view has changed (and therefore the task needs to re-run) when the url changes
.id(url)
}
private var imageView: Image {
if let image {
return Image(uiImage: image)
} else {
return placeholder
}
}
private var placeholder: Image {
Image(systemName: style == .roundRect ? "person.crop.square" : "person.crop.circle")
}
public enum Style: Equatable {
case roundRect, circle
var cornerRadiusFraction: CGFloat {
switch self {
case .roundRect:
return 0.1
case .circle:
return 0.5
}
}
}
}

View File

@ -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,28 +0,0 @@
// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "TuskerPreferences",
platforms: [
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "TuskerPreferences",
targets: ["TuskerPreferences"]),
],
dependencies: [
.package(path: "../Pachyderm"),
],
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: "TuskerPreferences",
dependencies: ["Pachyderm"]
),
]
)

View File

@ -1,53 +0,0 @@
//
// StatusSwipeAction.swift
// Tusker
//
// Created by Shadowfacts on 11/26/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
public enum StatusSwipeAction: String, Codable, Hashable, CaseIterable {
case reply
case favorite
case reblog
case share
case bookmark
case openInSafari
public var displayName: String {
switch self {
case .reply:
return "Reply"
case .favorite:
return "Favorite"
case .reblog:
return "Reblog"
case .share:
return "Share"
case .bookmark:
return "Bookmark"
case .openInSafari:
return "Open in Safari"
}
}
public var systemImageName: String {
switch self {
case .reply:
return "arrowshape.turn.up.left.fill"
case .favorite:
return "star.fill"
case .reblog:
return "repeat"
case .share:
return "square.and.arrow.up"
case .bookmark:
return "bookmark.fill"
case .openInSafari:
return "safari"
}
}
}

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,23 +0,0 @@
{
"pins" : [
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "025bcb1165deab2e20d4eaba79967ce73013f496",
"version" : "1.2.1"
}
},
{
"identity" : "swift-url",
"kind" : "remoteSourceControl",
"location" : "https://github.com/karwa/swift-url.git",
"state" : {
"branch" : "main",
"revision" : "220f6ab9d8a7e0742f85eb9f21b745942e001ae6"
}
}
],
"version" : 2
}

View File

@ -1,31 +0,0 @@
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "UserAccounts",
platforms: [
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "UserAccounts",
targets: ["UserAccounts"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
.package(path: "../Pachyderm"),
],
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: "UserAccounts",
dependencies: ["Pachyderm"]),
// .testTarget(
// name: "UserAccountsTests",
// dependencies: ["UserAccounts"]),
]
)

View File

@ -1,3 +0,0 @@
# UserAccounts
A description of this package.

View File

@ -1,80 +0,0 @@
//
// UserAccountInfo.swift
// UserAccounts
//
// Created by Shadowfacts on 3/5/23.
//
import Foundation
import CryptoKit
public struct UserAccountInfo: Equatable, Hashable, Identifiable {
public let id: String
public let instanceURL: URL
public let clientID: String
public let clientSecret: String
public private(set) var username: String!
public let accessToken: String
fileprivate static let tempAccountID = "temp"
static func id(instanceURL: URL, username: String?) -> String {
// We hash the instance host and username to form the account ID
// so that account IDs will match across devices, allowing for data syncing and handoff.
var hasher = SHA256()
hasher.update(data: instanceURL.host!.data(using: .utf8)!)
if let username {
hasher.update(data: username.data(using: .utf8)!)
}
return Data(hasher.finalize()).base64EncodedString()
}
/// Only to be used for temporary MastodonController needed to fetch own account info and create final UserAccountInfo with real username
public init(tempInstanceURL instanceURL: URL, clientID: String, clientSecret: String, accessToken: String) {
self.id = UserAccountInfo.tempAccountID
self.instanceURL = instanceURL
self.clientID = clientID
self.clientSecret = clientSecret
self.accessToken = accessToken
}
init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String) {
self.id = UserAccountInfo.id(instanceURL: instanceURL, username: username)
self.instanceURL = instanceURL
self.clientID = clientID
self.clientSecret = clientSecret
self.username = username
self.accessToken = accessToken
}
init?(userDefaultsDict dict: [String: String]) {
guard let id = dict["id"],
let instanceURL = dict["instanceURL"],
let url = URL(string: instanceURL),
let clientID = dict["clientID"],
let secret = dict["clientSecret"],
let accessToken = dict["accessToken"] else {
return nil
}
self.id = id
self.instanceURL = url
self.clientID = clientID
self.clientSecret = secret
self.username = dict["username"]
self.accessToken = accessToken
}
/// A filename-safe string for this account
public var persistenceKey: String {
// slashes are not allowed in the persistent store coordinator name
id.replacingOccurrences(of: "/", with: "_")
}
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
public static func ==(lhs: UserAccountInfo, rhs: UserAccountInfo) -> Bool {
return lhs.id == rhs.id
}
}

View File

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Share View Controller-->
<scene sceneID="ceB-am-kn3">
<objects>
<viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" opaque="NO" contentMode="scaleToFill" id="wbc-yd-nQP">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
<viewLayoutGuide key="safeArea" id="1Xd-am-t49"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="CEy-Cv-SGf" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

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