Revert "Replace WebURL with URL.ParseStrategy"

This reverts commit adaf8dc217.
This commit is contained in:
Shadowfacts 2024-12-26 17:31:18 -05:00
parent 666d2c468a
commit a35b72d256
43 changed files with 238 additions and 191 deletions

View File

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

View File

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

View File

@ -7,6 +7,7 @@
//
import Foundation
import WebURL
/**
The base Mastodon API client.
@ -201,8 +202,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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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://見.香港/热狗/🌭"))
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@
#if os(visionOS)
import SwiftUI
import Pachyderm
import WebURLFoundationExtras
import HTMLStreamer
struct TrendingLinkCardView: View {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,8 @@
import UIKit
import Pachyderm
import SafariServices
import WebURL
import WebURLFoundationExtras
import Combine
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])

View File

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

View File

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

View File

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