Compare commits
25 Commits
develop
...
compose-re
Author | SHA1 | Date |
---|---|---|
Shadowfacts | ad5f45c620 | |
Shadowfacts | c564bb4112 | |
Shadowfacts | ec9673f6c0 | |
Shadowfacts | 8cc9849b36 | |
Shadowfacts | 8006b0add9 | |
Shadowfacts | b9e3d8ec5e | |
Shadowfacts | 2fb76e322a | |
Shadowfacts | 57990f8339 | |
Shadowfacts | 381f3ee737 | |
Shadowfacts | 5be80d8e68 | |
Shadowfacts | 02fd724b0b | |
Shadowfacts | 7d47f1f259 | |
Shadowfacts | cad074bcc3 | |
Shadowfacts | 8243e06e95 | |
Shadowfacts | 5f6699749c | |
Shadowfacts | ec50dd6bb6 | |
Shadowfacts | 5d9974ddf8 | |
Shadowfacts | f001e8edcd | |
Shadowfacts | 17c67a3d5d | |
Shadowfacts | 54fadaa270 | |
Shadowfacts | ff433c4270 | |
Shadowfacts | 71fd804fd7 | |
Shadowfacts | 198b201a51 | |
Shadowfacts | 66626c8f62 | |
Shadowfacts | 727f28e39f |
12
CHANGELOG.md
12
CHANGELOG.md
|
@ -1,17 +1,5 @@
|
|||
# Changelog
|
||||
|
||||
## 2024.5 (139)
|
||||
Bugfixes:
|
||||
- Fix error decoding certain posts
|
||||
|
||||
## 2024.5 (138)
|
||||
Bugfixes:
|
||||
- Fix potential crash when displaying certain attachments
|
||||
- Fix potential crash due to race condition when opening push notification in app
|
||||
- Fix misaligned text between profile field values/labels
|
||||
- Fix rate limited error message not including reset timestamp
|
||||
- iPadOS/macOS: Fix Cmd+R shortcut not working
|
||||
|
||||
## 2024.5 (137)
|
||||
Features/Improvements:
|
||||
- Improve gallery presentation/dismissal transitions
|
||||
|
|
|
@ -14,6 +14,7 @@ import OSLog
|
|||
import Pachyderm
|
||||
import Intents
|
||||
import HTMLStreamer
|
||||
import WebURL
|
||||
import UIKit
|
||||
import TuskerPreferences
|
||||
|
||||
|
@ -237,7 +238,8 @@ class NotificationService: UNNotificationServiceExtension {
|
|||
for match in emojiRegex.matches(in: content.body, range: NSRange(location: 0, length: content.body.utf16.count)).reversed() {
|
||||
let emojiName = (content.body as NSString).substring(with: match.range(at: 1))
|
||||
guard let emoji = notification.status?.emojis.first(where: { $0.shortcode == emojiName }),
|
||||
let (data, _) = try? await URLSession.shared.data(from: emoji.url),
|
||||
let url = URL(emoji.url),
|
||||
let (data, _) = try? await URLSession.shared.data(from: url),
|
||||
let image = UIImage(data: data) else {
|
||||
continue
|
||||
}
|
||||
|
@ -366,7 +368,17 @@ private func decodeBase64URL(_ s: String) -> Data? {
|
|||
// copied from HTMLConverter.Callbacks, blergh
|
||||
private struct HTMLCallbacks: HTMLConversionCallbacks {
|
||||
static func makeURL(string: String) -> URL? {
|
||||
try? URL.ParseStrategy().parse(string)
|
||||
// Converting WebURL to URL is a small but non-trivial expense (since it works by
|
||||
// serializing the WebURL as a string and then having Foundation parse it again),
|
||||
// so, if available, use the system parser which doesn't require another round trip.
|
||||
if let url = try? URL.ParseStrategy().parse(string) {
|
||||
url
|
||||
} else if let web = WebURL(string),
|
||||
let url = URL(web) {
|
||||
url
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {
|
||||
|
|
|
@ -20,13 +20,24 @@ let package = Package(
|
|||
.package(path: "../InstanceFeatures"),
|
||||
.package(path: "../TuskerComponents"),
|
||||
.package(path: "../MatchedGeometryPresentation"),
|
||||
.package(path: "../TuskerPreferences"),
|
||||
.package(path: "../UserAccounts"),
|
||||
.package(path: "../GalleryVC"),
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "ComposeUI",
|
||||
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"],
|
||||
dependencies: [
|
||||
"Pachyderm",
|
||||
"InstanceFeatures",
|
||||
"TuskerComponents",
|
||||
"MatchedGeometryPresentation",
|
||||
"TuskerPreferences",
|
||||
"UserAccounts",
|
||||
"GalleryVC",
|
||||
],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]),
|
||||
|
|
|
@ -11,7 +11,7 @@ import Pachyderm
|
|||
import UniformTypeIdentifiers
|
||||
|
||||
@MainActor
|
||||
class PostService: ObservableObject {
|
||||
final class PostService: ObservableObject {
|
||||
private let mastodonController: ComposeMastodonContext
|
||||
private let config: ComposeUIConfig
|
||||
private let draft: Draft
|
||||
|
|
|
@ -12,7 +12,7 @@ import InstanceFeatures
|
|||
public struct CharacterCounter {
|
||||
|
||||
private 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 {
|
||||
let mentionsRemoved = removeMentions(in: text)
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
protocol ComposeInput: AnyObject, ObservableObject {
|
||||
var toolbarElements: [ToolbarElement] { get }
|
||||
|
@ -27,3 +28,44 @@ enum ToolbarElement {
|
|||
case emojiPicker
|
||||
case formattingButtons
|
||||
}
|
||||
|
||||
private struct FocusedComposeInput: FocusedValueKey {
|
||||
typealias Value = any ComposeInput
|
||||
}
|
||||
|
||||
extension FocusedValues {
|
||||
var composeInput: (any ComposeInput)? {
|
||||
get { self[FocusedComposeInput.self] }
|
||||
set { self[FocusedComposeInput.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@propertyWrapper
|
||||
final class MutableObservableBox<Value>: ObservableObject {
|
||||
@Published var wrappedValue: Value
|
||||
|
||||
init(wrappedValue: Value) {
|
||||
self.wrappedValue = wrappedValue
|
||||
}
|
||||
}
|
||||
|
||||
private struct FocusedComposeInputBox: EnvironmentKey {
|
||||
static let defaultValue: MutableObservableBox<(any ComposeInput)?> = .init(wrappedValue: nil)
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var composeInputBox: MutableObservableBox<(any ComposeInput)?> {
|
||||
get { self[FocusedComposeInputBox.self] }
|
||||
set { self[FocusedComposeInputBox.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
struct FocusedInputModifier: ViewModifier {
|
||||
@StateObject var box: MutableObservableBox<(any ComposeInput)?> = .init(wrappedValue: nil)
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.environment(\.composeInputBox, box)
|
||||
.focusedValue(\.composeInput, box.wrappedValue)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import Foundation
|
|||
import Pachyderm
|
||||
import InstanceFeatures
|
||||
import UserAccounts
|
||||
import SwiftUI
|
||||
|
||||
public protocol ComposeMastodonContext {
|
||||
var accountInfo: UserAccountInfo? { get }
|
||||
|
@ -26,4 +27,6 @@ public protocol ComposeMastodonContext {
|
|||
func searchCachedHashtags(query: String) -> [Hashtag]
|
||||
|
||||
func storeCreatedStatus(_ status: Status)
|
||||
|
||||
func fetchStatus(id: String) -> (any StatusProtocol)?
|
||||
}
|
||||
|
|
|
@ -181,7 +181,7 @@ extension EnvironmentValues {
|
|||
}
|
||||
}
|
||||
|
||||
private struct GIFViewWrapper: UIViewRepresentable {
|
||||
struct GIFViewWrapper: UIViewRepresentable {
|
||||
typealias UIViewType = GIFImageView
|
||||
|
||||
@State var controller: GIFController
|
||||
|
|
|
@ -131,7 +131,6 @@ class AttachmentsListController: ViewController {
|
|||
@EnvironmentObject private var controller: AttachmentsListController
|
||||
@EnvironmentObject private var draft: Draft
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
attachmentsList
|
||||
|
@ -217,7 +216,7 @@ fileprivate extension View {
|
|||
}
|
||||
|
||||
@available(visionOS 1.0, *)
|
||||
fileprivate struct AttachmentButtonLabelStyle: LabelStyle {
|
||||
struct AttachmentButtonLabelStyle: LabelStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
DefaultLabelStyle().makeBody(configuration: configuration)
|
||||
.foregroundStyle(.white)
|
||||
|
|
|
@ -130,11 +130,17 @@ public final class ComposeController: ViewController {
|
|||
}
|
||||
|
||||
public var view: some View {
|
||||
ComposeView(poster: poster)
|
||||
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
|
||||
.environmentObject(draft)
|
||||
.environmentObject(mastodonController.instanceFeatures)
|
||||
.environment(\.composeUIConfig, config)
|
||||
if Preferences.shared.hasFeatureFlag(.composeRewrite) {
|
||||
ComposeUI.ComposeView(draft: draft, mastodonController: mastodonController)
|
||||
.environment(\.currentAccount, currentAccount)
|
||||
.environment(\.composeUIConfig, config)
|
||||
} else {
|
||||
ComposeView(poster: poster)
|
||||
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
|
||||
.environmentObject(draft)
|
||||
.environmentObject(mastodonController.instanceFeatures)
|
||||
.environment(\.composeUIConfig, config)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
|
@ -17,7 +17,7 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider {
|
|||
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)
|
||||
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
|
||||
|
@ -31,7 +31,7 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider {
|
|||
Text("Any questions?")
|
||||
}
|
||||
} else {
|
||||
Text("What's on your mind?")
|
||||
Text("What’s on your mind?")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -41,7 +41,7 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider {
|
|||
}
|
||||
|
||||
// exists to provide access to the type alias since the @State property needs it to be explicit
|
||||
private protocol PlaceholderViewProvider {
|
||||
protocol PlaceholderViewProvider {
|
||||
associatedtype PlaceholderView: View
|
||||
@ViewBuilder
|
||||
static func makePlaceholderView() -> PlaceholderView
|
||||
|
|
|
@ -170,17 +170,26 @@ public class DraftsPersistentContainer: NSPersistentContainer {
|
|||
return
|
||||
}
|
||||
performBackgroundTask { context in
|
||||
let orphanedAttachmentsReq: NSFetchRequest<any NSFetchRequestResult> = DraftAttachment.fetchRequest()
|
||||
orphanedAttachmentsReq.predicate = NSPredicate(format: "draft == nil")
|
||||
let deleteReq = NSBatchDeleteRequest(fetchRequest: orphanedAttachmentsReq)
|
||||
do {
|
||||
try context.execute(deleteReq)
|
||||
} catch {
|
||||
logger.error("Failed to remove orphaned attachments: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
|
||||
let allAttachmentsReq = DraftAttachment.fetchRequest()
|
||||
allAttachmentsReq.predicate = NSPredicate(format: "fileURL != nil")
|
||||
guard let allAttachments = try? context.fetch(allAttachmentsReq) else {
|
||||
return
|
||||
}
|
||||
let orphaned = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
|
||||
for url in orphaned {
|
||||
let orphanedFiles = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
|
||||
for url in orphanedFiles {
|
||||
do {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
} catch {
|
||||
logger.error("Failed to remove orphaned attachment: \(String(describing: error), privacy: .public)")
|
||||
logger.error("Failed to remove orphaned attachment files: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
completion()
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
//
|
||||
// Environment.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 8/10/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
|
||||
//@propertyWrapper
|
||||
//struct RequiredEnvironment<Value>: DynamicProperty {
|
||||
// private let keyPath: KeyPath<EnvironmentValues, Value?>
|
||||
// @Environment private var value: Value?
|
||||
//
|
||||
// init(_ keyPath: KeyPath<EnvironmentValues, Value?>) {
|
||||
// self.keyPath = keyPath
|
||||
// self._value = Environment(keyPath)
|
||||
// }
|
||||
//
|
||||
// var wrappedValue: Value {
|
||||
// guard let value else {
|
||||
// preconditionFailure("Missing required environment value for \(keyPath)")
|
||||
// }
|
||||
// return value
|
||||
// }
|
||||
//}
|
||||
|
||||
private struct ComposeMastodonContextKey: EnvironmentKey {
|
||||
static let defaultValue: (any ComposeMastodonContext)? = nil
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var mastodonController: (any ComposeMastodonContext)? {
|
||||
get { self[ComposeMastodonContextKey.self] }
|
||||
set { self[ComposeMastodonContextKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
private struct CurrentAccountKey: EnvironmentKey {
|
||||
static let defaultValue: (any AccountProtocol)? = nil
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var currentAccount: (any AccountProtocol)? {
|
||||
get { self[CurrentAccountKey.self] }
|
||||
set { self[CurrentAccountKey.self] = newValue }
|
||||
}
|
||||
}
|
|
@ -11,7 +11,6 @@ import UIKit
|
|||
import Combine
|
||||
|
||||
class KeyboardReader: ObservableObject {
|
||||
// @Published var isVisible = false
|
||||
@Published var keyboardHeight: CGFloat = 0
|
||||
|
||||
var isVisible: Bool {
|
||||
|
@ -26,14 +25,12 @@ class KeyboardReader: ObservableObject {
|
|||
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,9 +9,13 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
enum StatusFormat: Int, CaseIterable {
|
||||
enum StatusFormat: Int, CaseIterable, Identifiable {
|
||||
case bold, italics, strikethrough, code
|
||||
|
||||
var id: some Hashable {
|
||||
rawValue
|
||||
}
|
||||
|
||||
func insertionResult(for contentType: StatusContentType) -> FormatInsertionResult? {
|
||||
switch contentType {
|
||||
case .plain:
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
//
|
||||
// Preferences.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 8/10/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import TuskerPreferences
|
||||
|
||||
typealias Preferences = TuskerPreferences.Preferences
|
|
@ -11,6 +11,7 @@ import Combine
|
|||
public protocol ViewController: ObservableObject {
|
||||
associatedtype ContentView: View
|
||||
|
||||
@MainActor
|
||||
@ViewBuilder
|
||||
var view: ContentView { get }
|
||||
}
|
||||
|
|
|
@ -0,0 +1,182 @@
|
|||
//
|
||||
// AttachmentRowView.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 8/18/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import InstanceFeatures
|
||||
import Vision
|
||||
|
||||
struct AttachmentRowView: View {
|
||||
@ObservedObject var attachment: DraftAttachment
|
||||
@State private var isRecognizingText = false
|
||||
@State private var textRecognitionError: (any Error)?
|
||||
|
||||
private var thumbnailSize: CGFloat {
|
||||
#if os(visionOS)
|
||||
120
|
||||
#else
|
||||
80
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
thumbnailView
|
||||
|
||||
descriptionView
|
||||
}
|
||||
.alertWithData("Text Recognition Failed", data: $textRecognitionError) { _ in
|
||||
Button("OK") {}
|
||||
} message: { error in
|
||||
Text(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: attachments missing descriptions feature
|
||||
|
||||
private var thumbnailView: some View {
|
||||
AttachmentThumbnailView(attachment: attachment)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(width: thumbnailSize, height: thumbnailSize)
|
||||
.contextMenu {
|
||||
EditDrawingButton(attachment: attachment)
|
||||
RecognizeTextButton(attachment: attachment, isRecognizingText: $isRecognizingText, error: $textRecognitionError)
|
||||
DeleteButton(attachment: attachment)
|
||||
} preview: {
|
||||
// TODO: need to fix flash of preview changing size
|
||||
AttachmentThumbnailView(attachment: attachment)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var descriptionView: some View {
|
||||
if isRecognizingText {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
} else {
|
||||
InlineAttachmentDescriptionView(attachment: attachment, minHeight: thumbnailSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EditDrawingButton: View {
|
||||
@ObservedObject var attachment: DraftAttachment
|
||||
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
|
||||
|
||||
var body: some View {
|
||||
if attachment.drawingData != nil {
|
||||
Button(action: editDrawing) {
|
||||
Label("Edit Drawing", systemImage: "hand.draw")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func editDrawing() {
|
||||
if case .drawing(let drawing) = attachment.data {
|
||||
presentDrawing?(drawing) {
|
||||
attachment.drawing = $0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RecognizeTextButton: View {
|
||||
@ObservedObject var attachment: DraftAttachment
|
||||
@Binding var isRecognizingText: Bool
|
||||
@Binding var error: (any Error)?
|
||||
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
||||
|
||||
var body: some View {
|
||||
if attachment.type == .image {
|
||||
Button {
|
||||
Task {
|
||||
await recognizeText()
|
||||
}
|
||||
} label: {
|
||||
Label("Recognize Text", systemImage: "doc.text.viewfinder")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func recognizeText() async {
|
||||
isRecognizingText = true
|
||||
defer { isRecognizingText = false }
|
||||
|
||||
do {
|
||||
let data = try await getAttachmentData()
|
||||
let observations = try await runRecognizeTextRequest(data: data)
|
||||
if let observations {
|
||||
var text = ""
|
||||
for observation in observations {
|
||||
let result = observation.topCandidates(1).first!
|
||||
text.append(result.string)
|
||||
text.append("\n")
|
||||
}
|
||||
self.attachment.attachmentDescription = text
|
||||
}
|
||||
} catch let error as NSError where error.domain == VNErrorDomain && error.code == 1 {
|
||||
// The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for.
|
||||
return
|
||||
} catch {
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
|
||||
private func getAttachmentData() async throws -> Data {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
attachment.getData(features: instanceFeatures) { result in
|
||||
switch result {
|
||||
case .success(let (data, _)):
|
||||
continuation.resume(returning: data)
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runRecognizeTextRequest(data: Data) async throws -> [VNRecognizedTextObservation]? {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let handler = VNImageRequestHandler(data: data)
|
||||
let request = VNRecognizeTextRequest { request, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume(returning: request.results as? [VNRecognizedTextObservation])
|
||||
}
|
||||
}
|
||||
request.recognitionLevel = .accurate
|
||||
request.usesLanguageCorrection = true
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
try? handler.perform([request])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DeleteButton: View {
|
||||
let attachment: DraftAttachment
|
||||
|
||||
var body: some View {
|
||||
Button(role: .destructive, action: removeAttachment) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
private func removeAttachment() {
|
||||
let draft = attachment.draft
|
||||
var array = draft.draftAttachments
|
||||
guard let index = array.firstIndex(of: attachment) else {
|
||||
return
|
||||
}
|
||||
array.remove(at: index)
|
||||
draft.attachments = NSMutableOrderedSet(array: array)
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// AttachmentRowView()
|
||||
//}
|
|
@ -0,0 +1,127 @@
|
|||
//
|
||||
// AttachmentThumbnailView.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 10/14/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import TuskerComponents
|
||||
import AVFoundation
|
||||
import Photos
|
||||
|
||||
struct AttachmentThumbnailView: View {
|
||||
let attachment: DraftAttachment
|
||||
var contentMode: ContentMode = .fit
|
||||
|
||||
var body: some View {
|
||||
AttachmentThumbnailViewContent(attachment: attachment, contentMode: contentMode)
|
||||
.id(attachment.id)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct AttachmentThumbnailViewContent: View {
|
||||
var attachment: DraftAttachment
|
||||
var contentMode: ContentMode = .fit
|
||||
@State private var mode: Mode = .empty
|
||||
@EnvironmentObject private var composeController: ComposeController
|
||||
|
||||
var body: some View {
|
||||
switch mode {
|
||||
case .empty:
|
||||
Image(systemName: "photo")
|
||||
.imageScale(.large)
|
||||
.foregroundStyle(.gray)
|
||||
.task {
|
||||
await loadThumbnail()
|
||||
}
|
||||
case .image(let image):
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: contentMode)
|
||||
case .gifController(let controller):
|
||||
GIFViewWrapper(controller: controller)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadThumbnail() async {
|
||||
switch attachment.data {
|
||||
case .editing(_, let kind, let url):
|
||||
switch kind {
|
||||
case .image:
|
||||
if let image = await composeController.fetchAttachment(url) {
|
||||
self.mode = .image(image)
|
||||
}
|
||||
|
||||
case .video, .gifv:
|
||||
await loadVideoThumbnail(url: url)
|
||||
|
||||
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 {
|
||||
$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.mode = .gifController(GIFController(gifData: data))
|
||||
} else if let image = UIImage(data: data) {
|
||||
self.mode = .image(image)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let size = CGSize(width: 80, height: 80)
|
||||
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { image, _ in
|
||||
if let image {
|
||||
self.mode = .image(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .drawing(let drawing):
|
||||
self.mode = .image(drawing.imageInLightMode(from: drawing.bounds))
|
||||
|
||||
case .file(let url, let type):
|
||||
if type.conforms(to: .movie) {
|
||||
await loadVideoThumbnail(url: url)
|
||||
} else if let data = try? Data(contentsOf: url) {
|
||||
if type == .gif {
|
||||
self.mode = .gifController(GIFController(gifData: data))
|
||||
} else if type.conforms(to: .image) {
|
||||
if let image = UIImage(data: data),
|
||||
// using prepareThumbnail on images from PHPicker results in extremely high memory usage,
|
||||
// crashing share extension. see FB12186346
|
||||
let prepared = await image.byPreparingForDisplay() {
|
||||
self.mode = .image(prepared)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func loadVideoThumbnail(url: URL) async {
|
||||
let asset = AVURLAsset(url: url)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
imageGenerator.appliesPreferredTrackTransform = true
|
||||
if let (cgImage, _) = try? await imageGenerator.image(at: .zero) {
|
||||
self.mode = .image(UIImage(cgImage: cgImage))
|
||||
}
|
||||
}
|
||||
|
||||
enum Mode {
|
||||
case empty
|
||||
case image(UIImage)
|
||||
case gifController(GIFController)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,154 @@
|
|||
//
|
||||
// AttachmentCollectionViewCell.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 11/20/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
final class AttachmentCollectionViewCell: UICollectionViewCell {
|
||||
let attachmentView: UIView & UIContentView
|
||||
|
||||
override init(frame: CGRect) {
|
||||
attachmentView = UIHostingConfiguration(content: {
|
||||
AttachmentCollectionViewCellView(attachment: nil)
|
||||
}).makeContentView()
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
attachmentView.frame = bounds
|
||||
addSubview(attachmentView)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func updateUI(attachment: DraftAttachment) {
|
||||
attachmentView.configuration = UIHostingConfiguration(content: {
|
||||
AttachmentCollectionViewCellView(attachment: attachment)
|
||||
}).margins(.all, .zero)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AttachmentCollectionViewCellView: View {
|
||||
let attachment: DraftAttachment?
|
||||
|
||||
var body: some View {
|
||||
if let attachment {
|
||||
AttachmentThumbnailView(attachment: attachment, contentMode: .fill)
|
||||
.squareFrame()
|
||||
.background {
|
||||
RoundedSquare(cornerRadius: 5)
|
||||
.fill(.quaternary)
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
AttachmentDescriptionLabel(attachment: attachment)
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
AttachmentRemoveButton(attachment: attachment)
|
||||
}
|
||||
.clipShape(RoundedSquare(cornerRadius: 5))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct AttachmentRemoveButton: View {
|
||||
let attachment: DraftAttachment
|
||||
|
||||
var body: some View {
|
||||
Button("Remove", systemImage: "xmark.circle.fill") {
|
||||
let draft = attachment.draft
|
||||
let attachments = draft.attachments.mutableCopy() as! NSMutableOrderedSet
|
||||
attachments.remove(attachment)
|
||||
draft.attachments = attachments
|
||||
DraftsPersistentContainer.shared.viewContext.delete(attachment)
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.imageScale(.large)
|
||||
.foregroundStyle(.white)
|
||||
.shadow(radius: 2)
|
||||
.padding([.top, .trailing], 2)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AttachmentDescriptionLabel: View {
|
||||
@ObservedObject var attachment: DraftAttachment
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
LinearGradient(
|
||||
stops: [.init(color: .clear, location: 0), .init(color: .clear, location: 0.6), .init(color: .black.opacity(0.5), location: 1)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
||||
label
|
||||
.foregroundStyle(.white)
|
||||
.shadow(color: .black.opacity(0.5), radius: 1)
|
||||
.padding([.horizontal, .bottom], 4)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var label: some View {
|
||||
if attachment.attachmentDescription.isEmpty {
|
||||
Label("Add alt", systemImage: "pencil")
|
||||
.labelStyle(NarrowSpacingLabelStyle())
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Text(attachment.attachmentDescription)
|
||||
.font(.caption)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NarrowSpacingLabelStyle: LabelStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
configuration.icon
|
||||
configuration.title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RoundedSquare: Shape {
|
||||
let cornerRadius: CGFloat
|
||||
|
||||
nonisolated func path(in rect: CGRect) -> Path {
|
||||
let minDimension = min(rect.width, rect.height)
|
||||
let square = CGRect(x: rect.minX - (rect.width - minDimension) / 2, y: rect.minY - (rect.height - minDimension), width: minDimension, height: minDimension)
|
||||
return RoundedRectangle(cornerRadius: cornerRadius).path(in: square)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SquareFrame: Layout {
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
precondition(subviews.count == 1)
|
||||
let size = proposal.replacingUnspecifiedDimensions(by: subviews[0].sizeThatFits(proposal))
|
||||
let minDimension = min(size.width, size.height)
|
||||
return CGSize(width: minDimension, height: minDimension)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
precondition(subviews.count == 1)
|
||||
let subviewSize = subviews[0].sizeThatFits(proposal)
|
||||
let minDimension = min(bounds.width, bounds.height)
|
||||
let origin = CGPoint(x: bounds.minX - (subviewSize.width - minDimension) / 2, y: bounds.minY - (subviewSize.height - minDimension) / 2)
|
||||
subviews[0].place(at: origin, proposal: ProposedViewSize(subviewSize))
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func squareFrame() -> some View {
|
||||
SquareFrame {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
//
|
||||
// AttachmentGalleryDataSource.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 11/21/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import GalleryVC
|
||||
import TuskerComponents
|
||||
import Photos
|
||||
|
||||
struct AttachmentsGalleryDataSource: GalleryDataSource {
|
||||
let collectionView: UICollectionView
|
||||
let attachmentAtIndex: (Int) -> DraftAttachment?
|
||||
|
||||
func galleryItemsCount() -> Int {
|
||||
collectionView.numberOfItems(inSection: 0) - 1
|
||||
}
|
||||
|
||||
func galleryContentViewController(forItemAt index: Int) -> any GalleryVC.GalleryContentViewController {
|
||||
let attachment = attachmentAtIndex(index)!
|
||||
|
||||
let content: any GalleryContentViewController
|
||||
switch attachment.data {
|
||||
case .editing(_, _, _):
|
||||
fatalError("TODO")
|
||||
|
||||
case .asset(let id):
|
||||
content = LoadingGalleryContentViewController(caption: nil) {
|
||||
if let (image, gifData) = await fetchImageAndGIFData(assetID: id) {
|
||||
let gifController = gifData.map(GIFController.init)
|
||||
return ImageGalleryContentViewController(image: image, caption: nil, gifController: gifController)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
case .drawing(let drawing):
|
||||
let image = drawing.imageInLightMode(from: drawing.bounds)
|
||||
content = ImageGalleryContentViewController(image: image, caption: nil, gifController: nil)
|
||||
|
||||
case .file(let url, let type):
|
||||
if type.conforms(to: .movie) {
|
||||
content = VideoGalleryContentViewController(url: url, caption: nil)
|
||||
} else if type.conforms(to: .image),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let image = UIImage(data: data) {
|
||||
let gifController = type == .gif ? GIFController(gifData: data) : nil
|
||||
content = ImageGalleryContentViewController(image: image, caption: nil, gifController: gifController)
|
||||
} else {
|
||||
return LoadingGalleryContentViewController(caption: nil) {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
case .none:
|
||||
return LoadingGalleryContentViewController(caption: nil) {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
return EditAttachmentWrapperGalleryContentViewController(draftAttachment: attachment, wrapped: content)
|
||||
}
|
||||
|
||||
func galleryContentTransitionSourceView(forItemAt index: Int) -> UIView? {
|
||||
collectionView.cellForItem(at: IndexPath(item: index, section: 0))
|
||||
}
|
||||
|
||||
private func fetchImageAndGIFData(assetID id: String) async -> (UIImage, Data?)? {
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
|
||||
return nil
|
||||
}
|
||||
let (type, data) = await withCheckedContinuation { continuation in
|
||||
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in
|
||||
continuation.resume(returning: (typeIdentifier, data))
|
||||
}
|
||||
}
|
||||
guard let data,
|
||||
let image = UIImage(data: data) else {
|
||||
return nil
|
||||
}
|
||||
if type == UTType.gif.identifier {
|
||||
return (image, data)
|
||||
} else {
|
||||
return (image, nil)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,309 @@
|
|||
//
|
||||
// AttachmentsSection.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 11/17/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import PencilKit
|
||||
import GalleryVC
|
||||
|
||||
struct AttachmentsSection: View {
|
||||
@ObservedObject var draft: Draft
|
||||
|
||||
var body: some View {
|
||||
WrappedCollectionView(
|
||||
draft: draft,
|
||||
spacing: 8,
|
||||
minItemSize: 100
|
||||
)
|
||||
// Impose a minimum height, because otherwise it defaults to zero which prevents the collection
|
||||
// view from laying out, and leaving the intrinsic content size at zero too.
|
||||
// Add 4 to the minItemSize because otherwise drag-and-drop while reordering can alter the contentOffset by that much.
|
||||
.frame(minHeight: 104)
|
||||
}
|
||||
}
|
||||
|
||||
// Use a UIViewControllerRepresentable so we have something from which to present the gallery VC.
|
||||
private struct WrappedCollectionView: UIViewControllerRepresentable {
|
||||
@ObservedObject var draft: Draft
|
||||
let spacing: CGFloat
|
||||
let minItemSize: CGFloat
|
||||
|
||||
func makeUIViewController(context: Context) -> WrappedCollectionViewController {
|
||||
WrappedCollectionViewController(spacing: spacing, minItemSize: minItemSize)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
|
||||
uiViewController.draft = draft
|
||||
uiViewController.addAttachment = {
|
||||
DraftsPersistentContainer.shared.viewContext.insert($0)
|
||||
$0.draft = draft
|
||||
draft.attachments.add($0)
|
||||
}
|
||||
uiViewController.updateAttachments()
|
||||
}
|
||||
}
|
||||
|
||||
private class WrappedCollectionViewController: UIViewController {
|
||||
let spacing: CGFloat
|
||||
let minItemSize: CGFloat
|
||||
var draft: Draft!
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
fileprivate var currentInteractiveMoveStartOffsetInCell: CGPoint?
|
||||
fileprivate var currentInteractiveMoveCell: AttachmentCollectionViewCell?
|
||||
fileprivate var addAttachment: ((DraftAttachment) -> Void)? = nil
|
||||
|
||||
init(spacing: CGFloat, minItemSize: CGFloat) {
|
||||
self.spacing = spacing
|
||||
self.minItemSize = minItemSize
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
let layout = UICollectionViewCompositionalLayout { [unowned self] section, environment in
|
||||
let (itemSize, itemsPerRow) = self.itemSize(width: environment.container.contentSize.width)
|
||||
|
||||
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .absolute(itemSize), heightDimension: .absolute(itemSize)))
|
||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(itemSize)), repeatingSubitem: item, count: itemsPerRow)
|
||||
group.interItemSpacing = .fixed(spacing)
|
||||
let section = NSCollectionLayoutSection(group: group)
|
||||
section.interGroupSpacing = spacing
|
||||
return section
|
||||
}
|
||||
let attachmentCell = UICollectionView.CellRegistration<AttachmentCollectionViewCell, DraftAttachment> { cell, indexPath, attachment in
|
||||
cell.updateUI(attachment: attachment)
|
||||
}
|
||||
let addButtonCell = UICollectionView.CellRegistration<UICollectionViewCell, Bool> { [unowned self] cell, indexPath, item in
|
||||
cell.contentConfiguration = UIHostingConfiguration(content: {
|
||||
AddAttachmentButton(viewController: self, enabled: item)
|
||||
}).margins(.all, .zero)
|
||||
}
|
||||
let collectionView = IntrinsicContentSizeCollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
self.view = collectionView
|
||||
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
|
||||
switch itemIdentifier {
|
||||
case .attachment(let attachment):
|
||||
return collectionView.dequeueConfiguredReusableCell(using: attachmentCell, for: indexPath, item: attachment)
|
||||
case .addButton:
|
||||
return collectionView.dequeueConfiguredReusableCell(using: addButtonCell, for: indexPath, item: true)
|
||||
}
|
||||
}
|
||||
dataSource.reorderingHandlers.canReorderItem = { item in
|
||||
switch item {
|
||||
case .attachment(_):
|
||||
true
|
||||
case .addButton:
|
||||
false
|
||||
}
|
||||
}
|
||||
dataSource.reorderingHandlers.didReorder = { [unowned self] transaction in
|
||||
let attachmentChanges = transaction.difference.map {
|
||||
switch $0 {
|
||||
case .insert(let offset, let element, let associatedWith):
|
||||
guard case .attachment(let attachment) = element else { fatalError() }
|
||||
return CollectionDifference<DraftAttachment>.Change.insert(offset: offset, element: attachment, associatedWith: associatedWith)
|
||||
case .remove(let offset, let element, let associatedWith):
|
||||
guard case .attachment(let attachment) = element else { fatalError() }
|
||||
return CollectionDifference<DraftAttachment>.Change.remove(offset: offset, element: attachment, associatedWith: associatedWith)
|
||||
}
|
||||
}
|
||||
let attachmentsDiff = CollectionDifference(attachmentChanges)!
|
||||
let array = draft.draftAttachments.applying(attachmentsDiff)!
|
||||
draft.attachments = NSMutableOrderedSet(array: array)
|
||||
}
|
||||
|
||||
collectionView.isScrollEnabled = false
|
||||
collectionView.clipsToBounds = false
|
||||
collectionView.delegate = self
|
||||
|
||||
let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(reorderingLongPressRecognized))
|
||||
longPressRecognizer.delegate = self
|
||||
collectionView.addGestureRecognizer(longPressRecognizer)
|
||||
}
|
||||
|
||||
private func itemSize(width: CGFloat) -> (CGFloat, Int) {
|
||||
// The maximum item size is 2*minItemSize + spacing - 1,
|
||||
// in the case where one item fits in the row but we are one pt short of
|
||||
// adding a second item.
|
||||
var itemSize = minItemSize
|
||||
var fittingCount = floor((width + spacing) / (itemSize + spacing))
|
||||
var usedSpaceForFittingCount = fittingCount * itemSize + (fittingCount - 1) * spacing
|
||||
var remainingSpace = width - usedSpaceForFittingCount
|
||||
if fittingCount == 1 && remainingSpace > minItemSize / 2 {
|
||||
// If there's only one item that would fit at min size, and giving
|
||||
// it the rest of the space would increase it by at least 50%,
|
||||
// add a second item anywyas.
|
||||
itemSize = (width - spacing) / 2
|
||||
fittingCount = 2
|
||||
usedSpaceForFittingCount = fittingCount * itemSize + (fittingCount - 1) * spacing
|
||||
remainingSpace = width - usedSpaceForFittingCount
|
||||
}
|
||||
itemSize = itemSize + remainingSpace / fittingCount
|
||||
return (itemSize, Int(fittingCount))
|
||||
}
|
||||
|
||||
func updateAttachments() {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.all])
|
||||
snapshot.appendItems(draft.draftAttachments.map { .attachment($0) })
|
||||
snapshot.appendItems([.addButton])
|
||||
dataSource.apply(snapshot)
|
||||
}
|
||||
|
||||
@objc func reorderingLongPressRecognized(_ recognizer: UILongPressGestureRecognizer) {
|
||||
let collectionView = recognizer.view as! UICollectionView
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
break
|
||||
case .changed:
|
||||
var pos = recognizer.location(in: collectionView)
|
||||
if let currentInteractiveMoveStartOffsetInCell {
|
||||
pos.x -= currentInteractiveMoveStartOffsetInCell.x
|
||||
pos.y -= currentInteractiveMoveStartOffsetInCell.y
|
||||
}
|
||||
collectionView.updateInteractiveMovementTargetPosition(pos)
|
||||
case .ended:
|
||||
collectionView.endInteractiveMovement()
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
self.currentInteractiveMoveCell?.attachmentView.transform = .identity
|
||||
}
|
||||
currentInteractiveMoveCell = nil
|
||||
currentInteractiveMoveStartOffsetInCell = nil
|
||||
case .cancelled:
|
||||
collectionView.cancelInteractiveMovement()
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
self.currentInteractiveMoveCell?.attachmentView.transform = .identity
|
||||
}
|
||||
currentInteractiveMoveCell = nil
|
||||
currentInteractiveMoveStartOffsetInCell = nil
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
enum Section {
|
||||
case all
|
||||
}
|
||||
|
||||
enum Item: Hashable {
|
||||
case attachment(DraftAttachment)
|
||||
case addButton
|
||||
}
|
||||
}
|
||||
|
||||
extension WrappedCollectionViewController: UIGestureRecognizerDelegate {
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
let collectionView = gestureRecognizer.view as! UICollectionView
|
||||
let location = gestureRecognizer.location(in: collectionView)
|
||||
guard let indexPath = collectionView.indexPathForItem(at: location),
|
||||
let cell = collectionView.cellForItem(at: indexPath) as? AttachmentCollectionViewCell else {
|
||||
return false
|
||||
}
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
cell.attachmentView.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
|
||||
}
|
||||
currentInteractiveMoveCell = cell
|
||||
currentInteractiveMoveStartOffsetInCell = gestureRecognizer.location(in: cell)
|
||||
currentInteractiveMoveStartOffsetInCell!.x -= cell.bounds.midX
|
||||
currentInteractiveMoveStartOffsetInCell!.y -= cell.bounds.midY
|
||||
return collectionView.beginInteractiveMovementForItem(at: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
extension WrappedCollectionViewController: UICollectionViewDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveOfItemFromOriginalIndexPath originalIndexPath: IndexPath, atCurrentIndexPath currentIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
|
||||
let snapshot = dataSource.snapshot()
|
||||
let items = snapshot.itemIdentifiers(inSection: .all).count
|
||||
if proposedIndexPath.row == items - 1 {
|
||||
return IndexPath(item: items - 2, section: proposedIndexPath.section)
|
||||
} else {
|
||||
return proposedIndexPath
|
||||
}
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard case .attachment(_) = dataSource.itemIdentifier(for: indexPath) else {
|
||||
return
|
||||
}
|
||||
let dataSource = AttachmentsGalleryDataSource(collectionView: collectionView) { [dataSource] in
|
||||
let item = dataSource?.itemIdentifier(for: IndexPath(item: $0, section: 0))
|
||||
switch item {
|
||||
case .attachment(let attachment):
|
||||
return attachment
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
let galleryVC = GalleryViewController(dataSource: dataSource, initialItemIndex: indexPath.item)
|
||||
galleryVC.showShareButton = false
|
||||
present(galleryVC, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private final class IntrinsicContentSizeCollectionView: UICollectionView {
|
||||
private var _intrinsicContentSize = CGSize.zero
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
_intrinsicContentSize
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
if contentSize != _intrinsicContentSize {
|
||||
_intrinsicContentSize = contentSize
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AddAttachmentButton: View {
|
||||
unowned let viewController: WrappedCollectionViewController
|
||||
let enabled: Bool
|
||||
@Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker
|
||||
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
if let presentAssetPicker {
|
||||
Button("Add photo or video", systemImage: "photo") {
|
||||
presentAssetPicker {
|
||||
let draft = viewController.draft!
|
||||
AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: $0.map(\.itemProvider))
|
||||
}
|
||||
}
|
||||
}
|
||||
if let presentDrawing {
|
||||
Button("Draw something", systemImage: "hand.draw") {
|
||||
presentDrawing(PKDrawing()) { drawing in
|
||||
let draft = viewController.draft!
|
||||
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
|
||||
attachment.id = UUID()
|
||||
attachment.drawing = drawing
|
||||
attachment.draft = draft
|
||||
draft.attachments.add(attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "photo.badge.plus")
|
||||
.imageScale(.large)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.foregroundStyle(.tint.opacity(0.1))
|
||||
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.stroke(.tint, style: StrokeStyle(lineWidth: 2, dash: [5]))
|
||||
}
|
||||
}
|
||||
.disabled(!enabled)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,198 @@
|
|||
//
|
||||
// EditAttachmentWrapperGalleryContentViewController.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 11/22/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import GalleryVC
|
||||
|
||||
class EditAttachmentWrapperGalleryContentViewController: UIViewController, GalleryContentViewController {
|
||||
let draftAttachment: DraftAttachment
|
||||
let wrapped: any GalleryContentViewController
|
||||
|
||||
var container: (any GalleryContentViewControllerContainer)?
|
||||
|
||||
var contentSize: CGSize {
|
||||
wrapped.contentSize
|
||||
}
|
||||
|
||||
var activityItemsForSharing: [Any] {
|
||||
wrapped.activityItemsForSharing
|
||||
}
|
||||
|
||||
var caption: String? {
|
||||
wrapped.caption
|
||||
}
|
||||
|
||||
private lazy var editDescriptionViewController: EditAttachmentDescriptionViewController = EditAttachmentDescriptionViewController(draftAttachment: draftAttachment, wrapped: wrapped.bottomControlsAccessoryViewController)
|
||||
|
||||
var bottomControlsAccessoryViewController: UIViewController? {
|
||||
editDescriptionViewController
|
||||
}
|
||||
|
||||
var insetBottomControlsAccessoryViewControllerToSafeArea: Bool {
|
||||
false
|
||||
}
|
||||
|
||||
var presentationAnimation: GalleryContentPresentationAnimation {
|
||||
wrapped.presentationAnimation
|
||||
}
|
||||
|
||||
var hideControlsOnZoom: Bool {
|
||||
false
|
||||
}
|
||||
|
||||
init(draftAttachment: DraftAttachment, wrapped: any GalleryContentViewController) {
|
||||
self.draftAttachment = draftAttachment
|
||||
self.wrapped = wrapped
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
wrapped.container = container
|
||||
addChild(wrapped)
|
||||
wrapped.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(wrapped.view)
|
||||
NSLayoutConstraint.activate([
|
||||
wrapped.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
wrapped.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
wrapped.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
wrapped.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
wrapped.didMove(toParent: self)
|
||||
}
|
||||
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||
wrapped.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
|
||||
if !visible {
|
||||
editDescriptionViewController.textView?.resignFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
func galleryContentDidAppear() {
|
||||
wrapped.galleryContentDidAppear()
|
||||
}
|
||||
|
||||
func galleryContentWillDisappear() {
|
||||
wrapped.galleryContentWillDisappear()
|
||||
}
|
||||
|
||||
func shouldHideControls() -> Bool {
|
||||
if editDescriptionViewController.textView.isFirstResponder {
|
||||
editDescriptionViewController.textView.resignFirstResponder()
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class EditAttachmentDescriptionViewController: UIViewController {
|
||||
private let draftAttachment: DraftAttachment
|
||||
private let wrapped: UIViewController?
|
||||
|
||||
private(set) var textView: UITextView!
|
||||
private var isShowingPlaceholder = false
|
||||
|
||||
private var descriptionObservation: NSKeyValueObservation?
|
||||
|
||||
init(draftAttachment: DraftAttachment, wrapped: UIViewController?) {
|
||||
self.draftAttachment = draftAttachment
|
||||
self.wrapped = wrapped
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.overrideUserInterfaceStyle = .dark
|
||||
view.backgroundColor = .secondarySystemFill
|
||||
|
||||
let stack = UIStackView()
|
||||
stack.axis = .vertical
|
||||
stack.distribution = .fill
|
||||
stack.spacing = 0
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
stack.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||
])
|
||||
|
||||
if let wrapped {
|
||||
stack.addArrangedSubview(wrapped.view)
|
||||
}
|
||||
|
||||
textView = UITextView()
|
||||
textView.backgroundColor = nil
|
||||
textView.font = .preferredFont(forTextStyle: .body)
|
||||
textView.adjustsFontForContentSizeCategory = true
|
||||
if draftAttachment.attachmentDescription.isEmpty {
|
||||
showPlaceholder()
|
||||
} else {
|
||||
removePlaceholder()
|
||||
textView.text = draftAttachment.attachmentDescription
|
||||
}
|
||||
textView.delegate = self
|
||||
stack.addArrangedSubview(textView)
|
||||
textView.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
||||
|
||||
descriptionObservation = draftAttachment.observe(\.attachmentDescription) { [unowned self] _, _ in
|
||||
let desc = self.draftAttachment.attachmentDescription
|
||||
if desc.isEmpty {
|
||||
if !isShowingPlaceholder {
|
||||
showPlaceholder()
|
||||
}
|
||||
} else {
|
||||
if isShowingPlaceholder {
|
||||
removePlaceholder()
|
||||
}
|
||||
self.textView.text = desc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func showPlaceholder() {
|
||||
isShowingPlaceholder = true
|
||||
textView.text = "Describe for the visually impaired"
|
||||
textView.textColor = .secondaryLabel
|
||||
}
|
||||
|
||||
fileprivate func removePlaceholder() {
|
||||
isShowingPlaceholder = false
|
||||
textView.text = ""
|
||||
textView.textColor = .label
|
||||
}
|
||||
}
|
||||
|
||||
extension EditAttachmentDescriptionViewController: UITextViewDelegate {
|
||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
if isShowingPlaceholder {
|
||||
removePlaceholder()
|
||||
}
|
||||
}
|
||||
|
||||
func textViewDidEndEditing(_ textView: UITextView) {
|
||||
draftAttachment.attachmentDescription = textView.text
|
||||
|
||||
if textView.text.isEmpty {
|
||||
showPlaceholder()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,182 @@
|
|||
//
|
||||
// AttachmentsListSection.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 10/14/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import InstanceFeatures
|
||||
import PencilKit
|
||||
|
||||
struct AttachmentsListSection: View {
|
||||
@ObservedObject var draft: Draft
|
||||
@ObservedObject var instanceFeatures: InstanceFeatures
|
||||
@Environment(\.canAddAttachment) private var canAddAttachment
|
||||
|
||||
var body: some View {
|
||||
attachmentRows
|
||||
|
||||
buttons
|
||||
.foregroundStyle(.tint)
|
||||
#if os(visionOS)
|
||||
.buttonStyle(.bordered)
|
||||
.labelStyle(AttachmentButtonLabelStyle())
|
||||
#endif
|
||||
}
|
||||
|
||||
private var attachmentRows: some View {
|
||||
ForEach(draft.draftAttachments) { attachment in
|
||||
AttachmentRowView(attachment: attachment)
|
||||
}
|
||||
.onMove(perform: moveAttachments)
|
||||
.onDelete(perform: deleteAttachments)
|
||||
.onInsert(of: DraftAttachment.readableTypeIdentifiersForItemProvider) { offset, providers in
|
||||
Self.insertAttachments(in: draft, at: offset, itemProviders: providers)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var buttons: some View {
|
||||
AddPhotoButton(addAttachments: self.addAttachments)
|
||||
|
||||
AddDrawingButton(draft: draft)
|
||||
|
||||
TogglePollButton(draft: draft)
|
||||
}
|
||||
|
||||
static func insertAttachments(in draft: Draft, at index: Int, itemProviders: [NSItemProvider]) {
|
||||
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
|
||||
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
|
||||
guard let attachment = object as? DraftAttachment else { return }
|
||||
DispatchQueue.main.async {
|
||||
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
||||
attachment.draft = draft
|
||||
draft.attachments.add(attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addAttachments(itemProviders: [NSItemProvider]) {
|
||||
Self.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders)
|
||||
}
|
||||
|
||||
private func moveAttachments(from source: IndexSet, to destination: Int) {
|
||||
// just using moveObjects(at:to:) on the draft.attachments NSMutableOrderedSet
|
||||
// results in the order switching back to the previous order and then to the correct one
|
||||
// on the subsequent 2 view updates. creating a new set with the proper order doesn't have that problem
|
||||
var array = draft.draftAttachments
|
||||
array.move(fromOffsets: source, toOffset: destination)
|
||||
draft.attachments = NSMutableOrderedSet(array: array)
|
||||
}
|
||||
|
||||
private func deleteAttachments(at indices: IndexSet) {
|
||||
var array = draft.draftAttachments
|
||||
array.remove(atOffsets: indices)
|
||||
draft.attachments = NSMutableOrderedSet(array: array)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct AddPhotoButton: View {
|
||||
let addAttachments: ([NSItemProvider]) -> Void
|
||||
@Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker
|
||||
|
||||
var body: some View {
|
||||
if let presentAssetPicker {
|
||||
Button("Add photo or video", systemImage: "photo") {
|
||||
presentAssetPicker { results in
|
||||
addAttachments(results.map(\.itemProvider))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AddDrawingButton: View {
|
||||
let draft: Draft
|
||||
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
|
||||
|
||||
var body: some View {
|
||||
if let presentDrawing {
|
||||
Button("Add drawing", systemImage: "hand.draw") {
|
||||
presentDrawing(PKDrawing()) { drawing in
|
||||
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
|
||||
attachment.id = UUID()
|
||||
attachment.drawing = drawing
|
||||
attachment.draft = self.draft
|
||||
self.draft.attachments.add(attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TogglePollButton: View {
|
||||
@ObservedObject var draft: Draft
|
||||
|
||||
var body: some View {
|
||||
Button(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal") {
|
||||
withAnimation {
|
||||
draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil
|
||||
}
|
||||
}
|
||||
.disabled(draft.attachments.count > 0)
|
||||
}
|
||||
}
|
||||
|
||||
struct AddAttachmentConditionsModifier: ViewModifier {
|
||||
@ObservedObject var draft: Draft
|
||||
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
||||
|
||||
private var canAddAttachment: Bool {
|
||||
if instanceFeatures.mastodonAttachmentRestrictions {
|
||||
return draft.attachments.count < 4
|
||||
&& draft.draftAttachments.allSatisfy { $0.type == .image }
|
||||
&& draft.poll == nil
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.environment(\.canAddAttachment, canAddAttachment)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CanAddAttachmentKey: EnvironmentKey {
|
||||
static let defaultValue = false
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var canAddAttachment: Bool {
|
||||
get { self[CanAddAttachmentKey.self] }
|
||||
set { self[CanAddAttachmentKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
struct DropAttachmentModifier: ViewModifier {
|
||||
let draft: Draft
|
||||
@Environment(\.canAddAttachment) private var canAddAttachment
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onDrop(of: DraftAttachment.readableTypeIdentifiersForItemProvider, delegate: AttachmentDropDelegate(draft: draft, canAddAttachment: canAddAttachment))
|
||||
}
|
||||
}
|
||||
|
||||
private struct AttachmentDropDelegate: DropDelegate {
|
||||
let draft: Draft
|
||||
let canAddAttachment: Bool
|
||||
|
||||
func validateDrop(info: DropInfo) -> Bool {
|
||||
canAddAttachment
|
||||
}
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: info.itemProviders(for: DraftAttachment.readableTypeIdentifiersForItemProvider))
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
// ComposeDraftView.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 11/16/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
struct ComposeDraftView: View {
|
||||
@ObservedObject var draft: Draft
|
||||
@FocusState.Binding var focusedField: FocusableField?
|
||||
@Environment(\.currentAccount) private var currentAccount
|
||||
@EnvironmentObject private var controller: ComposeController
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
// TODO: scroll effect?
|
||||
AvatarImageView(
|
||||
url: currentAccount?.avatar,
|
||||
size: 50,
|
||||
style: controller.config.avatarStyle,
|
||||
fetchAvatar: controller.fetchAvatar
|
||||
)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let currentAccount {
|
||||
AccountNameView(account: currentAccount)
|
||||
}
|
||||
|
||||
ContentWarningTextField(draft: draft, focusedField: $focusedField)
|
||||
|
||||
DraftContentEditor(draft: draft, focusedField: $focusedField)
|
||||
|
||||
AttachmentsSection(draft: draft)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountNameView: View {
|
||||
let account: any AccountProtocol
|
||||
@EnvironmentObject private var controller: ComposeController
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
controller.displayNameLabel(account, .body, 16)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(verbatim: "@\(account.acct)")
|
||||
.font(.body.weight(.light))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,242 @@
|
|||
//
|
||||
// ComposeToolbarView.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 8/10/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import TuskerComponents
|
||||
import InstanceFeatures
|
||||
import Pachyderm
|
||||
import TuskerPreferences
|
||||
|
||||
struct ComposeToolbarView: View {
|
||||
static let height: CGFloat = 44
|
||||
|
||||
@ObservedObject var draft: Draft
|
||||
let mastodonController: any ComposeMastodonContext
|
||||
@FocusState.Binding var focusedField: FocusableField?
|
||||
|
||||
var body: some View {
|
||||
#if os(visionOS)
|
||||
buttons
|
||||
#else
|
||||
ToolbarScrollView {
|
||||
buttons
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.frame(height: Self.height)
|
||||
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
|
||||
.overlay(alignment: .top) {
|
||||
Divider()
|
||||
.ignoresSafeArea(edges: [.leading, .trailing])
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var buttons: some View {
|
||||
HStack(spacing: 0) {
|
||||
ContentWarningButton(enabled: $draft.contentWarningEnabled, focusedField: $focusedField)
|
||||
|
||||
VisibilityButton(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
||||
|
||||
LocalOnlyButton(enabled: $draft.localOnly, mastodonController: mastodonController)
|
||||
|
||||
InsertEmojiButton()
|
||||
|
||||
FormatButtons()
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(visionOS)
|
||||
private struct ToolbarScrollView<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
@State private var minWidth: CGFloat?
|
||||
@State private var realWidth: CGFloat?
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
content
|
||||
.frame(minWidth: minWidth)
|
||||
.background {
|
||||
GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
||||
.onPreferenceChange(ToolbarWidthPrefKey.self) {
|
||||
realWidth = $0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollDisabled(realWidth ?? 0 <= minWidth ?? 0)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
||||
.onPreferenceChange(ToolbarWidthPrefKey.self) {
|
||||
minWidth = $0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private struct ToolbarWidthPrefKey: SwiftUI.PreferenceKey {
|
||||
static var defaultValue: CGFloat? = nil
|
||||
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
|
||||
value = value ?? nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContentWarningButton: View {
|
||||
@Binding var enabled: Bool
|
||||
@FocusState.Binding var focusedField: FocusableField?
|
||||
|
||||
var body: some View {
|
||||
Button("CW", action: toggleContentWarning)
|
||||
.accessibilityLabel(enabled ? "Remove content warning" : "Add content warning")
|
||||
.padding(5)
|
||||
.hoverEffect()
|
||||
}
|
||||
|
||||
private func toggleContentWarning() {
|
||||
enabled.toggle()
|
||||
if enabled {
|
||||
focusedField = .contentWarning
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct VisibilityButton: View {
|
||||
@ObservedObject var draft: Draft
|
||||
@ObservedObject var instanceFeatures: InstanceFeatures
|
||||
|
||||
private var visibilityBinding: Binding<Pachyderm.Visibility> {
|
||||
// On instances that conflate visibliity and local only, we still show two separate controls but don't allow
|
||||
// changing the visibility when local-only.
|
||||
if draft.localOnly,
|
||||
instanceFeatures.localOnlyPostsVisibility {
|
||||
return .constant(.public)
|
||||
} else {
|
||||
return $draft.visibility
|
||||
}
|
||||
}
|
||||
|
||||
private var visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] {
|
||||
let visibilities: [Pachyderm.Visibility]
|
||||
if !instanceFeatures.composeDirectStatuses {
|
||||
visibilities = [.public, .unlisted, .private]
|
||||
} else {
|
||||
visibilities = Pachyderm.Visibility.allCases
|
||||
}
|
||||
return visibilities.map { vis in
|
||||
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
|
||||
#if !targetEnvironment(macCatalyst) && !os(visionOS)
|
||||
// the button has a bunch of extra space by default, but combined with what we add it's too much
|
||||
.padding(.horizontal, -8)
|
||||
#endif
|
||||
.disabled(draft.editedStatusID != nil)
|
||||
.disabled(instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
|
||||
}
|
||||
}
|
||||
|
||||
private struct LocalOnlyButton: View {
|
||||
@Binding var enabled: Bool
|
||||
var mastodonController: any ComposeMastodonContext
|
||||
@ObservedObject private var instanceFeatures: InstanceFeatures
|
||||
|
||||
init(enabled: Binding<Bool>, mastodonController: any ComposeMastodonContext) {
|
||||
self._enabled = enabled
|
||||
self.mastodonController = mastodonController
|
||||
self.instanceFeatures = mastodonController.instanceFeatures
|
||||
}
|
||||
|
||||
private var options: [MenuPicker<Bool>.Option] {
|
||||
let domain = mastodonController.accountInfo!.instanceURL.host!
|
||||
return [
|
||||
.init(value: true, title: "Local-only", subtitle: "Only \(domain)", image: UIImage(named: "link.broken")),
|
||||
.init(value: false, title: "Federated", image: UIImage(systemName: "link")),
|
||||
]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if mastodonController.instanceFeatures.localOnlyPosts {
|
||||
MenuPicker(selection: $enabled, options: options, buttonStyle: .iconOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct InsertEmojiButton: View {
|
||||
@FocusedValue(\.composeInput) private var input
|
||||
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
|
||||
|
||||
var body: some View {
|
||||
if input?.toolbarElements.contains(.emojiPicker) == true {
|
||||
Button(action: beginAutocompletingEmoji) {
|
||||
Label("Insert custom emoji", systemImage: "face.smiling")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.system(size: imageSize))
|
||||
.padding(5)
|
||||
.hoverEffect()
|
||||
.transition(.opacity.animation(.linear(duration: 0.2)))
|
||||
}
|
||||
}
|
||||
|
||||
private func beginAutocompletingEmoji() {
|
||||
input?.beginAutocompletingEmoji()
|
||||
}
|
||||
}
|
||||
|
||||
private struct FormatButtons: View {
|
||||
@FocusedValue(\.composeInput) private var input
|
||||
@PreferenceObserving(\.$statusContentType) private var contentType
|
||||
|
||||
var body: some View {
|
||||
if let input,
|
||||
input.toolbarElements.contains(.formattingButtons),
|
||||
contentType != .plain {
|
||||
|
||||
Spacer()
|
||||
ForEach(StatusFormat.allCases) { format in
|
||||
FormatButton(format: format, input: input)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct FormatButton: View {
|
||||
let format: StatusFormat
|
||||
let input: any ComposeInput
|
||||
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
|
||||
|
||||
var body: some View {
|
||||
Button(action: applyFormat) {
|
||||
Image(systemName: format.imageName)
|
||||
.font(.system(size: imageSize))
|
||||
}
|
||||
.accessibilityLabel(format.accessibilityLabel)
|
||||
.padding(5)
|
||||
.hoverEffect()
|
||||
.transition(.opacity.animation(.linear(duration: 0.2)))
|
||||
}
|
||||
|
||||
private func applyFormat() {
|
||||
input.applyFormat(format)
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// ComposeToolbarView()
|
||||
//}
|
|
@ -0,0 +1,237 @@
|
|||
//
|
||||
// ComposeView.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 8/10/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ComposeView: View {
|
||||
@ObservedObject var draft: Draft
|
||||
let mastodonController: any ComposeMastodonContext
|
||||
@State private var poster: PostService? = nil
|
||||
@FocusState private var focusedField: FocusableField?
|
||||
@EnvironmentObject private var controller: ComposeController
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
navigationRoot
|
||||
}
|
||||
.environmentObject(mastodonController.instanceFeatures)
|
||||
}
|
||||
|
||||
private var navigationRoot: some View {
|
||||
ZStack {
|
||||
ScrollView {
|
||||
scrollContent
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#if !os(visionOS) && !targetEnvironment(macCatalyst)
|
||||
.modifier(ToolbarSafeAreaInsetModifier())
|
||||
#endif
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if let poster {
|
||||
PostProgressView(poster: poster)
|
||||
.frame(alignment: .top)
|
||||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.overlay(alignment: .bottom, content: {
|
||||
// TODO: during ducking animation, toolbar should move off the botto edge
|
||||
// This needs to be in an overlay, ignoring the keyboard safe area
|
||||
// doesn't work with the safeAreaInset modifier.
|
||||
toolbarView
|
||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||
.ignoresSafeArea(.keyboard)
|
||||
})
|
||||
#endif
|
||||
// Have these after the overlays so they barely work instead of not working at all. FB11790805
|
||||
.modifier(DropAttachmentModifier(draft: draft))
|
||||
.modifier(AddAttachmentConditionsModifier(draft: draft))
|
||||
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarActions(draft: draft, controller: controller)
|
||||
#if os(visionOS)
|
||||
ToolbarItem(placement: .bottomOrnament) {
|
||||
toolbarView
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var toolbarView: some View {
|
||||
ComposeToolbarView(draft: draft, mastodonController: mastodonController, focusedField: $focusedField)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var scrollContent: some View {
|
||||
VStack(spacing: 8) {
|
||||
NewReplyStatusView(draft: draft, mastodonController: mastodonController)
|
||||
|
||||
ComposeDraftView(draft: draft, focusedField: $focusedField)
|
||||
}
|
||||
.padding(8)
|
||||
// NewHeaderView(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
||||
// .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||
// .listRowSeparator(.hidden)
|
||||
//
|
||||
// ContentWarningTextField(draft: draft, focusedField: $focusedField)
|
||||
// .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||
// .listRowSeparator(.hidden)
|
||||
//
|
||||
// NewMainTextView(value: $draft.text, focusedField: $focusedField, handleAttachmentDrop: self.addAttachments)
|
||||
// .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||
// .listRowSeparator(.hidden)
|
||||
//
|
||||
// AttachmentsListSection(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
||||
}
|
||||
|
||||
private func addAttachments(_ itemProviders: [NSItemProvider]) {
|
||||
AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NavigationTitleModifier: ViewModifier {
|
||||
let draft: Draft
|
||||
let mastodonController: any ComposeMastodonContext
|
||||
|
||||
private var navigationTitle: String {
|
||||
if let id = draft.inReplyToID,
|
||||
let status = mastodonController.fetchStatus(id: id) {
|
||||
return "Reply to @\(status.account.acct)"
|
||||
} else if draft.editedStatusID != nil {
|
||||
return "Edit Post"
|
||||
} else {
|
||||
return "New Post"
|
||||
}
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.navigationTitle(navigationTitle)
|
||||
.preference(key: NavigationTitlePreferenceKey.self, value: navigationTitle)
|
||||
}
|
||||
}
|
||||
|
||||
// Public preference so that the host can read the title.
|
||||
public struct NavigationTitlePreferenceKey: PreferenceKey {
|
||||
public static var defaultValue: String? { nil }
|
||||
public static func reduce(value: inout String?, nextValue: () -> String?) {
|
||||
value = value ?? nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolbarActions: ToolbarContent {
|
||||
@ObservedObject var draft: Draft
|
||||
// Prior to iOS 16, the toolbar content doesn't seem to have access
|
||||
// to the environment form the containing view.
|
||||
let controller: ComposeController
|
||||
|
||||
var body: some ToolbarContent {
|
||||
ToolbarItem(placement: .cancellationAction) { ToolbarCancelButton(draft: draft) }
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
ToolbarItem(placement: .topBarTrailing) { draftsButton }
|
||||
ToolbarItem(placement: .confirmationAction) { postButton }
|
||||
#else
|
||||
ToolbarItem(placement: .confirmationAction) { postOrDraftsButton }
|
||||
#endif
|
||||
}
|
||||
|
||||
private var draftsButton: some View {
|
||||
Button(action: controller.showDrafts) {
|
||||
Text("Drafts")
|
||||
}
|
||||
}
|
||||
|
||||
private var postButton: some View {
|
||||
// TODO: don't use the controller for this
|
||||
Button(action: controller.postStatus) {
|
||||
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
||||
}
|
||||
.keyboardShortcut(.return, modifiers: .command)
|
||||
.disabled(!controller.postButtonEnabled)
|
||||
}
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
@ViewBuilder
|
||||
private var postOrDraftsButton: some View {
|
||||
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
|
||||
postButton
|
||||
} else {
|
||||
draftsButton
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private struct ToolbarCancelButton: View {
|
||||
let draft: Draft
|
||||
@EnvironmentObject private var controller: ComposeController
|
||||
|
||||
var body: some View {
|
||||
Button(action: controller.cancel) {
|
||||
Text("Cancel")
|
||||
// otherwise all Buttons in the nav bar are made semibold
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
}
|
||||
.disabled(controller.isPosting)
|
||||
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
|
||||
// edit drafts can't be saved
|
||||
if draft.editedStatusID == nil {
|
||||
Button(action: { controller.cancel(deleteDraft: false) }) {
|
||||
Text("Save Draft")
|
||||
}
|
||||
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
|
||||
Text("Delete Draft")
|
||||
}
|
||||
} else {
|
||||
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
|
||||
Text("Cancel Edit")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum FocusableField: Hashable {
|
||||
case contentWarning
|
||||
case body
|
||||
case attachmentDescription(UUID)
|
||||
|
||||
var nextField: FocusableField? {
|
||||
switch self {
|
||||
case .contentWarning:
|
||||
return .body
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(visionOS) && !targetEnvironment(macCatalyst)
|
||||
private struct ToolbarSafeAreaInsetModifier: ViewModifier {
|
||||
@StateObject private var keyboardReader = KeyboardReader()
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 17.0, *) {
|
||||
content
|
||||
.safeAreaPadding(.bottom, keyboardReader.isVisible ? 0 : ComposeToolbarView.height)
|
||||
} else {
|
||||
content
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if !keyboardReader.isVisible {
|
||||
Color.clear.frame(height: ComposeToolbarView.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
//#Preview {
|
||||
// ComposeView()
|
||||
//}
|
|
@ -0,0 +1,38 @@
|
|||
//
|
||||
// ContentWarningTextField.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 8/10/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ContentWarningTextField: View {
|
||||
@ObservedObject var draft: Draft
|
||||
@FocusState.Binding var focusedField: FocusableField?
|
||||
|
||||
var body: some View {
|
||||
if draft.contentWarningEnabled {
|
||||
EmojiTextField(
|
||||
text: $draft.contentWarning,
|
||||
placeholder: "Write your warning here",
|
||||
maxLength: nil,
|
||||
// TODO: completely replace this with FocusState
|
||||
becomeFirstResponder: .constant(false),
|
||||
focusNextView: Binding(get: {
|
||||
false
|
||||
}, set: {
|
||||
if $0 {
|
||||
focusedField = .body
|
||||
}
|
||||
})
|
||||
)
|
||||
.focused($focusedField, equals: .contentWarning)
|
||||
.modifier(FocusedInputModifier())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// ContentWarningTextField()
|
||||
//}
|
|
@ -0,0 +1,96 @@
|
|||
//
|
||||
// DraftContentEditor.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 11/16/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import InstanceFeatures
|
||||
|
||||
struct DraftContentEditor: View {
|
||||
@ObservedObject var draft: Draft
|
||||
@FocusState.Binding var focusedField: FocusableField?
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.composeUIConfig.fillColor) private var fillColor
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
NewMainTextView(value: $draft.text, focusedField: $focusedField, handleAttachmentDrop: self.addAttachments)
|
||||
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
LanguageButton(draft: draft)
|
||||
Spacer()
|
||||
CharactersRemaining(draft: draft)
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
.padding(.all.subtracting(.top), 2)
|
||||
}
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(colorScheme == .dark ? fillColor : Color(uiColor: .secondarySystemBackground))
|
||||
}
|
||||
}
|
||||
|
||||
private func addAttachments(_ providers: [NSItemProvider]) {
|
||||
AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: providers)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CharactersRemaining: View {
|
||||
@ObservedObject var draft: Draft
|
||||
@EnvironmentObject var instanceFeatures: InstanceFeatures
|
||||
|
||||
private var charsRemaining: Int {
|
||||
let limit = instanceFeatures.maxStatusChars
|
||||
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
|
||||
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(verbatim: charsRemaining.description)
|
||||
.foregroundStyle(charsRemaining < 0 ? .red : .secondary)
|
||||
.font(.callout.monospacedDigit())
|
||||
.accessibility(label: Text(charsRemaining < 0 ? "\(-charsRemaining) characters too many" : "\(charsRemaining) characters remaining"))
|
||||
}
|
||||
}
|
||||
|
||||
private struct LanguageButton: View {
|
||||
@ObservedObject var draft: Draft
|
||||
@EnvironmentObject private var instanceFeatures: InstanceFeatures
|
||||
@FocusedValue(\.composeInput) private var input
|
||||
@State private var hasChanged = false
|
||||
|
||||
var body: some View {
|
||||
if instanceFeatures.createStatusWithLanguage {
|
||||
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $hasChanged)
|
||||
.buttonStyle(LanguageButtonStyle())
|
||||
.onReceive(NotificationCenter.default.publisher(for: UITextInputMode.currentInputModeDidChangeNotification), perform: currentInputModeChanged)
|
||||
.onChange(of: draft.id) { _ in
|
||||
hasChanged = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func currentInputModeChanged(_ notification: Foundation.Notification) {
|
||||
guard !hasChanged,
|
||||
!draft.hasContent,
|
||||
let mode = input?.textInputMode,
|
||||
let code = LanguagePicker.codeFromInputMode(mode) else {
|
||||
return
|
||||
}
|
||||
draft.language = code.identifier
|
||||
}
|
||||
}
|
||||
|
||||
private struct LanguageButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.callout)
|
||||
.foregroundStyle(.tint.opacity(configuration.isPressed ? 0.8 : 1))
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal, 4)
|
||||
.background(.tint.opacity(configuration.isPressed ? 0.15 : 0.2), in: RoundedRectangle(cornerRadius: 3))
|
||||
.animation(.linear(duration: 0.1), value: configuration.isPressed)
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@ struct EmojiTextField: UIViewRepresentable {
|
|||
|
||||
@EnvironmentObject private var controller: ComposeController
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.composeInputBox) private var inputBox
|
||||
|
||||
@Binding var text: String
|
||||
let placeholder: String
|
||||
|
@ -75,7 +76,11 @@ struct EmojiTextField: UIViewRepresentable {
|
|||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(controller: controller, text: $text, focusNextView: focusNextView)
|
||||
let coordinator = Coordinator(controller: controller, text: $text, focusNextView: focusNextView)
|
||||
DispatchQueue.main.async {
|
||||
inputBox.wrappedValue = coordinator
|
||||
}
|
||||
return coordinator
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
|
||||
|
@ -113,12 +118,16 @@ struct EmojiTextField: UIViewRepresentable {
|
|||
}
|
||||
|
||||
func textFieldDidBeginEditing(_ textField: UITextField) {
|
||||
controller.currentInput = self
|
||||
DispatchQueue.main.async {
|
||||
self.controller.currentInput = self
|
||||
}
|
||||
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
||||
}
|
||||
|
||||
func textFieldDidEndEditing(_ textField: UITextField) {
|
||||
controller.currentInput = nil
|
||||
DispatchQueue.main.async {
|
||||
self.controller.currentInput = nil
|
||||
}
|
||||
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
||||
}
|
||||
|
||||
|
|
|
@ -29,3 +29,19 @@ struct HeaderView: View {
|
|||
}.frame(height: 50)
|
||||
}
|
||||
}
|
||||
|
||||
struct NewHeaderView: View {
|
||||
@ObservedObject var draft: Draft
|
||||
@ObservedObject var instanceFeatures: InstanceFeatures
|
||||
@Environment(\.currentAccount) private var currentAccount
|
||||
|
||||
private var charactersRemaining: Int {
|
||||
let limit = instanceFeatures.maxStatusChars
|
||||
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
|
||||
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HeaderView(currentAccount: currentAccount, charsRemaining: charactersRemaining)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,282 @@
|
|||
//
|
||||
// NewMainTextView.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 8/11/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct NewMainTextView: View {
|
||||
static var minHeight: CGFloat { 150 }
|
||||
|
||||
@Binding var value: String
|
||||
@FocusState.Binding var focusedField: FocusableField?
|
||||
var handleAttachmentDrop: ([NSItemProvider]) -> Void
|
||||
@State private var becomeFirstResponder = true
|
||||
|
||||
var body: some View {
|
||||
NewMainTextViewRepresentable(value: $value, becomeFirstResponder: $becomeFirstResponder, handleAttachmentDrop: handleAttachmentDrop)
|
||||
.focused($focusedField, equals: .body)
|
||||
.modifier(FocusedInputModifier())
|
||||
.overlay(alignment: .topLeading) {
|
||||
if value.isEmpty {
|
||||
PlaceholderView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewMainTextViewRepresentable: UIViewRepresentable {
|
||||
@Binding var value: String
|
||||
@Binding var becomeFirstResponder: Bool
|
||||
var handleAttachmentDrop: ([NSItemProvider]) -> Void
|
||||
@Environment(\.composeInputBox) private var inputBox
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.composeUIConfig.fillColor) private var fillColor
|
||||
@Environment(\.composeUIConfig.useTwitterKeyboard) private var useTwitterKeyboard
|
||||
// TODO: test textSelectionStartsAtBeginning
|
||||
@Environment(\.composeUIConfig.textSelectionStartsAtBeginning) private var textSelectionStartsAtBeginning
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
// TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary
|
||||
let view = WrappedTextView(usingTextLayoutManager: true)
|
||||
view.addInteraction(UIDropInteraction(delegate: context.coordinator))
|
||||
view.delegate = context.coordinator
|
||||
view.adjustsFontForContentSizeCategory = true
|
||||
view.textContainer.lineBreakMode = .byWordWrapping
|
||||
view.isScrollEnabled = false
|
||||
view.typingAttributes = [
|
||||
.foregroundColor: UIColor.label,
|
||||
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
|
||||
]
|
||||
view.backgroundColor = nil
|
||||
|
||||
// view.layer.cornerRadius = 5
|
||||
// view.layer.cornerCurve = .continuous
|
||||
// view.layer.shadowColor = UIColor.black.cgColor
|
||||
// view.layer.shadowOpacity = 0.15
|
||||
// view.layer.shadowOffset = .zero
|
||||
// view.layer.shadowRadius = 1
|
||||
|
||||
if textSelectionStartsAtBeginning {
|
||||
// Update the text immediately so that the selection isn't invalidated by the text changing.
|
||||
context.coordinator.updateTextViewTextIfNecessary(value, textView: view)
|
||||
view.selectedTextRange = view.textRange(from: view.beginningOfDocument, to: view.beginningOfDocument)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIViewType, context: Context) {
|
||||
context.coordinator.value = $value
|
||||
context.coordinator.handleAttachmentDrop = handleAttachmentDrop
|
||||
context.coordinator.updateTextViewTextIfNecessary(value, textView: uiView)
|
||||
|
||||
uiView.isEditable = isEnabled
|
||||
uiView.keyboardType = useTwitterKeyboard ? .twitter : .default
|
||||
// #if !os(visionOS)
|
||||
// uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground
|
||||
// #endif
|
||||
|
||||
// Trying to set this with the @FocusState binding in onAppear results in the
|
||||
// keyboard not appearing until after the sheet presentation animation completes :/
|
||||
if becomeFirstResponder {
|
||||
uiView.becomeFirstResponder()
|
||||
DispatchQueue.main.async {
|
||||
becomeFirstResponder = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> WrappedTextViewCoordinator {
|
||||
let coordinator = WrappedTextViewCoordinator(value: $value, handleAttachmentDrop: handleAttachmentDrop)
|
||||
// DispatchQueue.main.async {
|
||||
// inputBox.wrappedValue = coordinator
|
||||
// }
|
||||
return coordinator
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIViewType, context: Context) -> CGSize? {
|
||||
let width = proposal.width ?? 10
|
||||
let size = uiView.sizeThatFits(CGSize(width: width, height: 0))
|
||||
return CGSize(width: width, height: max(NewMainTextView.minHeight, size.height))
|
||||
}
|
||||
}
|
||||
|
||||
// laxer than the CharacterCounter regex, because we want to find mentions that are being typed but aren't yet complete (e.g., "@a@b")
|
||||
private let mentionRegex = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+)?", options: .caseInsensitive)
|
||||
|
||||
private final class WrappedTextViewCoordinator: NSObject {
|
||||
private static let attachment: NSTextAttachment = {
|
||||
let font = UIFont.systemFont(ofSize: 20)
|
||||
let size = /*1.4 * */font.capHeight
|
||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size))
|
||||
let image = renderer.image { ctx in
|
||||
UIColor.systemRed.setFill()
|
||||
ctx.fill(CGRect(x: 0, y: 0, width: size, height: size))
|
||||
}
|
||||
let attachment = NSTextAttachment(image: image)
|
||||
attachment.bounds = CGRect(x: 0, y: -1, width: size + 2, height: size)
|
||||
attachment.lineLayoutPadding = 1
|
||||
return attachment
|
||||
}()
|
||||
|
||||
var value: Binding<String>
|
||||
var handleAttachmentDrop: ([NSItemProvider]) -> Void
|
||||
|
||||
init(value: Binding<String>, handleAttachmentDrop: @escaping ([NSItemProvider]) -> Void) {
|
||||
self.value = value
|
||||
self.handleAttachmentDrop = handleAttachmentDrop
|
||||
}
|
||||
|
||||
private func plainTextFromAttributed(_ attributedText: NSAttributedString) -> String {
|
||||
attributedText.string.replacingOccurrences(of: "\u{FFFC}", with: "")
|
||||
}
|
||||
|
||||
private func attributedTextFromPlain(_ text: String) -> NSAttributedString {
|
||||
let str = NSMutableAttributedString(string: text)
|
||||
let mentionMatches = CharacterCounter.mention.matches(in: text, range: NSRange(location: 0, length: str.length))
|
||||
for match in mentionMatches.reversed() {
|
||||
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
|
||||
let range = NSRange(location: match.range.location, length: match.range.length + 1)
|
||||
str.addAttributes([
|
||||
.mention: true,
|
||||
.foregroundColor: UIColor.tintColor,
|
||||
], range: range)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
func updateTextViewTextIfNecessary(_ text: String, textView: UITextView) {
|
||||
if text != plainTextFromAttributed(textView.attributedText) {
|
||||
textView.attributedText = attributedTextFromPlain(text)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAttributes(in textView: UITextView) {
|
||||
let str = NSMutableAttributedString(attributedString: textView.attributedText!)
|
||||
var changed = false
|
||||
var cursorOffset = 0
|
||||
|
||||
// remove existing mentions that aren't valid
|
||||
str.enumerateAttribute(.mention, in: NSRange(location: 0, length: str.length), options: .reverse) { value, range, stop in
|
||||
var substr = (str.string as NSString).substring(with: range)
|
||||
let hasTextAttachment = substr.unicodeScalars.first == UnicodeScalar(NSTextAttachment.character)
|
||||
if hasTextAttachment {
|
||||
substr = String(substr.dropFirst())
|
||||
}
|
||||
if mentionRegex.numberOfMatches(in: substr, range: NSRange(location: 0, length: substr.utf16.count)) == 0 {
|
||||
changed = true
|
||||
str.removeAttribute(.mention, range: range)
|
||||
str.removeAttribute(.foregroundColor, range: range)
|
||||
if hasTextAttachment {
|
||||
str.deleteCharacters(in: NSRange(location: range.location, length: 1))
|
||||
cursorOffset -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add mentions for those missing
|
||||
let mentionMatches = mentionRegex.matches(in: str.string, range: NSRange(location: 0, length: str.length))
|
||||
for match in mentionMatches.reversed() {
|
||||
var attributeRange = NSRange()
|
||||
let attribute = str.attribute(.mention, at: match.range.location, effectiveRange: &attributeRange)
|
||||
// the attribute range should always be one greater than the match range, to account for the text attachment
|
||||
if attribute == nil || attributeRange.length <= match.range.length {
|
||||
changed = true
|
||||
let newAttributeRange: NSRange
|
||||
if attribute == nil {
|
||||
str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location)
|
||||
newAttributeRange = NSRange(location: match.range.location, length: match.range.length + 1)
|
||||
cursorOffset += 1
|
||||
} else {
|
||||
newAttributeRange = match.range
|
||||
}
|
||||
str.addAttributes([
|
||||
.mention: true,
|
||||
.foregroundColor: UIColor.tintColor,
|
||||
], range: newAttributeRange)
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
let selection = textView.selectedRange
|
||||
|
||||
textView.attributedText = str
|
||||
|
||||
textView.selectedRange = NSRange(location: selection.location + cursorOffset, length: selection.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WrappedTextViewCoordinator: UITextViewDelegate {
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
if textView.text.isEmpty {
|
||||
textView.typingAttributes = [
|
||||
.foregroundColor: UIColor.label,
|
||||
.font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)),
|
||||
]
|
||||
} else {
|
||||
updateAttributes(in: textView)
|
||||
}
|
||||
|
||||
let plain = plainTextFromAttributed(textView.attributedText)
|
||||
if plain != value.wrappedValue {
|
||||
value.wrappedValue = plain
|
||||
}
|
||||
}
|
||||
|
||||
func textViewDidChangeSelection(_ textView: UITextView) {
|
||||
}
|
||||
}
|
||||
|
||||
//extension WrappedTextViewCoordinator: ComposeInput {
|
||||
//
|
||||
//}
|
||||
|
||||
// Because of FB11790805, we can't handle drops for the entire screen.
|
||||
// The onDrop modifier also doesn't work when applied to the NewMainTextView.
|
||||
// So, manually add the UIInteraction to at least handle that.
|
||||
extension WrappedTextViewCoordinator: UIDropInteractionDelegate {
|
||||
func dropInteraction(_ interaction: UIDropInteraction, canHandle session: any UIDropSession) -> Bool {
|
||||
session.canLoadObjects(ofClass: DraftAttachment.self)
|
||||
}
|
||||
func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: any UIDropSession) -> UIDropProposal {
|
||||
UIDropProposal(operation: .copy)
|
||||
}
|
||||
func dropInteraction(_ interaction: UIDropInteraction, performDrop session: any UIDropSession) {
|
||||
handleAttachmentDrop(session.items.map(\.itemProvider))
|
||||
}
|
||||
}
|
||||
|
||||
private final class WrappedTextView: UITextView {
|
||||
}
|
||||
|
||||
private extension NSAttributedString.Key {
|
||||
static let mention = NSAttributedString.Key("Tusker.ComposeUI.mention")
|
||||
}
|
||||
|
||||
private struct PlaceholderView: View {
|
||||
@State private var placeholder: PlaceholderController.PlaceholderView = PlaceholderController.makePlaceholderView()
|
||||
@ScaledMetric private var fontSize = 20
|
||||
|
||||
private var placeholderOffset: CGSize {
|
||||
#if os(visionOS)
|
||||
CGSize(width: 8, height: 8)
|
||||
#else
|
||||
CGSize(width: 4, height: 8)
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
placeholder
|
||||
.font(.system(size: fontSize))
|
||||
.foregroundStyle(.secondary)
|
||||
.offset(placeholderOffset)
|
||||
.accessibilityHidden(true)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
//
|
||||
// PostProgressView.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 8/10/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PostProgressView: View {
|
||||
@ObservedObject var poster: PostService
|
||||
|
||||
var body: some View {
|
||||
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
|
||||
WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// PostProgressView()
|
||||
//}
|
|
@ -132,3 +132,21 @@ private struct AvatarContainerRepresentable<Content: View>: UIViewControllerRepr
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NewReplyStatusView: View {
|
||||
let draft: Draft
|
||||
let mastodonController: any ComposeMastodonContext
|
||||
|
||||
var body: some View {
|
||||
if let id = draft.inReplyToID,
|
||||
let status = mastodonController.fetchStatus(id: id) {
|
||||
ReplyStatusView(
|
||||
status: status,
|
||||
rowTopInset: 8,
|
||||
globalFrameOutsideList: .zero
|
||||
)
|
||||
// i don't know why swiftui can't infer this from the status that's passed into the ReplyStatusView changing
|
||||
.id(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -157,7 +157,6 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi
|
|||
}
|
||||
|
||||
open var activityItemsForSharing: [Any] {
|
||||
// [VideoActivityItemSource(asset: item.asset, url: url)]
|
||||
[]
|
||||
}
|
||||
|
||||
|
|
|
@ -15,8 +15,11 @@ public protocol GalleryContentViewController: UIViewController {
|
|||
var caption: String? { get }
|
||||
var contentOverlayAccessoryViewController: UIViewController? { get }
|
||||
var bottomControlsAccessoryViewController: UIViewController? { get }
|
||||
var insetBottomControlsAccessoryViewControllerToSafeArea: Bool { get }
|
||||
var presentationAnimation: GalleryContentPresentationAnimation { get }
|
||||
var hideControlsOnZoom: Bool { get }
|
||||
|
||||
func shouldHideControls() -> Bool
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool)
|
||||
func galleryContentDidAppear()
|
||||
func galleryContentWillDisappear()
|
||||
|
@ -31,10 +34,22 @@ public extension GalleryContentViewController {
|
|||
nil
|
||||
}
|
||||
|
||||
var insetBottomControlsAccessoryViewControllerToSafeArea: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
var presentationAnimation: GalleryContentPresentationAnimation {
|
||||
.fromSourceView
|
||||
}
|
||||
|
||||
var hideControlsOnZoom: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func shouldHideControls() -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||
}
|
||||
|
||||
|
|
|
@ -45,6 +45,12 @@ class GalleryItemViewController: UIViewController {
|
|||
|
||||
private var scrollViewSizeForLastZoomScaleUpdate: CGSize?
|
||||
|
||||
var showShareButton: Bool = true {
|
||||
didSet {
|
||||
shareButton?.isHidden = !showShareButton
|
||||
}
|
||||
}
|
||||
|
||||
override var prefersHomeIndicatorAutoHidden: Bool {
|
||||
return !controlsVisible
|
||||
}
|
||||
|
@ -66,6 +72,10 @@ class GalleryItemViewController: UIViewController {
|
|||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// Need to use the keyboard layout guide in some way in this VC,
|
||||
// otherwise the keyboardLayoutGuide inside the bottom controls accessory view doesn't animate
|
||||
_ = view.keyboardLayoutGuide
|
||||
|
||||
scrollView = UIScrollView()
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
scrollView.delegate = self
|
||||
|
@ -109,6 +119,7 @@ class GalleryItemViewController: UIViewController {
|
|||
return UIPointerStyle(effect: .highlight(effect.preview), shape: .roundedRect(button.frame))
|
||||
}
|
||||
shareButton.preferredBehavioralStyle = .pad
|
||||
shareButton.isHidden = !showShareButton
|
||||
shareButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
updateShareButton()
|
||||
topControlsView.addSubview(shareButton)
|
||||
|
@ -141,12 +152,14 @@ class GalleryItemViewController: UIViewController {
|
|||
bottomControlsView.addArrangedSubview(controlsAccessory.view)
|
||||
controlsAccessory.didMove(toParent: self)
|
||||
|
||||
// Make sure the controls accessory is within the safe area.
|
||||
let spacer = UIView()
|
||||
bottomControlsView.addArrangedSubview(spacer)
|
||||
let spacerTopConstraint = spacer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
|
||||
spacerTopConstraint.priority = .init(999)
|
||||
spacerTopConstraint.isActive = true
|
||||
if content.insetBottomControlsAccessoryViewControllerToSafeArea {
|
||||
// Make sure the controls accessory is within the safe area.
|
||||
let spacer = UIView()
|
||||
bottomControlsView.addArrangedSubview(spacer)
|
||||
let spacerTopConstraint = spacer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
|
||||
spacerTopConstraint.priority = .init(999)
|
||||
spacerTopConstraint.isActive = true
|
||||
}
|
||||
}
|
||||
|
||||
captionTextView = UITextView()
|
||||
|
@ -210,6 +223,14 @@ class GalleryItemViewController: UIViewController {
|
|||
singleTap.require(toFail: doubleTap)
|
||||
view.addGestureRecognizer(singleTap)
|
||||
view.addGestureRecognizer(doubleTap)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillUpdate), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillUpdate), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillUpdate), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc private func keyboardWillUpdate() {
|
||||
updateZoomScale(resetZoom: true)
|
||||
}
|
||||
|
||||
override func viewSafeAreaInsetsDidChange() {
|
||||
|
@ -332,7 +353,7 @@ class GalleryItemViewController: UIViewController {
|
|||
return
|
||||
}
|
||||
|
||||
let heightScale = view.bounds.height / content.contentSize.height
|
||||
let heightScale = (view.bounds.height - view.keyboardLayoutGuide.layoutFrame.height) / content.contentSize.height
|
||||
let widthScale = view.bounds.width / content.contentSize.width
|
||||
let minScale = min(widthScale, heightScale)
|
||||
let maxScale = minScale >= 1 ? minScale + 2 : 2
|
||||
|
@ -355,7 +376,7 @@ class GalleryItemViewController: UIViewController {
|
|||
|
||||
// Note: use frame for the content.view, because that's in the coordinate space of the scroll view
|
||||
// which means it's already been scaled by the zoom factor.
|
||||
let yOffset = max(0, (view.bounds.height - content.view.frame.height) / 2)
|
||||
let yOffset = max(0, (view.bounds.height - view.keyboardLayoutGuide.layoutFrame.height - content.view.frame.height) / 2)
|
||||
contentViewTopConstraint!.constant = yOffset
|
||||
|
||||
let xOffset = max(0, (view.bounds.width - content.view.frame.width) / 2)
|
||||
|
@ -432,7 +453,9 @@ class GalleryItemViewController: UIViewController {
|
|||
scrollView.zoomScale > scrollView.minimumZoomScale {
|
||||
animateZoomOut()
|
||||
} else {
|
||||
setControlsVisible(!controlsVisible, animated: true, dueToUserInteraction: true)
|
||||
if content.shouldHideControls() {
|
||||
setControlsVisible(!controlsVisible, animated: true, dueToUserInteraction: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -551,7 +574,9 @@ extension GalleryItemViewController: UIScrollViewDelegate {
|
|||
if scrollView.zoomScale <= scrollView.minimumZoomScale {
|
||||
setControlsVisible(true, animated: true, dueToUserInteraction: true)
|
||||
} else {
|
||||
setControlsVisible(false, animated: true, dueToUserInteraction: true)
|
||||
if content.hideControlsOnZoom {
|
||||
setControlsVisible(false, animated: true, dueToUserInteraction: true)
|
||||
}
|
||||
}
|
||||
|
||||
centerContent()
|
||||
|
|
|
@ -26,6 +26,14 @@ public class GalleryViewController: UIPageViewController {
|
|||
private var dismissInteraction: GalleryDismissInteraction!
|
||||
private var presentationAnimationCompletionHandlers: [() -> Void] = []
|
||||
|
||||
public var showShareButton: Bool = true {
|
||||
didSet {
|
||||
if viewControllers?.isEmpty == false {
|
||||
currentItemViewController.showShareButton = showShareButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override public var prefersStatusBarHidden: Bool {
|
||||
true
|
||||
}
|
||||
|
@ -89,7 +97,9 @@ public class GalleryViewController: UIPageViewController {
|
|||
|
||||
private func makeItemVC(index: Int) -> GalleryItemViewController {
|
||||
let content = galleryDataSource.galleryContentViewController(forItemAt: index)
|
||||
return GalleryItemViewController(delegate: self, itemIndex: index, content: content)
|
||||
let itemVC = GalleryItemViewController(delegate: self, itemIndex: index, content: content)
|
||||
itemVC.showShareButton = showShareButton
|
||||
return itemVC
|
||||
}
|
||||
|
||||
func presentationAnimationCompleted() {
|
||||
|
|
|
@ -14,7 +14,8 @@ extension UIView {
|
|||
while let superview = view.superview {
|
||||
if superview.layer.masksToBounds {
|
||||
return superview
|
||||
} else if superview is UIScrollView {
|
||||
} else if let scrollView = superview as? UIScrollView,
|
||||
scrollView.isScrollEnabled {
|
||||
return self
|
||||
} else {
|
||||
view = superview
|
||||
|
|
|
@ -7,7 +7,6 @@ let package = Package(
|
|||
name: "Pachyderm",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.macOS(.v13),
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
|
@ -17,6 +16,7 @@ let package = Package(
|
|||
],
|
||||
dependencies: [
|
||||
// Dependencies declare other packages that this package depends on.
|
||||
.package(url: "https://github.com/karwa/swift-url.git", exact: "0.4.2"),
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||
|
@ -24,6 +24,8 @@ let package = Package(
|
|||
.target(
|
||||
name: "Pachyderm",
|
||||
dependencies: [
|
||||
.product(name: "WebURL", package: "swift-url"),
|
||||
.product(name: "WebURLFoundationExtras", package: "swift-url"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
/**
|
||||
The base Mastodon API client.
|
||||
|
@ -24,30 +25,27 @@ public struct Client: Sendable {
|
|||
|
||||
public var timeoutInterval: TimeInterval = 60
|
||||
|
||||
private static let dateFormatter: DateFormatter = {
|
||||
static let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter
|
||||
}()
|
||||
private static let iso8601Formatter = ISO8601DateFormatter()
|
||||
private static func decodeDate(string: String) -> Date? {
|
||||
// for the next time mastodon accidentally changes date formats >.>
|
||||
return dateFormatter.date(from: string) ?? iso8601Formatter.date(from: string)
|
||||
}
|
||||
|
||||
static let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
let iso8601 = ISO8601DateFormatter()
|
||||
decoder.dateDecodingStrategy = .custom({ (decoder) in
|
||||
let container = try decoder.singleValueContainer()
|
||||
let str = try container.decode(String.self)
|
||||
if let date = Self.decodeDate(string: str) {
|
||||
// for the next time mastodon accidentally changes date formats >.>
|
||||
if let date = formatter.date(from: str) {
|
||||
return date
|
||||
} else if let date = iso8601.date(from: str) {
|
||||
return date
|
||||
} else {
|
||||
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
|
||||
}
|
||||
})
|
||||
|
||||
return decoder
|
||||
}()
|
||||
|
||||
|
@ -107,15 +105,6 @@ public struct Client: Sendable {
|
|||
return task
|
||||
}
|
||||
|
||||
private func error(from response: HTTPURLResponse) -> ErrorType {
|
||||
if response.statusCode == 429,
|
||||
let date = response.value(forHTTPHeaderField: "X-RateLimit-Reset").flatMap(Self.decodeDate) {
|
||||
return .rateLimited(date)
|
||||
} else {
|
||||
return .unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
|
@ -201,8 +190,8 @@ public struct Client: Sendable {
|
|||
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
|
||||
let wellKnownResults = try await run(wellKnown).0
|
||||
if let url = wellKnownResults.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
|
||||
let href = try? URL.ParseStrategy().parse(url.href),
|
||||
href.host == self.baseURL.host() {
|
||||
let href = WebURL(url.href),
|
||||
href.host == WebURL(self.baseURL)?.host {
|
||||
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
|
||||
return try await run(nodeInfo).0
|
||||
} else {
|
||||
|
@ -586,8 +575,6 @@ extension Client {
|
|||
return "Invalid Model"
|
||||
case .mastodonError(let code, let error):
|
||||
return "Server Error (\(code)): \(error)"
|
||||
case .rateLimited(let reset):
|
||||
return "Rate Limited Until \(reset.formatted(date: .omitted, time: .standard))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -598,7 +585,6 @@ extension Client {
|
|||
case invalidResponse
|
||||
case invalidModel(Swift.Error)
|
||||
case mastodonError(Int, String)
|
||||
case rateLimited(Date)
|
||||
}
|
||||
|
||||
enum NodeInfoError: LocalizedError {
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct Announcement: Decodable, Sendable, Hashable, Identifiable {
|
||||
public let id: String
|
||||
|
@ -59,7 +60,7 @@ extension Announcement {
|
|||
public struct Account: Decodable, Sendable, Hashable {
|
||||
public let id: String
|
||||
public let username: String
|
||||
@URLDecoder public var url: URL
|
||||
public let url: WebURL
|
||||
public let acct: String
|
||||
}
|
||||
}
|
||||
|
@ -67,7 +68,7 @@ extension Announcement {
|
|||
extension Announcement {
|
||||
public struct Status: Decodable, Sendable, Hashable {
|
||||
public let id: String
|
||||
@URLDecoder public var url: URL
|
||||
public let url: WebURL
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,17 +7,18 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct Card: Codable, Sendable {
|
||||
@URLDecoder public var url: URL
|
||||
public let url: WebURL
|
||||
public let title: String
|
||||
public let description: String
|
||||
@OptionalURLDecoder public var image: URL?
|
||||
public let image: WebURL?
|
||||
public let kind: Kind
|
||||
public let authorName: String?
|
||||
@OptionalURLDecoder public var authorURL: URL?
|
||||
public let authorURL: WebURL?
|
||||
public let providerName: String?
|
||||
@OptionalURLDecoder public var providerURL: URL?
|
||||
public let providerURL: WebURL?
|
||||
public let html: String?
|
||||
public let width: Int?
|
||||
public let height: Int?
|
||||
|
@ -26,15 +27,15 @@ public struct Card: Codable, Sendable {
|
|||
public let history: [History]?
|
||||
|
||||
public init(
|
||||
url: URL,
|
||||
url: WebURL,
|
||||
title: String,
|
||||
description: String,
|
||||
image: URL? = nil,
|
||||
image: WebURL? = nil,
|
||||
kind: Card.Kind,
|
||||
authorName: String? = nil,
|
||||
authorURL: URL? = nil,
|
||||
authorURL: WebURL? = nil,
|
||||
providerName: String? = nil,
|
||||
providerURL: URL? = nil,
|
||||
providerURL: WebURL? = nil,
|
||||
html: String? = nil,
|
||||
width: Int? = nil,
|
||||
height: Int? = nil,
|
||||
|
@ -60,15 +61,15 @@ public struct Card: Codable, Sendable {
|
|||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self._url = try container.decode(URLDecoder.self, forKey: .url)
|
||||
self.url = try container.decode(WebURL.self, forKey: .url)
|
||||
self.title = try container.decode(String.self, forKey: .title)
|
||||
self.description = try container.decode(String.self, forKey: .description)
|
||||
self.kind = try container.decode(Kind.self, forKey: .kind)
|
||||
self._image = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .image) ?? nil
|
||||
self.image = try? container.decodeIfPresent(WebURL.self, forKey: .image)
|
||||
self.authorName = try? container.decodeIfPresent(String.self, forKey: .authorName)
|
||||
self._authorURL = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .authorURL) ?? nil
|
||||
self.authorURL = try? container.decodeIfPresent(WebURL.self, forKey: .authorURL)
|
||||
self.providerName = try? container.decodeIfPresent(String.self, forKey: .providerName)
|
||||
self._providerURL = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .providerURL) ?? nil
|
||||
self.providerURL = try? container.decodeIfPresent(WebURL.self, forKey: .providerURL)
|
||||
self.html = try? container.decodeIfPresent(String.self, forKey: .html)
|
||||
self.width = try? container.decodeIfPresent(Int.self, forKey: .width)
|
||||
self.height = try? container.decodeIfPresent(Int.self, forKey: .height)
|
||||
|
|
|
@ -7,11 +7,14 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct Emoji: Codable, Sendable {
|
||||
public let shortcode: String
|
||||
@URLDecoder public var url: URL
|
||||
@URLDecoder public var staticURL: URL
|
||||
// these shouldn't need to be WebURLs as they're not external resources,
|
||||
// but some instances (pleroma?) has emoji urls that Foundation considers malformed so we use WebURL to be more lenient
|
||||
public let url: WebURL
|
||||
public let staticURL: WebURL
|
||||
public let visibleInPicker: Bool
|
||||
public let category: String?
|
||||
|
||||
|
@ -19,8 +22,13 @@ public struct Emoji: Codable, Sendable {
|
|||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.shortcode = try container.decode(String.self, forKey: .shortcode)
|
||||
self._url = try container.decode(URLDecoder.self, forKey: .url)
|
||||
self._staticURL = try container.decode(URLDecoder.self, forKey: .staticURL)
|
||||
do {
|
||||
self.url = try container.decode(WebURL.self, forKey: .url)
|
||||
} catch {
|
||||
let s = try? container.decode(String.self, forKey: .url)
|
||||
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath + [CodingKeys.url], debugDescription: "Could not decode URL '\(s ?? "<failed to decode string>")'", underlyingError: error))
|
||||
}
|
||||
self.staticURL = try container.decode(WebURL.self, forKey: .staticURL)
|
||||
self.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker)
|
||||
self.category = try container.decodeIfPresent(String.self, forKey: .category)
|
||||
}
|
||||
|
|
|
@ -7,10 +7,12 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
import WebURLFoundationExtras
|
||||
|
||||
public struct Hashtag: Codable, Sendable {
|
||||
public let name: String
|
||||
@URLDecoder public var url: URL
|
||||
public let url: WebURL
|
||||
/// Only present when returned from the trending hashtags endpoint
|
||||
public let history: [History]?
|
||||
/// Only present on Mastodon >= 4 and when logged in
|
||||
|
@ -18,7 +20,7 @@ public struct Hashtag: Codable, Sendable {
|
|||
|
||||
public init(name: String, url: URL) {
|
||||
self.name = name
|
||||
self.url = url
|
||||
self.url = WebURL(url)!
|
||||
self.history = nil
|
||||
self.following = nil
|
||||
}
|
||||
|
@ -27,7 +29,7 @@ public struct Hashtag: Codable, Sendable {
|
|||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.name = try container.decode(String.self, forKey: .name)
|
||||
// pixelfed (possibly others) don't fully escape special characters in the hashtag url
|
||||
self._url = try container.decode(URLDecoder.self, forKey: .url)
|
||||
self.url = try container.decode(WebURL.self, forKey: .url)
|
||||
self.history = try container.decodeIfPresent([History].self, forKey: .history)
|
||||
self.following = try container.decodeIfPresent(Bool.self, forKey: .following)
|
||||
}
|
||||
|
|
|
@ -7,9 +7,10 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct Mention: Codable, Sendable {
|
||||
@URLDecoder public var url: URL
|
||||
public let url: WebURL
|
||||
public let username: String
|
||||
public let acct: String
|
||||
/// The instance-local ID of the user being mentioned.
|
||||
|
@ -20,10 +21,15 @@ public struct Mention: Codable, Sendable {
|
|||
self.username = try container.decode(String.self, forKey: .username)
|
||||
self.acct = try container.decode(String.self, forKey: .acct)
|
||||
self.id = try container.decode(String.self, forKey: .id)
|
||||
self._url = try container.decode(URLDecoder.self, forKey: .url)
|
||||
do {
|
||||
self.url = try container.decode(WebURL.self, forKey: .url)
|
||||
} catch {
|
||||
let s = try? container.decode(String.self, forKey: .url)
|
||||
throw DecodingError.dataCorruptedError(forKey: .url, in: container, debugDescription: "Could not decode URL '\(s ?? "<failed to decode string>")'")
|
||||
}
|
||||
}
|
||||
|
||||
public init(url: URL, username: String, acct: String, id: String) {
|
||||
public init(url: WebURL, username: String, acct: String, id: String) {
|
||||
self.url = url
|
||||
self.username = username
|
||||
self.acct = acct
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct Notification: Decodable, Sendable {
|
||||
public let id: String
|
||||
|
@ -17,7 +18,7 @@ public struct Notification: Decodable, Sendable {
|
|||
// Only present for pleroma emoji reactions
|
||||
// Either an emoji or :shortcode: (for akkoma custom emoji reactions)
|
||||
public let emoji: String?
|
||||
@OptionalURLDecoder public var emojiURL: URL?
|
||||
public let emojiURL: WebURL?
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
@ -32,7 +33,7 @@ public struct Notification: Decodable, Sendable {
|
|||
self.account = try container.decode(Account.self, forKey: .account)
|
||||
self.status = try container.decodeIfPresent(Status.self, forKey: .status)
|
||||
self.emoji = try container.decodeIfPresent(String.self, forKey: .emoji)
|
||||
self._emojiURL = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .emojiURL) ?? nil
|
||||
self.emojiURL = try container.decodeIfPresent(WebURL.self, forKey: .emojiURL)
|
||||
}
|
||||
|
||||
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
||||
|
|
|
@ -6,13 +6,14 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct PushNotification: Decodable {
|
||||
public let accessToken: String
|
||||
public let preferredLocale: String
|
||||
public let notificationID: String
|
||||
public let notificationType: Notification.Kind
|
||||
@URLDecoder public var icon: URL
|
||||
public let icon: WebURL
|
||||
public let title: String
|
||||
public let body: String
|
||||
|
||||
|
@ -28,7 +29,7 @@ public struct PushNotification: Decodable {
|
|||
self.notificationID = i.description
|
||||
}
|
||||
self.notificationType = try container.decode(Notification.Kind.self, forKey: .notificationType)
|
||||
self._icon = try container.decode(URLDecoder.self, forKey: .icon)
|
||||
self.icon = try container.decode(WebURL.self, forKey: .icon)
|
||||
self.title = try container.decode(String.self, forKey: .title)
|
||||
self.body = try container.decode(String.self, forKey: .body)
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public final class Status: StatusProtocol, Decodable, Sendable {
|
||||
/// The pseudo-visibility used by instance types (Akkoma) that overload the visibility for local-only posts.
|
||||
|
@ -14,8 +15,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
|||
|
||||
public let id: String
|
||||
public let uri: String
|
||||
private let _url: OptionalURLDecoder
|
||||
public var url: URL? { _url.wrappedValue }
|
||||
public let url: WebURL?
|
||||
public let account: Account
|
||||
public let inReplyToID: String?
|
||||
public let inReplyToAccountID: String?
|
||||
|
@ -55,13 +55,13 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
|||
self.id = try container.decode(String.self, forKey: .id)
|
||||
self.uri = try container.decode(String.self, forKey: .uri)
|
||||
do {
|
||||
self._url = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .url) ?? nil
|
||||
self.url = try container.decodeIfPresent(WebURL.self, forKey: .url)
|
||||
} catch {
|
||||
let s = try? container.decode(String.self, forKey: .url)
|
||||
if s == "" {
|
||||
self._url = OptionalURLDecoder(wrappedValue: nil)
|
||||
self.url = nil
|
||||
} else {
|
||||
throw error
|
||||
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)
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
||||
public private(set) var notifications: [Notification]
|
||||
|
@ -149,7 +150,7 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
|||
case poll
|
||||
case update
|
||||
case status
|
||||
case emojiReaction(String, URL?)
|
||||
case emojiReaction(String, WebURL?)
|
||||
case unknown
|
||||
|
||||
var notificationKind: Notification.Kind {
|
||||
|
|
|
@ -1,86 +0,0 @@
|
|||
//
|
||||
// URLDecoder.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 12/15/24.
|
||||
//
|
||||
import Foundation
|
||||
|
||||
private let parseStrategy = URL.ParseStrategy()
|
||||
.scheme(.required)
|
||||
.user(.optional)
|
||||
.password(.optional)
|
||||
.host(.required)
|
||||
.port(.optional)
|
||||
.path(.optional)
|
||||
.query(.optional)
|
||||
.fragment(.optional)
|
||||
|
||||
private let formatStyle = URL.FormatStyle()
|
||||
.scheme(.always)
|
||||
.user(.omitWhen(.user, matches: [""]))
|
||||
.password(.omitWhen(.password, matches: [""]))
|
||||
.host(.always)
|
||||
.port(.omitIfHTTPFamily)
|
||||
.path(.always)
|
||||
.query(.omitWhen(.query, matches: [""]))
|
||||
.fragment(.omitWhen(.fragment, matches: [""]))
|
||||
|
||||
@propertyWrapper
|
||||
public struct URLDecoder: Codable, Sendable, Hashable {
|
||||
public var wrappedValue: URL
|
||||
|
||||
public init(wrappedValue: URL) {
|
||||
self.wrappedValue = wrappedValue
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let s = try decoder.singleValueContainer().decode(String.self)
|
||||
self.wrappedValue = try parseStrategy.parse(s)
|
||||
}
|
||||
|
||||
public func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(wrappedValue.formatted(formatStyle))
|
||||
}
|
||||
}
|
||||
|
||||
@propertyWrapper
|
||||
public struct OptionalURLDecoder: Codable, Sendable, Hashable, ExpressibleByNilLiteral {
|
||||
public var wrappedValue: URL?
|
||||
|
||||
public init(wrappedValue: URL?) {
|
||||
self.wrappedValue = wrappedValue
|
||||
}
|
||||
|
||||
public init(nilLiteral: ()) {
|
||||
self.wrappedValue = nil
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
self.wrappedValue = nil
|
||||
} else {
|
||||
let s = try container.decode(String.self)
|
||||
if s.isEmpty {
|
||||
self.wrappedValue = nil
|
||||
} else {
|
||||
do {
|
||||
self.wrappedValue = try parseStrategy.parse(s)
|
||||
} catch {
|
||||
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "Could not decode URL '\(s)'", underlyingError: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
if let wrappedValue {
|
||||
try container.encode(wrappedValue.formatted(formatStyle))
|
||||
} else {
|
||||
try container.encodeNil()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -197,72 +197,72 @@ class NotificationGroupTests: XCTestCase {
|
|||
|
||||
func testGroupSimple() {
|
||||
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeA2], only: [.favourite])
|
||||
XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!])
|
||||
XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2])!])
|
||||
}
|
||||
|
||||
func testGroupWithOtherGroupableInBetween() {
|
||||
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeB, likeA2], only: [.favourite])
|
||||
XCTAssertEqual(groups, [
|
||||
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!,
|
||||
NotificationGroup(notifications: [likeB], kind: .favourite)!,
|
||||
NotificationGroup(notifications: [likeA1, likeA2])!,
|
||||
NotificationGroup(notifications: [likeB])!,
|
||||
])
|
||||
}
|
||||
|
||||
func testDontGroupWithUngroupableInBetween() {
|
||||
let groups = NotificationGroup.createGroups(notifications: [likeA1, mentionB, likeA2], only: [.favourite])
|
||||
XCTAssertEqual(groups, [
|
||||
NotificationGroup(notifications: [likeA1], kind: .favourite)!,
|
||||
NotificationGroup(notifications: [mentionB], kind: .mention)!,
|
||||
NotificationGroup(notifications: [likeA2], kind: .favourite)!,
|
||||
NotificationGroup(notifications: [likeA1])!,
|
||||
NotificationGroup(notifications: [mentionB])!,
|
||||
NotificationGroup(notifications: [likeA2])!,
|
||||
])
|
||||
}
|
||||
|
||||
func testMergeSimpleGroups() {
|
||||
let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)!
|
||||
let group2 = NotificationGroup(notifications: [likeA2], kind: .favourite)!
|
||||
let group1 = NotificationGroup(notifications: [likeA1])!
|
||||
let group2 = NotificationGroup(notifications: [likeA2])!
|
||||
let merged = NotificationGroup.mergeGroups(first: [group1], second: [group2], only: [.favourite])
|
||||
XCTAssertEqual(merged, [
|
||||
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!
|
||||
NotificationGroup(notifications: [likeA1, likeA2])!
|
||||
])
|
||||
}
|
||||
|
||||
func testMergeGroupsWithOtherGroupableInBetween() {
|
||||
let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)!
|
||||
let group2 = NotificationGroup(notifications: [likeB], kind: .favourite)!
|
||||
let group3 = NotificationGroup(notifications: [likeA2], kind: .favourite)!
|
||||
let group1 = NotificationGroup(notifications: [likeA1])!
|
||||
let group2 = NotificationGroup(notifications: [likeB])!
|
||||
let group3 = NotificationGroup(notifications: [likeA2])!
|
||||
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
|
||||
XCTAssertEqual(merged, [
|
||||
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!,
|
||||
NotificationGroup(notifications: [likeB], kind: .favourite)!,
|
||||
NotificationGroup(notifications: [likeA1, likeA2])!,
|
||||
NotificationGroup(notifications: [likeB])!,
|
||||
])
|
||||
|
||||
let merged2 = NotificationGroup.mergeGroups(first: [group1], second: [group2, group3], only: [.favourite])
|
||||
XCTAssertEqual(merged2, [
|
||||
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!,
|
||||
NotificationGroup(notifications: [likeB], kind: .favourite)!,
|
||||
NotificationGroup(notifications: [likeA1, likeA2])!,
|
||||
NotificationGroup(notifications: [likeB])!,
|
||||
])
|
||||
|
||||
let group4 = NotificationGroup(notifications: [likeB2], kind: .favourite)!
|
||||
let group5 = NotificationGroup(notifications: [mentionB], kind: .mention)!
|
||||
let group4 = NotificationGroup(notifications: [likeB2])!
|
||||
let group5 = NotificationGroup(notifications: [mentionB])!
|
||||
let merged3 = NotificationGroup.mergeGroups(first: [group1, group5, group2], second: [group4, group3], only: [.favourite])
|
||||
print(merged3.count)
|
||||
XCTAssertEqual(merged3, [
|
||||
group1,
|
||||
group5,
|
||||
NotificationGroup(notifications: [likeB, likeB2], kind: .favourite),
|
||||
NotificationGroup(notifications: [likeB, likeB2]),
|
||||
group3
|
||||
])
|
||||
}
|
||||
|
||||
func testDontMergeWithUngroupableInBetween() {
|
||||
let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)!
|
||||
let group2 = NotificationGroup(notifications: [mentionB], kind: .mention)!
|
||||
let group3 = NotificationGroup(notifications: [likeA2], kind: .favourite)!
|
||||
let group1 = NotificationGroup(notifications: [likeA1])!
|
||||
let group2 = NotificationGroup(notifications: [mentionB])!
|
||||
let group3 = NotificationGroup(notifications: [likeA2])!
|
||||
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
|
||||
XCTAssertEqual(merged, [
|
||||
NotificationGroup(notifications: [likeA1], kind: .favourite)!,
|
||||
NotificationGroup(notifications: [mentionB], kind: .mention)!,
|
||||
NotificationGroup(notifications: [likeA2], kind: .favourite)!,
|
||||
NotificationGroup(notifications: [likeA1])!,
|
||||
NotificationGroup(notifications: [mentionB])!,
|
||||
NotificationGroup(notifications: [likeA2])!,
|
||||
])
|
||||
}
|
||||
|
||||
|
|
|
@ -6,21 +6,20 @@
|
|||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Pachyderm
|
||||
import WebURL
|
||||
import WebURLFoundationExtras
|
||||
|
||||
class URLTests: XCTestCase {
|
||||
|
||||
func testDecodeURL() {
|
||||
XCTAssertNotNil(try? URL.ParseStrategy().parse("https://xn--baw-joa.social/test/é"))
|
||||
XCTAssertNotNil(try? URL.ParseStrategy().parse("http://見.香港/热狗/🌭"))
|
||||
}
|
||||
|
||||
func testRoundtripURL() throws {
|
||||
let orig = URLDecoder(wrappedValue: URL(string: "https://example.com")!)
|
||||
let encoded = try JSONEncoder().encode(orig)
|
||||
print(String(data: encoded, encoding: .utf8)!)
|
||||
let decoded = try JSONDecoder().decode(URLDecoder.self, from: encoded)
|
||||
XCTAssertEqual(orig.wrappedValue, decoded.wrappedValue)
|
||||
XCTAssertNotNil(WebURL(URL(string: "https://xn--baw-joa.social/@unituebingen")!))
|
||||
XCTAssertNotNil(WebURL("https://xn--baw-joa.social/@unituebingen"))
|
||||
XCTAssertNotNil(URLComponents(string: "https://xn--baw-joa.social/test/é"))
|
||||
XCTAssertNotNil(WebURL("https://xn--baw-joa.social/test/é"))
|
||||
if #available(iOS 16.0, *) {
|
||||
XCTAssertNotNil(try? URL.ParseStrategy().parse("https://xn--baw-joa.social/test/é"))
|
||||
XCTAssertNotNil(try? URL.ParseStrategy().parse("http://見.香港/热狗/🌭"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -9,5 +9,6 @@ import Foundation
|
|||
|
||||
public enum FeatureFlag: String, Codable {
|
||||
case iPadBrowserNavigation = "ipad-browser-navigation"
|
||||
case composeRewrite = "compose-rewrite"
|
||||
case pushNotifCustomEmoji = "push-notif-custom-emoji"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
//
|
||||
// PreferenceObserving.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 8/10/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
@propertyWrapper
|
||||
public struct PreferenceObserving<Key: PreferenceKey>: DynamicProperty {
|
||||
public typealias PrefKeyPath = KeyPath<PreferenceStore, PreferencePublisher<Key>>
|
||||
|
||||
private let keyPath: PrefKeyPath
|
||||
@StateObject private var observer: Observer
|
||||
|
||||
public init(_ keyPath: PrefKeyPath) {
|
||||
self.keyPath = keyPath
|
||||
self._observer = StateObject(wrappedValue: Observer(keyPath: keyPath))
|
||||
}
|
||||
|
||||
public var wrappedValue: Key.Value {
|
||||
Preferences.shared.getValue(preferenceKeyPath: keyPath)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private class Observer: ObservableObject {
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
init(keyPath: PrefKeyPath) {
|
||||
cancellable = Preferences.shared[keyPath: keyPath].sink { [unowned self] _ in
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,6 +9,7 @@
|
|||
import SwiftUI
|
||||
import ComposeUI
|
||||
import TuskerComponents
|
||||
import WebURLFoundationExtras
|
||||
import Combine
|
||||
import TuskerPreferences
|
||||
|
||||
|
@ -45,7 +46,7 @@ class ShareHostingController: UIHostingController<ShareHostingController.View> {
|
|||
currentAccountContainerView: { AnyView(SwitchAccountContainerView(content: $0, mastodonContextPublisher: mastodonContextPublisher)) },
|
||||
replyContentView: { _, _ in fatalError("replies aren't allowed in share sheet") },
|
||||
emojiImageView: {
|
||||
AnyView(AsyncImage(url: $0.url) {
|
||||
AnyView(AsyncImage(url: URL($0.url)!) {
|
||||
$0
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
|
|
|
@ -83,4 +83,8 @@ final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Send
|
|||
|
||||
func storeCreatedStatus(_ status: Status) {
|
||||
}
|
||||
|
||||
func fetchStatus(id: String) -> (any StatusProtocol)? {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
|
@ -103,6 +103,7 @@
|
|||
D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E02BC61C6700208903 /* UserAccounts */; };
|
||||
D630C3E52BC6313400208903 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E42BC6313400208903 /* Pachyderm */; };
|
||||
D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */ = {isa = PBXBuildFile; productRef = D630C4222BC7842C00208903 /* HTMLStreamer */; };
|
||||
D630C4252BC7845800208903 /* WebURL in Frameworks */ = {isa = PBXBuildFile; productRef = D630C4242BC7845800208903 /* WebURL */; };
|
||||
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; };
|
||||
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; };
|
||||
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; };
|
||||
|
@ -163,6 +164,7 @@
|
|||
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; };
|
||||
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626B21361C6700C9CBA2 /* Account+Preferences.swift */; };
|
||||
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */; };
|
||||
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */ = {isa = PBXBuildFile; productRef = D6676CA427A8D0020052936B /* WebURLFoundationExtras */; };
|
||||
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; };
|
||||
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; };
|
||||
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; };
|
||||
|
@ -816,6 +818,7 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D630C4252BC7845800208903 /* WebURL in Frameworks */,
|
||||
D62220472C7EA8DF003E43B7 /* TuskerPreferences in Frameworks */,
|
||||
D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */,
|
||||
D630C3E52BC6313400208903 /* Pachyderm in Frameworks */,
|
||||
|
@ -852,6 +855,7 @@
|
|||
D6934F2C2BA7AD32002B1C8D /* GalleryVC in Frameworks */,
|
||||
D6552367289870790048A653 /* ScreenCorners in Frameworks */,
|
||||
D60BB3942B30076F00DAEA65 /* HTMLStreamer in Frameworks */,
|
||||
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
|
||||
D63CC702290EC0B8000E19DE /* Sentry in Frameworks */,
|
||||
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */,
|
||||
);
|
||||
|
@ -1777,6 +1781,7 @@
|
|||
D630C3E02BC61C6700208903 /* UserAccounts */,
|
||||
D630C3E42BC6313400208903 /* Pachyderm */,
|
||||
D630C4222BC7842C00208903 /* HTMLStreamer */,
|
||||
D630C4242BC7845800208903 /* WebURL */,
|
||||
D62220462C7EA8DF003E43B7 /* TuskerPreferences */,
|
||||
);
|
||||
productName = NotificationExtension;
|
||||
|
@ -1828,6 +1833,7 @@
|
|||
);
|
||||
name = Tusker;
|
||||
packageProductDependencies = (
|
||||
D6676CA427A8D0020052936B /* WebURLFoundationExtras */,
|
||||
D674A50827F9128D00BA03AC /* Pachyderm */,
|
||||
D6552366289870790048A653 /* ScreenCorners */,
|
||||
D63CC701290EC0B8000E19DE /* Sentry */,
|
||||
|
@ -1954,6 +1960,7 @@
|
|||
);
|
||||
mainGroup = D6D4DDC3212518A000E1C4BB;
|
||||
packageReferences = (
|
||||
D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */,
|
||||
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */,
|
||||
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */,
|
||||
D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */,
|
||||
|
@ -3267,6 +3274,14 @@
|
|||
minimumVersion = 1.0.1;
|
||||
};
|
||||
};
|
||||
D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/karwa/swift-url";
|
||||
requirement = {
|
||||
kind = exactVersion;
|
||||
version = 0.4.2;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
|
@ -3304,6 +3319,11 @@
|
|||
package = D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */;
|
||||
productName = HTMLStreamer;
|
||||
};
|
||||
D630C4242BC7845800208903 /* WebURL */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */;
|
||||
productName = WebURL;
|
||||
};
|
||||
D635237029B78A7D009ED5E7 /* TuskerComponents */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = TuskerComponents;
|
||||
|
@ -3322,6 +3342,11 @@
|
|||
isa = XCSwiftPackageProductDependency;
|
||||
productName = TTTKit;
|
||||
};
|
||||
D6676CA427A8D0020052936B /* WebURLFoundationExtras */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */;
|
||||
productName = WebURLFoundationExtras;
|
||||
};
|
||||
D674A50827F9128D00BA03AC /* Pachyderm */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Pachyderm;
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import Foundation
|
||||
import CoreData
|
||||
import Pachyderm
|
||||
import WebURLFoundationExtras
|
||||
|
||||
@objc(FollowedHashtag)
|
||||
public final class FollowedHashtag: NSManagedObject {
|
||||
|
@ -32,6 +33,6 @@ extension FollowedHashtag {
|
|||
convenience init(hashtag: Hashtag, context: NSManagedObjectContext) {
|
||||
self.init(context: context)
|
||||
self.name = hashtag.name
|
||||
self.url = hashtag.url
|
||||
self.url = URL(hashtag.url)!
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import Foundation
|
||||
import CoreData
|
||||
import Pachyderm
|
||||
import WebURLFoundationExtras
|
||||
import UserAccounts
|
||||
|
||||
@objc(SavedHashtag)
|
||||
|
@ -41,6 +42,6 @@ extension SavedHashtag {
|
|||
self.init(context: context)
|
||||
self.accountID = account.id
|
||||
self.name = hashtag.name
|
||||
self.url = hashtag.url
|
||||
self.url = URL(hashtag.url)!
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
import Foundation
|
||||
import CoreData
|
||||
import Pachyderm
|
||||
import WebURLFoundationExtras
|
||||
|
||||
@objc(StatusMO)
|
||||
public final class StatusMO: NSManagedObject, StatusProtocol {
|
||||
|
@ -135,7 +136,7 @@ extension StatusMO {
|
|||
self.sensitive = status.sensitive
|
||||
self.spoilerText = status.spoilerText
|
||||
self.uri = status.uri
|
||||
self.url = status.url
|
||||
self.url = status.url != nil ? URL(status.url!) : nil
|
||||
self.visibility = status.visibility
|
||||
self.poll = status.poll
|
||||
self.localOnly = status.localOnly ?? false
|
||||
|
|
|
@ -59,31 +59,3 @@ private struct AppGroupedListRowBackground: ViewModifier {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@propertyWrapper
|
||||
private struct PreferenceObserving<Key: TuskerPreferences.PreferenceKey>: DynamicProperty {
|
||||
typealias PrefKeyPath = KeyPath<PreferenceStore, PreferencePublisher<Key>>
|
||||
|
||||
let keyPath: PrefKeyPath
|
||||
@StateObject private var observer: Observer
|
||||
|
||||
init(_ keyPath: PrefKeyPath) {
|
||||
self.keyPath = keyPath
|
||||
self._observer = StateObject(wrappedValue: Observer(keyPath: keyPath))
|
||||
}
|
||||
|
||||
var wrappedValue: Key.Value {
|
||||
Preferences.shared.getValue(preferenceKeyPath: keyPath)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private class Observer: ObservableObject {
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
init(keyPath: PrefKeyPath) {
|
||||
cancellable = Preferences.shared[keyPath: keyPath].sink { [unowned self] _ in
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,8 @@
|
|||
|
||||
import UIKit
|
||||
import HTMLStreamer
|
||||
import Pachyderm
|
||||
import WebURL
|
||||
import WebURLFoundationExtras
|
||||
|
||||
class HTMLConverter {
|
||||
|
||||
|
@ -44,7 +45,17 @@ extension HTMLConverter {
|
|||
// note: this is duplicated in NotificationExtension
|
||||
struct Callbacks: HTMLConversionCallbacks {
|
||||
static func makeURL(string: String) -> URL? {
|
||||
try? URL.ParseStrategy().parse(string)
|
||||
// Converting WebURL to URL is a small but non-trivial expense (since it works by
|
||||
// serializing the WebURL as a string and then having Foundation parse it again),
|
||||
// so, if available, use the system parser which doesn't require another round trip.
|
||||
if let url = try? URL.ParseStrategy().parse(string) {
|
||||
url
|
||||
} else if let web = WebURL(string),
|
||||
let url = URL(web) {
|
||||
url
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import WebURL
|
||||
|
||||
class AnnouncementContentTextView: ContentTextView {
|
||||
|
||||
|
@ -29,7 +30,7 @@ class AnnouncementContentTextView: ContentTextView {
|
|||
|
||||
override func getMention(for url: URL, text: String) -> Mention? {
|
||||
announcement?.mentions.first {
|
||||
$0.url == url
|
||||
URL($0.url) == url
|
||||
}.map {
|
||||
Mention(url: $0.url, username: $0.username, acct: $0.acct, id: $0.id)
|
||||
}
|
||||
|
@ -37,7 +38,7 @@ class AnnouncementContentTextView: ContentTextView {
|
|||
|
||||
override func getHashtag(for url: URL, text: String) -> Hashtag? {
|
||||
announcement?.tags.first {
|
||||
$0.url == url
|
||||
URL($0.url) == url
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import SwiftUI
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
import WebURLFoundationExtras
|
||||
|
||||
struct AnnouncementListRow: View {
|
||||
@Binding var announcement: Announcement
|
||||
|
@ -115,8 +116,8 @@ struct AnnouncementListRow: View {
|
|||
let url: URL?
|
||||
let staticURL: URL?
|
||||
if case .custom(let emoji) = reaction {
|
||||
url = emoji.url
|
||||
staticURL = emoji.staticURL
|
||||
url = URL(emoji.url)
|
||||
staticURL = URL(emoji.staticURL)
|
||||
} else {
|
||||
url = nil
|
||||
staticURL = nil
|
||||
|
|
|
@ -50,6 +50,10 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
|||
emojiImageView: { AnyView(CustomEmojiImageView(emoji: $0)) }
|
||||
)
|
||||
|
||||
if let account = mastodonController.account {
|
||||
controller.currentAccount = account
|
||||
}
|
||||
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
super.init(rootView: View(mastodonController: mastodonController, controller: controller))
|
||||
|
@ -129,7 +133,6 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
|||
let picker = PHPickerViewController(configuration: config)
|
||||
picker.delegate = self
|
||||
picker.modalPresentationStyle = .pageSheet
|
||||
picker.overrideUserInterfaceStyle = .dark
|
||||
// sheet detents don't play nice with PHPickerViewController, see
|
||||
// let sheet = picker.sheetPresentationController!
|
||||
// sheet.detents = [.medium(), .large()]
|
||||
|
@ -151,7 +154,8 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
|||
var body: some SwiftUI.View {
|
||||
ControllerView(controller: { controller })
|
||||
.task {
|
||||
if let account = try? await mastodonController.getOwnAccount() {
|
||||
if controller.currentAccount == nil,
|
||||
let account = try? await mastodonController.getOwnAccount() {
|
||||
controller.currentAccount = account
|
||||
}
|
||||
}
|
||||
|
@ -185,6 +189,7 @@ extension ComposeHostingController: DuckableViewController {
|
|||
}
|
||||
#endif
|
||||
|
||||
// TODO: don't conform MastodonController to this protocol, use a separate type
|
||||
extension MastodonController: ComposeMastodonContext {
|
||||
@MainActor
|
||||
func searchCachedAccounts(query: String) -> [AccountProtocol] {
|
||||
|
@ -227,6 +232,10 @@ extension MastodonController: ComposeMastodonContext {
|
|||
func storeCreatedStatus(_ status: Status) {
|
||||
persistentContainer.addOrUpdate(status: status)
|
||||
}
|
||||
|
||||
func fetchStatus(id: String) -> (any StatusProtocol)? {
|
||||
return persistentContainer.status(for: id)
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeHostingController: PHPickerViewControllerDelegate {
|
||||
|
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import WebURL
|
||||
import WebURLFoundationExtras
|
||||
|
||||
private let mastodonRemoteStatusRegex = try! NSRegularExpression(pattern: "^/@.+@.+/\\d{18}")
|
||||
private func isLikelyMastodonRemoteStatus(url: URL) -> Bool {
|
||||
|
@ -227,10 +229,10 @@ class ConversationViewController: UIViewController {
|
|||
let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") {
|
||||
effectiveURL = location
|
||||
} else {
|
||||
effectiveURL = url.formatted(.url.fragment(.never))
|
||||
effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
|
||||
}
|
||||
} else {
|
||||
effectiveURL = url.formatted(.url.fragment(.never))
|
||||
effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
|
||||
}
|
||||
|
||||
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)
|
||||
|
@ -482,11 +484,3 @@ extension ConversationViewController: StatusBarTappableViewController {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConversationViewController: RefreshableViewController {
|
||||
func refresh() {
|
||||
Task {
|
||||
await refreshContext()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import UIKit
|
|||
import Combine
|
||||
import Pachyderm
|
||||
import CoreData
|
||||
import WebURLFoundationExtras
|
||||
|
||||
class ExploreViewController: UIViewController, UICollectionViewDelegate, CollectionViewController {
|
||||
|
||||
|
@ -559,7 +560,10 @@ extension ExploreViewController: UICollectionViewDragDelegate {
|
|||
activity.displaysAuxiliaryScene = true
|
||||
provider = NSItemProvider(object: activity)
|
||||
case let .savedHashtag(hashtag):
|
||||
provider = NSItemProvider(object: hashtag.url as NSURL)
|
||||
guard let url = URL(hashtag.url) else {
|
||||
return []
|
||||
}
|
||||
provider = NSItemProvider(object: url as NSURL)
|
||||
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: accountID) {
|
||||
activity.displaysAuxiliaryScene = true
|
||||
provider.registerObject(activity, visibility: .all)
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import WebURLFoundationExtras
|
||||
import Combine
|
||||
|
||||
class TrendingHashtagsViewController: UIViewController, CollectionViewController {
|
||||
|
@ -276,10 +277,11 @@ extension TrendingHashtagsViewController: UICollectionViewDelegate {
|
|||
extension TrendingHashtagsViewController: UICollectionViewDragDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath),
|
||||
case let .tag(hashtag) = item else {
|
||||
case let .tag(hashtag) = item,
|
||||
let url = URL(hashtag.url) else {
|
||||
return []
|
||||
}
|
||||
let provider = NSItemProvider(object: hashtag.url as NSURL)
|
||||
let provider = NSItemProvider(object: url as NSURL)
|
||||
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) {
|
||||
activity.displaysAuxiliaryScene = true
|
||||
provider.registerObject(activity, visibility: .all)
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
#if !os(visionOS)
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import WebURLFoundationExtras
|
||||
import HTMLStreamer
|
||||
|
||||
class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
|
||||
|
@ -70,7 +71,7 @@ class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
|
|||
self.card = card
|
||||
self.thumbnailView.image = nil
|
||||
|
||||
thumbnailView.update(for: card.image, blurhash: card.blurhash)
|
||||
thumbnailView.update(for: card.image.flatMap { URL($0) }, blurhash: card.blurhash)
|
||||
|
||||
let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
titleLabel.text = title
|
||||
|
|
|
@ -9,17 +9,22 @@
|
|||
#if os(visionOS)
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import WebURLFoundationExtras
|
||||
import HTMLStreamer
|
||||
|
||||
struct TrendingLinkCardView: View {
|
||||
let card: Card
|
||||
|
||||
private var imageURL: URL? {
|
||||
card.image
|
||||
if let image = card.image {
|
||||
URL(image)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private var descriptionText: String {
|
||||
let converter = TextConverter(configuration: .init(insertNewlines: false))
|
||||
var converter = TextConverter(configuration: .init(insertNewlines: false))
|
||||
return converter.convert(html: card.description)
|
||||
}
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import WebURLFoundationExtras
|
||||
import SafariServices
|
||||
import Combine
|
||||
#if os(visionOS)
|
||||
|
@ -292,19 +293,21 @@ extension TrendingLinksViewController: UICollectionViewDelegate {
|
|||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard case .link(let card) = dataSource.itemIdentifier(for: indexPath) else {
|
||||
guard case .link(let card) = dataSource.itemIdentifier(for: indexPath),
|
||||
let url = URL(card.url) else {
|
||||
return
|
||||
}
|
||||
selected(url: card.url)
|
||||
selected(url: url)
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
guard case .link(let card) = dataSource.itemIdentifier(for: indexPath),
|
||||
let url = URL(card.url),
|
||||
let cell = collectionView.cellForItem(at: indexPath) else {
|
||||
return nil
|
||||
}
|
||||
return UIContextMenuConfiguration {
|
||||
let vc = SFSafariViewController(url: card.url)
|
||||
let vc = SFSafariViewController(url: url)
|
||||
#if !os(visionOS)
|
||||
vc.preferredControlTintColor = Preferences.shared.accentColor.color
|
||||
#endif
|
||||
|
@ -321,10 +324,11 @@ extension TrendingLinksViewController: UICollectionViewDelegate {
|
|||
|
||||
extension TrendingLinksViewController: UICollectionViewDragDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
|
||||
guard case .link(let card) = dataSource.itemIdentifier(for: indexPath) else {
|
||||
guard case .link(let card) = dataSource.itemIdentifier(for: indexPath),
|
||||
let url = URL(card.url) else {
|
||||
return []
|
||||
}
|
||||
return [UIDragItem(itemProvider: NSItemProvider(object: card.url as NSURL))]
|
||||
return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -513,7 +513,9 @@ extension TrendsViewController: UICollectionViewDelegate {
|
|||
show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
|
||||
|
||||
case let .link(card):
|
||||
selected(url: card.url)
|
||||
if let url = URL(card.url) {
|
||||
selected(url: url)
|
||||
}
|
||||
|
||||
case let .status(id, state):
|
||||
selected(status: id, state: state.copy())
|
||||
|
@ -542,9 +544,12 @@ extension TrendsViewController: UICollectionViewDelegate {
|
|||
}
|
||||
|
||||
case let .link(card):
|
||||
guard let url = URL(card.url) else {
|
||||
return nil
|
||||
}
|
||||
let cell = collectionView.cellForItem(at: indexPath)!
|
||||
return UIContextMenuConfiguration {
|
||||
let vc = SFSafariViewController(url: card.url)
|
||||
let vc = SFSafariViewController(url: url)
|
||||
#if !os(visionOS)
|
||||
vc.preferredControlTintColor = Preferences.shared.accentColor.color
|
||||
#endif
|
||||
|
@ -619,7 +624,10 @@ extension TrendsViewController: UICollectionViewDragDelegate {
|
|||
return []
|
||||
|
||||
case let .tag(hashtag):
|
||||
let provider = NSItemProvider(object: hashtag.url as NSURL)
|
||||
guard let url = URL(hashtag.url) else {
|
||||
return []
|
||||
}
|
||||
let provider = NSItemProvider(object: url as NSURL)
|
||||
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) {
|
||||
activity.displaysAuxiliaryScene = true
|
||||
provider.registerObject(activity, visibility: .all)
|
||||
|
@ -627,7 +635,10 @@ extension TrendsViewController: UICollectionViewDragDelegate {
|
|||
return [UIDragItem(itemProvider: provider)]
|
||||
|
||||
case let .link(card):
|
||||
return [UIDragItem(itemProvider: NSItemProvider(object: card.url as NSURL))]
|
||||
guard let url = URL(card.url) else {
|
||||
return []
|
||||
}
|
||||
return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))]
|
||||
|
||||
case let .status(id, _):
|
||||
guard let status = mastodonController.persistentContainer.status(for: id),
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import UIKit
|
||||
import UserAccounts
|
||||
import WebURL
|
||||
|
||||
class FastSwitchingAccountView: UIView {
|
||||
|
||||
|
@ -130,7 +131,11 @@ class FastSwitchingAccountView: UIView {
|
|||
|
||||
private func setupAccount(account: UserAccountInfo) {
|
||||
usernameLabel.text = account.username
|
||||
instanceLabel.text = account.instanceURL.host(percentEncoded: false)
|
||||
if let domain = WebURL.Domain(account.instanceURL.host!) {
|
||||
instanceLabel.text = domain.render(.uncheckedUnicodeString)
|
||||
} else {
|
||||
instanceLabel.text = account.instanceURL.host!
|
||||
}
|
||||
let controller = MastodonController.getForAccount(account)
|
||||
avatarTask = Task {
|
||||
guard let account = try? await controller.getOwnAccount(),
|
||||
|
|
|
@ -151,22 +151,6 @@ class BaseMainTabBarViewController: UITabBarController, FastAccountSwitcherViewC
|
|||
return false
|
||||
#endif // !os(visionOS)
|
||||
}
|
||||
|
||||
// MARK: Keyboard shortcuts
|
||||
|
||||
override func target(forAction action: Selector, withSender sender: Any?) -> Any? {
|
||||
// This is a silly workaround for when the sidebar is focused (and therefore first responder), which,
|
||||
// unfortunately, is almost always. Because the content view controller then isn't in the responder chain,
|
||||
// we manually delegate to the top view controller if possible.
|
||||
if action == #selector(RefreshableViewController.refresh),
|
||||
let selected = selectedViewController as? NavigationControllerProtocol,
|
||||
let top = selected.topViewController as? RefreshableViewController {
|
||||
return top
|
||||
} else {
|
||||
return super.target(forAction: action, withSender: sender)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension BaseMainTabBarViewController: TuskerNavigationDelegate {
|
||||
|
|
|
@ -219,19 +219,6 @@ class MainSplitViewController: UISplitViewController {
|
|||
@objc func handleComposeKeyCommand() {
|
||||
compose(editing: nil)
|
||||
}
|
||||
|
||||
override func target(forAction action: Selector, withSender sender: Any?) -> Any? {
|
||||
// This is a silly workaround for when the sidebar is focused (and therefore first responder), which,
|
||||
// unfortunately, is almost always. Because the content view controller then isn't in the responder chain,
|
||||
// we manually delegate to the top view controller if possible.
|
||||
if action == #selector(RefreshableViewController.refresh),
|
||||
traitCollection.horizontalSizeClass == .regular,
|
||||
let top = secondaryNavController.topViewController as? RefreshableViewController {
|
||||
return top
|
||||
} else {
|
||||
return super.target(forAction: action, withSender: sender)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -155,7 +155,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
|
|||
fetchCustomEmojiImage?.1.cancel()
|
||||
case .emojiReaction(let emojiOrShortcode, let url):
|
||||
iconImageView.image = nil
|
||||
if let url,
|
||||
if let url = url.flatMap({ URL($0) }),
|
||||
fetchCustomEmojiImage?.0 != url {
|
||||
fetchCustomEmojiImage?.1.cancel()
|
||||
let task = Task {
|
||||
|
|
|
@ -740,7 +740,7 @@ extension NotificationsCollectionViewController: UICollectionViewDragDelegate {
|
|||
return cell.dragItemsForBeginning(session: session)
|
||||
case .poll, .update:
|
||||
let status = group.notifications.first!.status!
|
||||
let provider = NSItemProvider(object: status.url! as NSURL)
|
||||
let provider = NSItemProvider(object: URL(status.url!)! as NSURL)
|
||||
let activity = UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: mastodonController.accountInfo!.id)
|
||||
activity.displaysAuxiliaryScene = true
|
||||
provider.registerObject(activity, visibility: .all)
|
||||
|
|
|
@ -180,9 +180,3 @@ extension NotificationsPageViewController: StateRestorableViewController {
|
|||
return currentPage.userActivity(accountID: mastodonController.accountInfo!.id)
|
||||
}
|
||||
}
|
||||
|
||||
extension NotificationsPageViewController: RefreshableViewController {
|
||||
func refresh() {
|
||||
(currentViewController as? RefreshableViewController)?.refresh()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,7 +18,6 @@ struct AdvancedPrefsView : View {
|
|||
@State private var mastodonCacheSize: Int64 = 0
|
||||
@State private var cloudKitStatus: CKAccountStatus?
|
||||
@State private var isShowingFeatureFlagAlert = false
|
||||
@State private var featureFlagName = ""
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
|
@ -32,23 +31,9 @@ struct AdvancedPrefsView : View {
|
|||
.listStyle(.insetGrouped)
|
||||
.appGroupedListBackground(container: PreferencesNavigationController.self)
|
||||
.onTapGesture(count: 3) {
|
||||
featureFlagName = ""
|
||||
isShowingFeatureFlagAlert = true
|
||||
}
|
||||
.alert("Enable Feature Flag", isPresented: $isShowingFeatureFlagAlert) {
|
||||
TextField("Flag Name", text: $featureFlagName)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Enable") {
|
||||
if let flag = FeatureFlag(rawValue: featureFlagName) {
|
||||
preferences.enabledFeatureFlags.insert(flag)
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("Warning: Feature flags are intended for development and debugging use only. They are experimental and subject to change at any time.")
|
||||
}
|
||||
.modifier(FeatureFlagAlertModifier(showing: $isShowingFeatureFlagAlert))
|
||||
.navigationBarTitle(Text("Advanced"))
|
||||
}
|
||||
|
||||
|
@ -252,6 +237,74 @@ extension StatusContentType {
|
|||
}
|
||||
}
|
||||
|
||||
private struct FeatureFlagAlertModifier: ViewModifier {
|
||||
@Binding var showing: Bool
|
||||
@ObservedObject private var preferences = Preferences.shared
|
||||
@State private var featureFlagName = ""
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
content
|
||||
.onChange(of: showing) {
|
||||
if $0 {
|
||||
featureFlagName = ""
|
||||
}
|
||||
}
|
||||
.alert("Enable Feature Flag", isPresented: $showing) {
|
||||
textField
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Enable", action: enableFlag)
|
||||
} message: {
|
||||
warning
|
||||
}
|
||||
} else {
|
||||
content
|
||||
.sheet(isPresented: $showing) {
|
||||
NavigationView {
|
||||
List {
|
||||
Section {
|
||||
textField
|
||||
} footer: {
|
||||
warning
|
||||
}
|
||||
}
|
||||
.navigationTitle("Enable Feature Flag")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.listStyle(.insetGrouped)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
showing = false
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Enable", action: self.enableFlag)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var textField: some View {
|
||||
TextField("Flag Name", text: $featureFlagName)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
private var warning: Text {
|
||||
Text("Warning: Feature flags are intended for development and debugging use only. They are experimental and subject to change at any time.")
|
||||
}
|
||||
|
||||
private func enableFlag() {
|
||||
if let flag = FeatureFlag(rawValue: featureFlagName) {
|
||||
preferences.enabledFeatureFlags.insert(flag)
|
||||
showing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct AdvancedPrefsView_Previews : PreviewProvider {
|
||||
static var previews: some View {
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import WebURL
|
||||
|
||||
struct MockStatusView: View {
|
||||
@ObservedObject private var preferences = Preferences.shared
|
||||
|
@ -135,8 +136,8 @@ private struct MockStatusCardView: UIViewRepresentable {
|
|||
let view = StatusCardView()
|
||||
view.isUserInteractionEnabled = false
|
||||
let card = StatusCardView.CardData(
|
||||
url: URL(string: "https://vaccor.space/tusker")!,
|
||||
image: URL(string: "https://vaccor.space/tusker/img/icon.png")!,
|
||||
url: WebURL("https://vaccor.space/tusker")!,
|
||||
image: WebURL("https://vaccor.space/tusker/img/icon.png")!,
|
||||
title: "Tusker",
|
||||
description: "Tusker is an iOS app for Mastodon"
|
||||
)
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import SwiftUI
|
||||
import UserAccounts
|
||||
import WebURL
|
||||
|
||||
struct PrefsAccountView: View {
|
||||
let account: UserAccountInfo
|
||||
|
@ -18,7 +19,12 @@ struct PrefsAccountView: View {
|
|||
VStack(alignment: .prefsAvatar) {
|
||||
Text(verbatim: account.username)
|
||||
.foregroundColor(.primary)
|
||||
Text(verbatim: account.instanceURL.host(percentEncoded: false)!)
|
||||
let instance = if let domain = WebURL.Domain(account.instanceURL.host!) {
|
||||
domain.render(.uncheckedUnicodeString)
|
||||
} else {
|
||||
account.instanceURL.host!
|
||||
}
|
||||
Text(verbatim: instance)
|
||||
.font(.caption)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
|
|
@ -393,9 +393,3 @@ extension ProfileViewController: StatusBarTappableViewController {
|
|||
return currentViewController.handleStatusBarTapped(xPosition: xPosition)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController: RefreshableViewController {
|
||||
func refresh() {
|
||||
currentViewController.refresh()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import UIKit
|
||||
import Combine
|
||||
import Pachyderm
|
||||
import WebURLFoundationExtras
|
||||
|
||||
fileprivate let accountCell = "accountCell"
|
||||
fileprivate let statusCell = "statusCell"
|
||||
|
@ -537,7 +538,7 @@ extension SearchResultsViewController: UICollectionViewDragDelegate {
|
|||
url = account.url
|
||||
activity = UserActivityManager.showProfileActivity(id: id, accountID: accountInfo.id)
|
||||
case .hashtag(let tag):
|
||||
url = tag.url
|
||||
url = URL(tag.url)!
|
||||
activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: tag.name), accountID: accountInfo.id)!
|
||||
case .status(let id, _):
|
||||
guard let status = mastodonController.persistentContainer.status(for: id),
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import WebURL
|
||||
|
||||
class StatusActionAccountListViewController: UIViewController {
|
||||
|
||||
|
@ -182,7 +183,7 @@ extension StatusActionAccountListViewController {
|
|||
enum ActionType {
|
||||
case favorite
|
||||
case reblog
|
||||
case emojiReaction(String, URL?)
|
||||
case emojiReaction(String, WebURL?)
|
||||
|
||||
init?(_ groupKind: NotificationGroup.Kind) {
|
||||
switch groupKind {
|
||||
|
|
|
@ -212,9 +212,3 @@ extension TimelinesPageViewController: StateRestorableViewController {
|
|||
return (currentViewController as? TimelineViewController)?.stateRestorationActivity()
|
||||
}
|
||||
}
|
||||
|
||||
extension TimelinesPageViewController: RefreshableViewController {
|
||||
func refresh() {
|
||||
(currentViewController as? RefreshableViewController)?.refresh()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import UIKit
|
||||
import SafariServices
|
||||
import Pachyderm
|
||||
import WebURLFoundationExtras
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
|
@ -153,7 +154,12 @@ extension MenuActionProvider {
|
|||
}
|
||||
}
|
||||
|
||||
let shareSection = actionsForURL(hashtag.url, source: source)
|
||||
let shareSection: [UIMenuElement]
|
||||
if let url = URL(hashtag.url) {
|
||||
shareSection = actionsForURL(url, source: source)
|
||||
} else {
|
||||
shareSection = []
|
||||
}
|
||||
|
||||
return [
|
||||
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
|
||||
|
@ -369,11 +375,14 @@ extension MenuActionProvider {
|
|||
}
|
||||
|
||||
func actionsForTrendingLink(card: Card, source: PopoverSource) -> [UIMenuElement] {
|
||||
guard let url = URL(card.url) else {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
openInSafariAction(url: card.url),
|
||||
openInSafariAction(url: url),
|
||||
createAction(identifier: "share", title: "Share…", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
|
||||
guard let self else { return }
|
||||
self.navigationDelegate?.showMoreOptions(forURL: card.url, source: source)
|
||||
self.navigationDelegate?.showMoreOptions(forURL: url, source: source)
|
||||
}),
|
||||
createAction(identifier: "postlink", title: "Post this Link", systemImageName: "square.and.pencil", handler: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
|
@ -384,7 +393,7 @@ extension MenuActionProvider {
|
|||
text += title
|
||||
text += ":\n"
|
||||
}
|
||||
text += card.url.absoluteString
|
||||
text += url.absoluteString
|
||||
|
||||
let draft = self.mastodonController!.createDraft(text: text)
|
||||
self.navigationDelegate?.compose(editing: draft)
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import WebURLFoundationExtras
|
||||
import os
|
||||
|
||||
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||
|
@ -53,7 +54,7 @@ struct AccountDisplayNameView: View {
|
|||
}
|
||||
|
||||
group.enter()
|
||||
let request = ImageCache.emojis.get(emoji.url) { (_, image) in
|
||||
let request = ImageCache.emojis.get(URL(emoji.url)!) { (_, image) in
|
||||
defer { group.leave() }
|
||||
guard let image = image else { return }
|
||||
|
||||
|
|
|
@ -402,7 +402,7 @@ class AttachmentView: GIFImageView {
|
|||
makeBadgeView(text: "ALT")
|
||||
}
|
||||
if badges.contains(.noAlt) {
|
||||
makeBadgeView(text: "NO ALT")
|
||||
makeBadgeView(text: "No ALT")
|
||||
}
|
||||
|
||||
let first = stack.arrangedSubviews.first!
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import WebURLFoundationExtras
|
||||
import os
|
||||
|
||||
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||
|
@ -72,7 +73,7 @@ extension BaseEmojiLabel {
|
|||
|
||||
foundEmojis = true
|
||||
|
||||
if let image = ImageCache.emojis.get(emoji.url)?.image {
|
||||
if let image = ImageCache.emojis.get(URL(emoji.url)!)?.image {
|
||||
// if the image is cached, add it immediately.
|
||||
// we generate the thumbnail on the main thread, because it's usually fast enough
|
||||
// and the delay caused by doing it asynchronously looks works.
|
||||
|
@ -89,7 +90,7 @@ extension BaseEmojiLabel {
|
|||
// otherwise, perform the network request
|
||||
|
||||
group.enter()
|
||||
let request = ImageCache.emojis.getFromSource(emoji.url) { (_, image) in
|
||||
let request = ImageCache.emojis.getFromSource(URL(emoji.url)!) { (_, image) in
|
||||
guard let image else {
|
||||
group.leave()
|
||||
return
|
||||
|
@ -97,7 +98,7 @@ extension BaseEmojiLabel {
|
|||
image.prepareThumbnail(of: emojiImageSize(image)) { thumbnail in
|
||||
guard let thumbnail = thumbnail?.cgImage,
|
||||
case let rescaled = UIImage(cgImage: thumbnail, scale: screenScale, orientation: .up),
|
||||
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: emoji.url, image: rescaled) else {
|
||||
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: URL(emoji.url)!, image: rescaled) else {
|
||||
group.leave()
|
||||
return
|
||||
}
|
||||
|
|
|
@ -9,6 +9,8 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
import SafariServices
|
||||
import WebURL
|
||||
import WebURLFoundationExtras
|
||||
import Combine
|
||||
|
||||
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import WebURLFoundationExtras
|
||||
|
||||
struct CustomEmojiImageView: View {
|
||||
let emoji: Emoji
|
||||
|
@ -34,7 +35,7 @@ struct CustomEmojiImageView: View {
|
|||
|
||||
@MainActor
|
||||
private func loadImage() {
|
||||
request = ImageCache.emojis.get(emoji.url) { (_, image) in
|
||||
request = ImageCache.emojis.get(URL(emoji.url)!) { (_, image) in
|
||||
DispatchQueue.main.async {
|
||||
self.request = nil
|
||||
if let image = image {
|
||||
|
|
|
@ -12,7 +12,11 @@ import SwiftUI
|
|||
import SafariServices
|
||||
|
||||
class ProfileFieldValueView: UIView {
|
||||
weak var navigationDelegate: TuskerNavigationDelegate?
|
||||
weak var navigationDelegate: TuskerNavigationDelegate? {
|
||||
didSet {
|
||||
textView.navigationDelegate = navigationDelegate
|
||||
}
|
||||
}
|
||||
|
||||
private static let converter = HTMLConverter(
|
||||
font: .preferredFont(forTextStyle: .body),
|
||||
|
@ -24,9 +28,8 @@ class ProfileFieldValueView: UIView {
|
|||
|
||||
private let account: AccountMO
|
||||
private let field: Account.Field
|
||||
private var link: (String, URL)?
|
||||
|
||||
private let label = EmojiLabel()
|
||||
private let textView = ContentTextView()
|
||||
private var iconView: UIView?
|
||||
|
||||
private var currentTargetedPreview: UITargetedPreview?
|
||||
|
@ -39,34 +42,28 @@ class ProfileFieldValueView: UIView {
|
|||
|
||||
let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value))
|
||||
|
||||
converted.enumerateAttribute(.link, in: converted.fullRange) { value, range, stop in
|
||||
guard value != nil else { return }
|
||||
if self.link == nil {
|
||||
self.link = (converted.attributedSubstring(from: range).string, value as! URL)
|
||||
}
|
||||
#if os(visionOS)
|
||||
converted.addAttribute(.foregroundColor, value: UIColor.link, range: range)
|
||||
#else
|
||||
converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range)
|
||||
#endif
|
||||
// the .link attribute in a UILabel always makes the color blue >.>
|
||||
converted.removeAttribute(.link, range: range)
|
||||
}
|
||||
|
||||
if link != nil {
|
||||
label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(linkTapped)))
|
||||
label.addInteraction(UIContextMenuInteraction(delegate: self))
|
||||
label.isUserInteractionEnabled = true
|
||||
}
|
||||
|
||||
label.numberOfLines = 0
|
||||
label.font = .preferredFont(forTextStyle: .body)
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
label.attributedText = converted
|
||||
label.setEmojis(account.emojis, identifier: account.id)
|
||||
label.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(label)
|
||||
#if os(visionOS)
|
||||
textView.linkTextAttributes = [
|
||||
.foregroundColor: UIColor.link
|
||||
]
|
||||
#else
|
||||
textView.linkTextAttributes = [
|
||||
.foregroundColor: UIColor.tintColor
|
||||
]
|
||||
#endif
|
||||
textView.backgroundColor = nil
|
||||
textView.isScrollEnabled = false
|
||||
textView.isSelectable = false
|
||||
textView.isEditable = false
|
||||
textView.font = .preferredFont(forTextStyle: .body)
|
||||
updateTextContainerInset()
|
||||
textView.adjustsFontForContentSizeCategory = true
|
||||
textView.attributedText = converted
|
||||
textView.setEmojis(account.emojis, identifier: account.id)
|
||||
textView.isUserInteractionEnabled = true
|
||||
textView.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
textView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(textView)
|
||||
|
||||
let labelTrailingConstraint: NSLayoutConstraint
|
||||
|
||||
|
@ -83,20 +80,20 @@ class ProfileFieldValueView: UIView {
|
|||
icon.isPointerInteractionEnabled = true
|
||||
icon.accessibilityLabel = "Verified link"
|
||||
addSubview(icon)
|
||||
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
|
||||
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
|
||||
NSLayoutConstraint.activate([
|
||||
icon.centerYAnchor.constraint(equalTo: label.centerYAnchor),
|
||||
icon.centerYAnchor.constraint(equalTo: textView.centerYAnchor),
|
||||
icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
|
||||
])
|
||||
} else {
|
||||
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: trailingAnchor)
|
||||
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor)
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
label.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
textView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
labelTrailingConstraint,
|
||||
label.topAnchor.constraint(equalTo: topAnchor),
|
||||
label.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
textView.topAnchor.constraint(equalTo: topAnchor),
|
||||
textView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
|
@ -105,37 +102,36 @@ class ProfileFieldValueView: UIView {
|
|||
}
|
||||
|
||||
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
||||
var size = label.sizeThatFits(size)
|
||||
var size = textView.sizeThatFits(size)
|
||||
if let iconView {
|
||||
size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory {
|
||||
updateTextContainerInset()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateTextContainerInset() {
|
||||
// blergh
|
||||
switch traitCollection.preferredContentSizeCategory {
|
||||
case .extraSmall:
|
||||
textView.textContainerInset = UIEdgeInsets(top: 4, left: 0, bottom: 0, right: 0)
|
||||
case .small:
|
||||
textView.textContainerInset = UIEdgeInsets(top: 3, left: 0, bottom: 0, right: 0)
|
||||
case .medium, .large:
|
||||
textView.textContainerInset = UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0)
|
||||
default:
|
||||
textView.textContainerInset = .zero
|
||||
}
|
||||
}
|
||||
|
||||
func setTextAlignment(_ alignment: NSTextAlignment) {
|
||||
label.textAlignment = alignment
|
||||
}
|
||||
|
||||
func getHashtagOrURL() -> (Hashtag?, URL)? {
|
||||
guard let (text, url) = link else {
|
||||
return nil
|
||||
}
|
||||
if text.starts(with: "#") {
|
||||
return (Hashtag(name: String(text.dropFirst()), url: url), url)
|
||||
} else {
|
||||
return (nil, url)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func linkTapped() {
|
||||
guard let (hashtag, url) = getHashtagOrURL() else {
|
||||
return
|
||||
}
|
||||
if let hashtag {
|
||||
navigationDelegate?.selected(tag: hashtag)
|
||||
} else {
|
||||
navigationDelegate?.selected(url: url)
|
||||
}
|
||||
textView.textAlignment = alignment
|
||||
}
|
||||
|
||||
@objc private func verifiedIconTapped() {
|
||||
|
@ -145,7 +141,7 @@ class ProfileFieldValueView: UIView {
|
|||
let view = ProfileFieldVerificationView(
|
||||
acct: account.acct,
|
||||
verifiedAt: field.verifiedAt!,
|
||||
linkText: label.text ?? "",
|
||||
linkText: textView.text ?? "",
|
||||
navigationDelegate: navigationDelegate
|
||||
)
|
||||
let host = UIHostingController(rootView: view)
|
||||
|
@ -169,49 +165,3 @@ class ProfileFieldValueView: UIView {
|
|||
navigationDelegate.present(toPresent, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileFieldValueView: UIContextMenuInteractionDelegate, MenuActionProvider {
|
||||
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
|
||||
guard let (hashtag, url) = getHashtagOrURL(),
|
||||
let navigationDelegate else {
|
||||
return nil
|
||||
}
|
||||
if let hashtag {
|
||||
return UIContextMenuConfiguration {
|
||||
HashtagTimelineViewController(for: hashtag, mastodonController: navigationDelegate.apiController)
|
||||
} actionProvider: { _ in
|
||||
UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self)))
|
||||
}
|
||||
} else {
|
||||
return UIContextMenuConfiguration {
|
||||
let vc = SFSafariViewController(url: url)
|
||||
#if !os(visionOS)
|
||||
vc.preferredControlTintColor = Preferences.shared.accentColor.color
|
||||
#endif
|
||||
return vc
|
||||
} actionProvider: { _ in
|
||||
UIMenu(children: self.actionsForURL(url, source: .view(self)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, highlightPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? {
|
||||
var rect = label.textRect(forBounds: label.bounds, limitedToNumberOfLines: 0)
|
||||
// the rect should be vertically centered, but textRect doesn't seem to take the label's vertical alignment into account
|
||||
rect.origin.x = 0
|
||||
rect.origin.y = (bounds.height - rect.height) / 2
|
||||
let parameters = UIPreviewParameters(textLineRects: [rect as NSValue])
|
||||
let preview = UITargetedPreview(view: label, parameters: parameters)
|
||||
currentTargetedPreview = preview
|
||||
return preview
|
||||
}
|
||||
|
||||
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, dismissalPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? {
|
||||
return currentTargetedPreview
|
||||
}
|
||||
|
||||
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: navigationDelegate!)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,8 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
import SafariServices
|
||||
import WebURL
|
||||
import WebURLFoundationExtras
|
||||
import HTMLStreamer
|
||||
|
||||
class StatusCardView: UIView {
|
||||
|
@ -182,14 +184,14 @@ class StatusCardView: UIView {
|
|||
if sensitive {
|
||||
if let blurhash = card.blurhash {
|
||||
imageView.blurImage = false
|
||||
imageView.showOnlyBlurHash(blurhash, for: image)
|
||||
imageView.showOnlyBlurHash(blurhash, for: URL(image)!)
|
||||
} else {
|
||||
// if we don't have a blurhash, load the image and show it behind a blur
|
||||
imageView.blurImage = true
|
||||
imageView.update(for: image, blurhash: nil)
|
||||
imageView.update(for: URL(image), blurhash: nil)
|
||||
}
|
||||
} else {
|
||||
imageView.update(for: image, blurhash: card.blurhash)
|
||||
imageView.update(for: URL(image), blurhash: card.blurhash)
|
||||
}
|
||||
imageView.isHidden = false
|
||||
leadingSpacer.isHidden = true
|
||||
|
@ -208,8 +210,8 @@ class StatusCardView: UIView {
|
|||
descriptionLabel.text = description
|
||||
descriptionLabel.isHidden = description.isEmpty
|
||||
|
||||
if let host = card.url.host(percentEncoded: false) {
|
||||
domainLabel.text = host
|
||||
if let host = card.url.host {
|
||||
domainLabel.text = host.serialized
|
||||
domainLabel.isHidden = false
|
||||
} else {
|
||||
domainLabel.isHidden = true
|
||||
|
@ -236,7 +238,7 @@ class StatusCardView: UIView {
|
|||
setNeedsDisplay()
|
||||
|
||||
if let card = card, let delegate = navigationDelegate {
|
||||
delegate.selected(url: card.url)
|
||||
delegate.selected(url: URL(card.url)!)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -246,8 +248,8 @@ class StatusCardView: UIView {
|
|||
}
|
||||
|
||||
struct CardData: Equatable {
|
||||
let url: URL
|
||||
let image: URL?
|
||||
let url: WebURL
|
||||
let image: WebURL?
|
||||
let title: String
|
||||
let description: String
|
||||
let blurhash: String?
|
||||
|
@ -260,7 +262,7 @@ class StatusCardView: UIView {
|
|||
self.blurhash = card.blurhash
|
||||
}
|
||||
|
||||
init(url: URL, image: URL? = nil, title: String, description: String, blurhash: String? = nil) {
|
||||
init(url: WebURL, image: WebURL? = nil, title: String, description: String, blurhash: String? = nil) {
|
||||
self.url = url
|
||||
self.image = image
|
||||
self.title = title
|
||||
|
@ -276,13 +278,13 @@ extension StatusCardView: UIContextMenuInteractionDelegate {
|
|||
guard let card = card else { return nil }
|
||||
|
||||
return UIContextMenuConfiguration(identifier: nil) {
|
||||
let vc = SFSafariViewController(url: card.url)
|
||||
let vc = SFSafariViewController(url: URL(card.url)!)
|
||||
#if !os(visionOS)
|
||||
vc.preferredControlTintColor = Preferences.shared.accentColor.color
|
||||
#endif
|
||||
return vc
|
||||
} actionProvider: { (_) in
|
||||
let actions = self.actionProvider?.actionsForURL(card.url, source: .view(self)) ?? []
|
||||
let actions = self.actionProvider?.actionsForURL(URL(card.url)!, source: .view(self)) ?? []
|
||||
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import WebURLFoundationExtras
|
||||
|
||||
class StatusContentTextView: ContentTextView {
|
||||
|
||||
|
@ -25,7 +26,7 @@ class StatusContentTextView: ContentTextView {
|
|||
let mastodonController = mastodonController,
|
||||
let status = mastodonController.persistentContainer.status(for: statusID) {
|
||||
mention = status.mentions.first { (mention) in
|
||||
url.host() == mention.url.host() && (
|
||||
url.host == mention.url.host!.serialized && (
|
||||
text.dropFirst() == mention.username // Mastodon and Pleroma include @ in the text
|
||||
|| text.dropFirst() == mention.acct // Misskey includes @ and uses the whole acct
|
||||
|| text == mention.username // GNU Social does not include the @ in the text, so we don't need to drop it
|
||||
|
@ -43,7 +44,7 @@ class StatusContentTextView: ContentTextView {
|
|||
let mastodonController = mastodonController,
|
||||
let status = mastodonController.persistentContainer.status(for: statusID) {
|
||||
hashtag = status.hashtags.first { (hashtag) in
|
||||
hashtag.url == url
|
||||
URL(hashtag.url) == url
|
||||
}
|
||||
} else {
|
||||
hashtag = nil
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
// https://help.apple.com/xcode/#/dev745c5c974
|
||||
|
||||
MARKETING_VERSION = 2024.5
|
||||
CURRENT_PROJECT_VERSION = 139
|
||||
CURRENT_PROJECT_VERSION = 137
|
||||
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
|
||||
|
||||
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev
|
||||
|
|
Loading…
Reference in New Issue