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 Pachyderm
import Intents import Intents
import HTMLStreamer import HTMLStreamer
import WebURL
import UIKit import UIKit
import TuskerPreferences 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() { 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)) let emojiName = (content.body as NSString).substring(with: match.range(at: 1))
guard let emoji = notification.status?.emojis.first(where: { $0.shortcode == emojiName }), 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 { let image = UIImage(data: data) else {
continue continue
} }
@ -366,7 +368,17 @@ private func decodeBase64URL(_ s: String) -> Data? {
// copied from HTMLConverter.Callbacks, blergh // copied from HTMLConverter.Callbacks, blergh
private struct HTMLCallbacks: HTMLConversionCallbacks { private struct HTMLCallbacks: HTMLConversionCallbacks {
static func makeURL(string: String) -> URL? { 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 { static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {

View File

@ -7,7 +7,6 @@ let package = Package(
name: "Pachyderm", name: "Pachyderm",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v16),
.macOS(.v13),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // 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: [
// Dependencies declare other packages that this package depends on. // Dependencies declare other packages that this package depends on.
.package(url: "https://github.com/karwa/swift-url.git", exact: "0.4.2"),
], ],
targets: [ targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite. // 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( .target(
name: "Pachyderm", name: "Pachyderm",
dependencies: [ dependencies: [
.product(name: "WebURL", package: "swift-url"),
.product(name: "WebURLFoundationExtras", package: "swift-url"),
], ],
swiftSettings: [ swiftSettings: [
.swiftLanguageMode(.v5) .swiftLanguageMode(.v5)

View File

@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import WebURL
/** /**
The base Mastodon API client. The base Mastodon API client.
@ -201,8 +202,8 @@ public struct Client: Sendable {
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo") let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
let wellKnownResults = try await run(wellKnown).0 let wellKnownResults = try await run(wellKnown).0
if let url = wellKnownResults.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.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), let href = WebURL(url.href),
href.host == self.baseURL.host() { href.host == WebURL(self.baseURL)?.host {
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path)) let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
return try await run(nodeInfo).0 return try await run(nodeInfo).0
} else { } else {

View File

@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import WebURL
public struct Announcement: Decodable, Sendable, Hashable, Identifiable { public struct Announcement: Decodable, Sendable, Hashable, Identifiable {
public let id: String public let id: String
@ -59,7 +60,7 @@ extension Announcement {
public struct Account: Decodable, Sendable, Hashable { public struct Account: Decodable, Sendable, Hashable {
public let id: String public let id: String
public let username: String public let username: String
@URLDecoder public var url: URL public let url: WebURL
public let acct: String public let acct: String
} }
} }
@ -67,7 +68,7 @@ extension Announcement {
extension Announcement { extension Announcement {
public struct Status: Decodable, Sendable, Hashable { public struct Status: Decodable, Sendable, Hashable {
public let id: String public let id: String
@URLDecoder public var url: URL public let url: WebURL
} }
} }

View File

@ -7,17 +7,18 @@
// //
import Foundation import Foundation
import WebURL
public struct Card: Codable, Sendable { public struct Card: Codable, Sendable {
@URLDecoder public var url: URL public let url: WebURL
public let title: String public let title: String
public let description: String public let description: String
@OptionalURLDecoder public var image: URL? public let image: WebURL?
public let kind: Kind public let kind: Kind
public let authorName: String? public let authorName: String?
@OptionalURLDecoder public var authorURL: URL? public let authorURL: WebURL?
public let providerName: String? public let providerName: String?
@OptionalURLDecoder public var providerURL: URL? public let providerURL: WebURL?
public let html: String? public let html: String?
public let width: Int? public let width: Int?
public let height: Int? public let height: Int?
@ -26,15 +27,15 @@ public struct Card: Codable, Sendable {
public let history: [History]? public let history: [History]?
public init( public init(
url: URL, url: WebURL,
title: String, title: String,
description: String, description: String,
image: URL? = nil, image: WebURL? = nil,
kind: Card.Kind, kind: Card.Kind,
authorName: String? = nil, authorName: String? = nil,
authorURL: URL? = nil, authorURL: WebURL? = nil,
providerName: String? = nil, providerName: String? = nil,
providerURL: URL? = nil, providerURL: WebURL? = nil,
html: String? = nil, html: String? = nil,
width: Int? = nil, width: Int? = nil,
height: Int? = nil, height: Int? = nil,
@ -60,15 +61,15 @@ public struct Card: Codable, Sendable {
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) 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.title = try container.decode(String.self, forKey: .title)
self.description = try container.decode(String.self, forKey: .description) self.description = try container.decode(String.self, forKey: .description)
self.kind = try container.decode(Kind.self, forKey: .kind) 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.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.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.html = try? container.decodeIfPresent(String.self, forKey: .html)
self.width = try? container.decodeIfPresent(Int.self, forKey: .width) self.width = try? container.decodeIfPresent(Int.self, forKey: .width)
self.height = try? container.decodeIfPresent(Int.self, forKey: .height) self.height = try? container.decodeIfPresent(Int.self, forKey: .height)

View File

@ -7,11 +7,14 @@
// //
import Foundation import Foundation
import WebURL
public struct Emoji: Codable, Sendable { public struct Emoji: Codable, Sendable {
public let shortcode: String public let shortcode: String
@URLDecoder public var url: URL // these shouldn't need to be WebURLs as they're not external resources,
@URLDecoder public var staticURL: URL // 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 visibleInPicker: Bool
public let category: String? public let category: String?
@ -19,8 +22,13 @@ public struct Emoji: Codable, Sendable {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
self.shortcode = try container.decode(String.self, forKey: .shortcode) self.shortcode = try container.decode(String.self, forKey: .shortcode)
self._url = try container.decode(URLDecoder.self, forKey: .url) do {
self._staticURL = try container.decode(URLDecoder.self, forKey: .staticURL) 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.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker)
self.category = try container.decodeIfPresent(String.self, forKey: .category) self.category = try container.decodeIfPresent(String.self, forKey: .category)
} }

View File

@ -7,10 +7,12 @@
// //
import Foundation import Foundation
import WebURL
import WebURLFoundationExtras
public struct Hashtag: Codable, Sendable { public struct Hashtag: Codable, Sendable {
public let name: String public let name: String
@URLDecoder public var url: URL public let url: WebURL
/// Only present when returned from the trending hashtags endpoint /// Only present when returned from the trending hashtags endpoint
public let history: [History]? public let history: [History]?
/// Only present on Mastodon >= 4 and when logged in /// Only present on Mastodon >= 4 and when logged in
@ -18,7 +20,7 @@ public struct Hashtag: Codable, Sendable {
public init(name: String, url: URL) { public init(name: String, url: URL) {
self.name = name self.name = name
self.url = url self.url = WebURL(url)!
self.history = nil self.history = nil
self.following = nil self.following = nil
} }
@ -27,7 +29,7 @@ public struct Hashtag: Codable, Sendable {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
self.name = try container.decode(String.self, forKey: .name) self.name = try container.decode(String.self, forKey: .name)
// pixelfed (possibly others) don't fully escape special characters in the hashtag url // 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.history = try container.decodeIfPresent([History].self, forKey: .history)
self.following = try container.decodeIfPresent(Bool.self, forKey: .following) self.following = try container.decodeIfPresent(Bool.self, forKey: .following)
} }

View File

@ -7,9 +7,10 @@
// //
import Foundation import Foundation
import WebURL
public struct Mention: Codable, Sendable { public struct Mention: Codable, Sendable {
@URLDecoder public var url: URL public let url: WebURL
public let username: String public let username: String
public let acct: String public let acct: String
/// The instance-local ID of the user being mentioned. /// 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.username = try container.decode(String.self, forKey: .username)
self.acct = try container.decode(String.self, forKey: .acct) self.acct = try container.decode(String.self, forKey: .acct)
self.id = try container.decode(String.self, forKey: .id) 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.url = url
self.username = username self.username = username
self.acct = acct self.acct = acct

View File

@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import WebURL
public struct Notification: Decodable, Sendable { public struct Notification: Decodable, Sendable {
public let id: String public let id: String
@ -17,7 +18,7 @@ public struct Notification: Decodable, Sendable {
// Only present for pleroma emoji reactions // Only present for pleroma emoji reactions
// Either an emoji or :shortcode: (for akkoma custom emoji reactions) // Either an emoji or :shortcode: (for akkoma custom emoji reactions)
public let emoji: String? public let emoji: String?
@OptionalURLDecoder public var emojiURL: URL? public let emojiURL: WebURL?
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) 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.account = try container.decode(Account.self, forKey: .account)
self.status = try container.decodeIfPresent(Status.self, forKey: .status) self.status = try container.decodeIfPresent(Status.self, forKey: .status)
self.emoji = try container.decodeIfPresent(String.self, forKey: .emoji) 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> { public static func dismiss(id notificationID: String) -> Request<Empty> {

View File

@ -6,13 +6,14 @@
// //
import Foundation import Foundation
import WebURL
public struct PushNotification: Decodable { public struct PushNotification: Decodable {
public let accessToken: String public let accessToken: String
public let preferredLocale: String public let preferredLocale: String
public let notificationID: String public let notificationID: String
public let notificationType: Notification.Kind public let notificationType: Notification.Kind
@URLDecoder public var icon: URL public let icon: WebURL
public let title: String public let title: String
public let body: String public let body: String
@ -28,7 +29,7 @@ public struct PushNotification: Decodable {
self.notificationID = i.description self.notificationID = i.description
} }
self.notificationType = try container.decode(Notification.Kind.self, forKey: .notificationType) 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.title = try container.decode(String.self, forKey: .title)
self.body = try container.decode(String.self, forKey: .body) self.body = try container.decode(String.self, forKey: .body)
} }

View File

@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import WebURL
public final class Status: StatusProtocol, Decodable, Sendable { public final class Status: StatusProtocol, Decodable, Sendable {
/// The pseudo-visibility used by instance types (Akkoma) that overload the visibility for local-only posts. /// 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 id: String
public let uri: String public let uri: String
private let _url: OptionalURLDecoder public let url: WebURL?
public var url: URL? { _url.wrappedValue }
public let account: Account public let account: Account
public let inReplyToID: String? public let inReplyToID: String?
public let inReplyToAccountID: 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.id = try container.decode(String.self, forKey: .id)
self.uri = try container.decode(String.self, forKey: .uri) self.uri = try container.decode(String.self, forKey: .uri)
do { do {
self._url = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .url) ?? nil self.url = try container.decodeIfPresent(WebURL.self, forKey: .url)
} catch { } catch {
let s = try? container.decode(String.self, forKey: .url) let s = try? container.decode(String.self, forKey: .url)
if s == "" { if s == "" {
self._url = OptionalURLDecoder(wrappedValue: nil) self.url = nil
} else { } 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) self.account = try container.decode(Account.self, forKey: .account)

View File

@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import WebURL
public struct NotificationGroup: Identifiable, Hashable, Sendable { public struct NotificationGroup: Identifiable, Hashable, Sendable {
public private(set) var notifications: [Notification] public private(set) var notifications: [Notification]
@ -149,7 +150,7 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
case poll case poll
case update case update
case status case status
case emojiReaction(String, URL?) case emojiReaction(String, WebURL?)
case unknown case unknown
var notificationKind: Notification.Kind { 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 import XCTest
@testable import Pachyderm import WebURL
import WebURLFoundationExtras
class URLTests: XCTestCase { class URLTests: XCTestCase {
func testDecodeURL() { func testDecodeURL() {
XCTAssertNotNil(try? URL.ParseStrategy().parse("https://xn--baw-joa.social/test/é")) XCTAssertNotNil(WebURL(URL(string: "https://xn--baw-joa.social/@unituebingen")!))
XCTAssertNotNil(try? URL.ParseStrategy().parse("http://見.香港/热狗/🌭")) 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/é"))
func testRoundtripURL() throws { if #available(iOS 16.0, *) {
let orig = URLDecoder(wrappedValue: URL(string: "https://example.com")!) XCTAssertNotNil(try? URL.ParseStrategy().parse("https://xn--baw-joa.social/test/é"))
let encoded = try JSONEncoder().encode(orig) XCTAssertNotNil(try? URL.ParseStrategy().parse("http://見.香港/热狗/🌭"))
print(String(data: encoded, encoding: .utf8)!) }
let decoded = try JSONDecoder().decode(URLDecoder.self, from: encoded)
XCTAssertEqual(orig.wrappedValue, decoded.wrappedValue)
} }
} }

View File

@ -9,6 +9,7 @@
import SwiftUI import SwiftUI
import ComposeUI import ComposeUI
import TuskerComponents import TuskerComponents
import WebURLFoundationExtras
import Combine import Combine
import TuskerPreferences import TuskerPreferences
@ -45,7 +46,7 @@ class ShareHostingController: UIHostingController<ShareHostingController.View> {
currentAccountContainerView: { AnyView(SwitchAccountContainerView(content: $0, mastodonContextPublisher: mastodonContextPublisher)) }, currentAccountContainerView: { AnyView(SwitchAccountContainerView(content: $0, mastodonContextPublisher: mastodonContextPublisher)) },
replyContentView: { _, _ in fatalError("replies aren't allowed in share sheet") }, replyContentView: { _, _ in fatalError("replies aren't allowed in share sheet") },
emojiImageView: { emojiImageView: {
AnyView(AsyncImage(url: $0.url) { AnyView(AsyncImage(url: URL($0.url)!) {
$0 $0
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)

View File

@ -103,6 +103,7 @@
D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E02BC61C6700208903 /* UserAccounts */; }; D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E02BC61C6700208903 /* UserAccounts */; };
D630C3E52BC6313400208903 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E42BC6313400208903 /* Pachyderm */; }; D630C3E52BC6313400208903 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E42BC6313400208903 /* Pachyderm */; };
D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */ = {isa = PBXBuildFile; productRef = D630C4222BC7842C00208903 /* HTMLStreamer */; }; 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 */; }; D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; };
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.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 */; }; 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 */; }; D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; };
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626B21361C6700C9CBA2 /* Account+Preferences.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 */; }; 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 */; }; D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; };
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; }; D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; };
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; }; D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; };
@ -816,6 +818,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D630C4252BC7845800208903 /* WebURL in Frameworks */,
D62220472C7EA8DF003E43B7 /* TuskerPreferences in Frameworks */, D62220472C7EA8DF003E43B7 /* TuskerPreferences in Frameworks */,
D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */, D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */,
D630C3E52BC6313400208903 /* Pachyderm in Frameworks */, D630C3E52BC6313400208903 /* Pachyderm in Frameworks */,
@ -852,6 +855,7 @@
D6934F2C2BA7AD32002B1C8D /* GalleryVC in Frameworks */, D6934F2C2BA7AD32002B1C8D /* GalleryVC in Frameworks */,
D6552367289870790048A653 /* ScreenCorners in Frameworks */, D6552367289870790048A653 /* ScreenCorners in Frameworks */,
D60BB3942B30076F00DAEA65 /* HTMLStreamer in Frameworks */, D60BB3942B30076F00DAEA65 /* HTMLStreamer in Frameworks */,
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
D63CC702290EC0B8000E19DE /* Sentry in Frameworks */, D63CC702290EC0B8000E19DE /* Sentry in Frameworks */,
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */, D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */,
); );
@ -1777,6 +1781,7 @@
D630C3E02BC61C6700208903 /* UserAccounts */, D630C3E02BC61C6700208903 /* UserAccounts */,
D630C3E42BC6313400208903 /* Pachyderm */, D630C3E42BC6313400208903 /* Pachyderm */,
D630C4222BC7842C00208903 /* HTMLStreamer */, D630C4222BC7842C00208903 /* HTMLStreamer */,
D630C4242BC7845800208903 /* WebURL */,
D62220462C7EA8DF003E43B7 /* TuskerPreferences */, D62220462C7EA8DF003E43B7 /* TuskerPreferences */,
); );
productName = NotificationExtension; productName = NotificationExtension;
@ -1828,6 +1833,7 @@
); );
name = Tusker; name = Tusker;
packageProductDependencies = ( packageProductDependencies = (
D6676CA427A8D0020052936B /* WebURLFoundationExtras */,
D674A50827F9128D00BA03AC /* Pachyderm */, D674A50827F9128D00BA03AC /* Pachyderm */,
D6552366289870790048A653 /* ScreenCorners */, D6552366289870790048A653 /* ScreenCorners */,
D63CC701290EC0B8000E19DE /* Sentry */, D63CC701290EC0B8000E19DE /* Sentry */,
@ -1954,6 +1960,7 @@
); );
mainGroup = D6D4DDC3212518A000E1C4BB; mainGroup = D6D4DDC3212518A000E1C4BB;
packageReferences = ( packageReferences = (
D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */,
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */, D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */,
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */, D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */,
D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */, D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */,
@ -3267,6 +3274,14 @@
minimumVersion = 1.0.1; 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 */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
@ -3304,6 +3319,11 @@
package = D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */; package = D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */;
productName = HTMLStreamer; productName = HTMLStreamer;
}; };
D630C4242BC7845800208903 /* WebURL */ = {
isa = XCSwiftPackageProductDependency;
package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */;
productName = WebURL;
};
D635237029B78A7D009ED5E7 /* TuskerComponents */ = { D635237029B78A7D009ED5E7 /* TuskerComponents */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = TuskerComponents; productName = TuskerComponents;
@ -3322,6 +3342,11 @@
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = TTTKit; productName = TTTKit;
}; };
D6676CA427A8D0020052936B /* WebURLFoundationExtras */ = {
isa = XCSwiftPackageProductDependency;
package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */;
productName = WebURLFoundationExtras;
};
D674A50827F9128D00BA03AC /* Pachyderm */ = { D674A50827F9128D00BA03AC /* Pachyderm */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = Pachyderm; productName = Pachyderm;

View File

@ -9,6 +9,7 @@
import Foundation import Foundation
import CoreData import CoreData
import Pachyderm import Pachyderm
import WebURLFoundationExtras
@objc(FollowedHashtag) @objc(FollowedHashtag)
public final class FollowedHashtag: NSManagedObject { public final class FollowedHashtag: NSManagedObject {
@ -32,6 +33,6 @@ extension FollowedHashtag {
convenience init(hashtag: Hashtag, context: NSManagedObjectContext) { convenience init(hashtag: Hashtag, context: NSManagedObjectContext) {
self.init(context: context) self.init(context: context)
self.name = hashtag.name self.name = hashtag.name
self.url = hashtag.url self.url = URL(hashtag.url)!
} }
} }

View File

@ -9,6 +9,7 @@
import Foundation import Foundation
import CoreData import CoreData
import Pachyderm import Pachyderm
import WebURLFoundationExtras
import UserAccounts import UserAccounts
@objc(SavedHashtag) @objc(SavedHashtag)
@ -41,6 +42,6 @@ extension SavedHashtag {
self.init(context: context) self.init(context: context)
self.accountID = account.id self.accountID = account.id
self.name = hashtag.name self.name = hashtag.name
self.url = hashtag.url self.url = URL(hashtag.url)!
} }
} }

View File

@ -10,6 +10,7 @@
import Foundation import Foundation
import CoreData import CoreData
import Pachyderm import Pachyderm
import WebURLFoundationExtras
@objc(StatusMO) @objc(StatusMO)
public final class StatusMO: NSManagedObject, StatusProtocol { public final class StatusMO: NSManagedObject, StatusProtocol {
@ -135,7 +136,7 @@ extension StatusMO {
self.sensitive = status.sensitive self.sensitive = status.sensitive
self.spoilerText = status.spoilerText self.spoilerText = status.spoilerText
self.uri = status.uri self.uri = status.uri
self.url = status.url self.url = status.url != nil ? URL(status.url!) : nil
self.visibility = status.visibility self.visibility = status.visibility
self.poll = status.poll self.poll = status.poll
self.localOnly = status.localOnly ?? false self.localOnly = status.localOnly ?? false

View File

@ -8,7 +8,8 @@
import UIKit import UIKit
import HTMLStreamer import HTMLStreamer
import Pachyderm import WebURL
import WebURLFoundationExtras
class HTMLConverter { class HTMLConverter {
@ -44,7 +45,17 @@ extension HTMLConverter {
// note: this is duplicated in NotificationExtension // note: this is duplicated in NotificationExtension
struct Callbacks: HTMLConversionCallbacks { struct Callbacks: HTMLConversionCallbacks {
static func makeURL(string: String) -> URL? { 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 { static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import WebURL
class AnnouncementContentTextView: ContentTextView { class AnnouncementContentTextView: ContentTextView {
@ -29,7 +30,7 @@ class AnnouncementContentTextView: ContentTextView {
override func getMention(for url: URL, text: String) -> Mention? { override func getMention(for url: URL, text: String) -> Mention? {
announcement?.mentions.first { announcement?.mentions.first {
$0.url == url URL($0.url) == url
}.map { }.map {
Mention(url: $0.url, username: $0.username, acct: $0.acct, id: $0.id) 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? { override func getHashtag(for url: URL, text: String) -> Hashtag? {
announcement?.tags.first { announcement?.tags.first {
$0.url == url URL($0.url) == url
} }
} }

View File

@ -9,6 +9,7 @@
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
import TuskerComponents import TuskerComponents
import WebURLFoundationExtras
struct AnnouncementListRow: View { struct AnnouncementListRow: View {
@Binding var announcement: Announcement @Binding var announcement: Announcement
@ -115,8 +116,8 @@ struct AnnouncementListRow: View {
let url: URL? let url: URL?
let staticURL: URL? let staticURL: URL?
if case .custom(let emoji) = reaction { if case .custom(let emoji) = reaction {
url = emoji.url url = URL(emoji.url)
staticURL = emoji.staticURL staticURL = URL(emoji.staticURL)
} else { } else {
url = nil url = nil
staticURL = nil staticURL = nil

View File

@ -8,6 +8,8 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import WebURL
import WebURLFoundationExtras
private let mastodonRemoteStatusRegex = try! NSRegularExpression(pattern: "^/@.+@.+/\\d{18}") private let mastodonRemoteStatusRegex = try! NSRegularExpression(pattern: "^/@.+@.+/\\d{18}")
private func isLikelyMastodonRemoteStatus(url: URL) -> Bool { private func isLikelyMastodonRemoteStatus(url: URL) -> Bool {
@ -227,10 +229,10 @@ class ConversationViewController: UIViewController {
let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") { let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") {
effectiveURL = location effectiveURL = location
} else { } else {
effectiveURL = url.formatted(.url.fragment(.never)) effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
} }
} else { } else {
effectiveURL = url.formatted(.url.fragment(.never)) effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
} }
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true) let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)

View File

@ -10,6 +10,7 @@ import UIKit
import Combine import Combine
import Pachyderm import Pachyderm
import CoreData import CoreData
import WebURLFoundationExtras
class ExploreViewController: UIViewController, UICollectionViewDelegate, CollectionViewController { class ExploreViewController: UIViewController, UICollectionViewDelegate, CollectionViewController {
@ -559,7 +560,10 @@ extension ExploreViewController: UICollectionViewDragDelegate {
activity.displaysAuxiliaryScene = true activity.displaysAuxiliaryScene = true
provider = NSItemProvider(object: activity) provider = NSItemProvider(object: activity)
case let .savedHashtag(hashtag): 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) { if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: accountID) {
activity.displaysAuxiliaryScene = true activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import WebURLFoundationExtras
import Combine import Combine
class TrendingHashtagsViewController: UIViewController, CollectionViewController { class TrendingHashtagsViewController: UIViewController, CollectionViewController {
@ -276,10 +277,11 @@ extension TrendingHashtagsViewController: UICollectionViewDelegate {
extension TrendingHashtagsViewController: UICollectionViewDragDelegate { extension TrendingHashtagsViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let item = dataSource.itemIdentifier(for: indexPath), 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 [] 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) { if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) {
activity.displaysAuxiliaryScene = true activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)

View File

@ -9,6 +9,7 @@
#if !os(visionOS) #if !os(visionOS)
import UIKit import UIKit
import Pachyderm import Pachyderm
import WebURLFoundationExtras
import HTMLStreamer import HTMLStreamer
class TrendingLinkCardCollectionViewCell: UICollectionViewCell { class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
@ -70,7 +71,7 @@ class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
self.card = card self.card = card
self.thumbnailView.image = nil 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) let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
titleLabel.text = title titleLabel.text = title

View File

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

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import WebURLFoundationExtras
import SafariServices import SafariServices
import Combine import Combine
#if os(visionOS) #if os(visionOS)
@ -292,19 +293,21 @@ extension TrendingLinksViewController: UICollectionViewDelegate {
} }
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 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 return
} }
selected(url: card.url) selected(url: url)
} }
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard case .link(let card) = dataSource.itemIdentifier(for: indexPath), guard case .link(let card) = dataSource.itemIdentifier(for: indexPath),
let url = URL(card.url),
let cell = collectionView.cellForItem(at: indexPath) else { let cell = collectionView.cellForItem(at: indexPath) else {
return nil return nil
} }
return UIContextMenuConfiguration { return UIContextMenuConfiguration {
let vc = SFSafariViewController(url: card.url) let vc = SFSafariViewController(url: url)
#if !os(visionOS) #if !os(visionOS)
vc.preferredControlTintColor = Preferences.shared.accentColor.color vc.preferredControlTintColor = Preferences.shared.accentColor.color
#endif #endif
@ -321,10 +324,11 @@ extension TrendingLinksViewController: UICollectionViewDelegate {
extension TrendingLinksViewController: UICollectionViewDragDelegate { extension TrendingLinksViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { 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 []
} }
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) show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
case let .link(card): case let .link(card):
selected(url: card.url) if let url = URL(card.url) {
selected(url: url)
}
case let .status(id, state): case let .status(id, state):
selected(status: id, state: state.copy()) selected(status: id, state: state.copy())
@ -542,9 +544,12 @@ extension TrendsViewController: UICollectionViewDelegate {
} }
case let .link(card): case let .link(card):
guard let url = URL(card.url) else {
return nil
}
let cell = collectionView.cellForItem(at: indexPath)! let cell = collectionView.cellForItem(at: indexPath)!
return UIContextMenuConfiguration { return UIContextMenuConfiguration {
let vc = SFSafariViewController(url: card.url) let vc = SFSafariViewController(url: url)
#if !os(visionOS) #if !os(visionOS)
vc.preferredControlTintColor = Preferences.shared.accentColor.color vc.preferredControlTintColor = Preferences.shared.accentColor.color
#endif #endif
@ -619,7 +624,10 @@ extension TrendsViewController: UICollectionViewDragDelegate {
return [] return []
case let .tag(hashtag): 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) { if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) {
activity.displaysAuxiliaryScene = true activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
@ -627,7 +635,10 @@ extension TrendsViewController: UICollectionViewDragDelegate {
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
case let .link(card): 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, _): case let .status(id, _):
guard let status = mastodonController.persistentContainer.status(for: id), guard let status = mastodonController.persistentContainer.status(for: id),

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import UserAccounts import UserAccounts
import WebURL
class FastSwitchingAccountView: UIView { class FastSwitchingAccountView: UIView {
@ -130,7 +131,11 @@ class FastSwitchingAccountView: UIView {
private func setupAccount(account: UserAccountInfo) { private func setupAccount(account: UserAccountInfo) {
usernameLabel.text = account.username 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) let controller = MastodonController.getForAccount(account)
avatarTask = Task { avatarTask = Task {
guard let account = try? await controller.getOwnAccount(), guard let account = try? await controller.getOwnAccount(),

View File

@ -155,7 +155,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
fetchCustomEmojiImage?.1.cancel() fetchCustomEmojiImage?.1.cancel()
case .emojiReaction(let emojiOrShortcode, let url): case .emojiReaction(let emojiOrShortcode, let url):
iconImageView.image = nil iconImageView.image = nil
if let url, if let url = url.flatMap({ URL($0) }),
fetchCustomEmojiImage?.0 != url { fetchCustomEmojiImage?.0 != url {
fetchCustomEmojiImage?.1.cancel() fetchCustomEmojiImage?.1.cancel()
let task = Task { let task = Task {

View File

@ -740,7 +740,7 @@ extension NotificationsCollectionViewController: UICollectionViewDragDelegate {
return cell.dragItemsForBeginning(session: session) return cell.dragItemsForBeginning(session: session)
case .poll, .update: case .poll, .update:
let status = group.notifications.first!.status! 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) let activity = UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: mastodonController.accountInfo!.id)
activity.displaysAuxiliaryScene = true activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)

View File

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
import WebURL
struct MockStatusView: View { struct MockStatusView: View {
@ObservedObject private var preferences = Preferences.shared @ObservedObject private var preferences = Preferences.shared
@ -135,8 +136,8 @@ private struct MockStatusCardView: UIViewRepresentable {
let view = StatusCardView() let view = StatusCardView()
view.isUserInteractionEnabled = false view.isUserInteractionEnabled = false
let card = StatusCardView.CardData( let card = StatusCardView.CardData(
url: URL(string: "https://vaccor.space/tusker")!, url: WebURL("https://vaccor.space/tusker")!,
image: URL(string: "https://vaccor.space/tusker/img/icon.png")!, image: WebURL("https://vaccor.space/tusker/img/icon.png")!,
title: "Tusker", title: "Tusker",
description: "Tusker is an iOS app for Mastodon" description: "Tusker is an iOS app for Mastodon"
) )

View File

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
import UserAccounts import UserAccounts
import WebURL
struct PrefsAccountView: View { struct PrefsAccountView: View {
let account: UserAccountInfo let account: UserAccountInfo
@ -18,7 +19,12 @@ struct PrefsAccountView: View {
VStack(alignment: .prefsAvatar) { VStack(alignment: .prefsAvatar) {
Text(verbatim: account.username) Text(verbatim: account.username)
.foregroundColor(.primary) .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) .font(.caption)
.foregroundColor(.primary) .foregroundColor(.primary)
} }

View File

@ -9,6 +9,7 @@
import UIKit import UIKit
import Combine import Combine
import Pachyderm import Pachyderm
import WebURLFoundationExtras
fileprivate let accountCell = "accountCell" fileprivate let accountCell = "accountCell"
fileprivate let statusCell = "statusCell" fileprivate let statusCell = "statusCell"
@ -537,7 +538,7 @@ extension SearchResultsViewController: UICollectionViewDragDelegate {
url = account.url url = account.url
activity = UserActivityManager.showProfileActivity(id: id, accountID: accountInfo.id) activity = UserActivityManager.showProfileActivity(id: id, accountID: accountInfo.id)
case .hashtag(let tag): case .hashtag(let tag):
url = tag.url url = URL(tag.url)!
activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: tag.name), accountID: accountInfo.id)! activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: tag.name), accountID: accountInfo.id)!
case .status(let id, _): case .status(let id, _):
guard let status = mastodonController.persistentContainer.status(for: id), guard let status = mastodonController.persistentContainer.status(for: id),

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import WebURL
class StatusActionAccountListViewController: UIViewController { class StatusActionAccountListViewController: UIViewController {
@ -182,7 +183,7 @@ extension StatusActionAccountListViewController {
enum ActionType { enum ActionType {
case favorite case favorite
case reblog case reblog
case emojiReaction(String, URL?) case emojiReaction(String, WebURL?)
init?(_ groupKind: NotificationGroup.Kind) { init?(_ groupKind: NotificationGroup.Kind) {
switch groupKind { switch groupKind {

View File

@ -9,6 +9,7 @@
import UIKit import UIKit
import SafariServices import SafariServices
import Pachyderm import Pachyderm
import WebURLFoundationExtras
import SwiftUI import SwiftUI
@MainActor @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 [ return [
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection), UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
@ -369,11 +375,14 @@ extension MenuActionProvider {
} }
func actionsForTrendingLink(card: Card, source: PopoverSource) -> [UIMenuElement] { func actionsForTrendingLink(card: Card, source: PopoverSource) -> [UIMenuElement] {
guard let url = URL(card.url) else {
return []
}
return [ return [
openInSafariAction(url: card.url), openInSafariAction(url: url),
createAction(identifier: "share", title: "Share…", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in createAction(identifier: "share", title: "Share…", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self else { return } 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 createAction(identifier: "postlink", title: "Post this Link", systemImageName: "square.and.pencil", handler: { [weak self] _ in
guard let self = self else { return } guard let self = self else { return }
@ -384,7 +393,7 @@ extension MenuActionProvider {
text += title text += title
text += ":\n" text += ":\n"
} }
text += card.url.absoluteString text += url.absoluteString
let draft = self.mastodonController!.createDraft(text: text) let draft = self.mastodonController!.createDraft(text: text)
self.navigationDelegate?.compose(editing: draft) self.navigationDelegate?.compose(editing: draft)

View File

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
import WebURLFoundationExtras
import os import os
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
@ -53,7 +54,7 @@ struct AccountDisplayNameView: View {
} }
group.enter() group.enter()
let request = ImageCache.emojis.get(emoji.url) { (_, image) in let request = ImageCache.emojis.get(URL(emoji.url)!) { (_, image) in
defer { group.leave() } defer { group.leave() }
guard let image = image else { return } guard let image = image else { return }

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import WebURLFoundationExtras
import os import os
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
@ -72,7 +73,7 @@ extension BaseEmojiLabel {
foundEmojis = true 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. // if the image is cached, add it immediately.
// we generate the thumbnail on the main thread, because it's usually fast enough // we generate the thumbnail on the main thread, because it's usually fast enough
// and the delay caused by doing it asynchronously looks works. // and the delay caused by doing it asynchronously looks works.
@ -89,7 +90,7 @@ extension BaseEmojiLabel {
// otherwise, perform the network request // otherwise, perform the network request
group.enter() 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 { guard let image else {
group.leave() group.leave()
return return
@ -97,7 +98,7 @@ extension BaseEmojiLabel {
image.prepareThumbnail(of: emojiImageSize(image)) { thumbnail in image.prepareThumbnail(of: emojiImageSize(image)) { thumbnail in
guard let thumbnail = thumbnail?.cgImage, guard let thumbnail = thumbnail?.cgImage,
case let rescaled = UIImage(cgImage: thumbnail, scale: screenScale, orientation: .up), 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() group.leave()
return return
} }

View File

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

View File

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
import WebURLFoundationExtras
struct CustomEmojiImageView: View { struct CustomEmojiImageView: View {
let emoji: Emoji let emoji: Emoji
@ -34,7 +35,7 @@ struct CustomEmojiImageView: View {
@MainActor @MainActor
private func loadImage() { private func loadImage() {
request = ImageCache.emojis.get(emoji.url) { (_, image) in request = ImageCache.emojis.get(URL(emoji.url)!) { (_, image) in
DispatchQueue.main.async { DispatchQueue.main.async {
self.request = nil self.request = nil
if let image = image { if let image = image {

View File

@ -9,6 +9,8 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import SafariServices import SafariServices
import WebURL
import WebURLFoundationExtras
import HTMLStreamer import HTMLStreamer
class StatusCardView: UIView { class StatusCardView: UIView {
@ -182,14 +184,14 @@ class StatusCardView: UIView {
if sensitive { if sensitive {
if let blurhash = card.blurhash { if let blurhash = card.blurhash {
imageView.blurImage = false imageView.blurImage = false
imageView.showOnlyBlurHash(blurhash, for: image) imageView.showOnlyBlurHash(blurhash, for: URL(image)!)
} else { } else {
// if we don't have a blurhash, load the image and show it behind a blur // if we don't have a blurhash, load the image and show it behind a blur
imageView.blurImage = true imageView.blurImage = true
imageView.update(for: image, blurhash: nil) imageView.update(for: URL(image), blurhash: nil)
} }
} else { } else {
imageView.update(for: image, blurhash: card.blurhash) imageView.update(for: URL(image), blurhash: card.blurhash)
} }
imageView.isHidden = false imageView.isHidden = false
leadingSpacer.isHidden = true leadingSpacer.isHidden = true
@ -208,8 +210,8 @@ class StatusCardView: UIView {
descriptionLabel.text = description descriptionLabel.text = description
descriptionLabel.isHidden = description.isEmpty descriptionLabel.isHidden = description.isEmpty
if let host = card.url.host(percentEncoded: false) { if let host = card.url.host {
domainLabel.text = host domainLabel.text = host.serialized
domainLabel.isHidden = false domainLabel.isHidden = false
} else { } else {
domainLabel.isHidden = true domainLabel.isHidden = true
@ -236,7 +238,7 @@ class StatusCardView: UIView {
setNeedsDisplay() setNeedsDisplay()
if let card = card, let delegate = navigationDelegate { 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 { struct CardData: Equatable {
let url: URL let url: WebURL
let image: URL? let image: WebURL?
let title: String let title: String
let description: String let description: String
let blurhash: String? let blurhash: String?
@ -260,7 +262,7 @@ class StatusCardView: UIView {
self.blurhash = card.blurhash 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.url = url
self.image = image self.image = image
self.title = title self.title = title
@ -276,13 +278,13 @@ extension StatusCardView: UIContextMenuInteractionDelegate {
guard let card = card else { return nil } guard let card = card else { return nil }
return UIContextMenuConfiguration(identifier: nil) { return UIContextMenuConfiguration(identifier: nil) {
let vc = SFSafariViewController(url: card.url) let vc = SFSafariViewController(url: URL(card.url)!)
#if !os(visionOS) #if !os(visionOS)
vc.preferredControlTintColor = Preferences.shared.accentColor.color vc.preferredControlTintColor = Preferences.shared.accentColor.color
#endif #endif
return vc return vc
} actionProvider: { (_) in } 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) return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
} }
} }

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import WebURLFoundationExtras
class StatusContentTextView: ContentTextView { class StatusContentTextView: ContentTextView {
@ -25,7 +26,7 @@ class StatusContentTextView: ContentTextView {
let mastodonController = mastodonController, let mastodonController = mastodonController,
let status = mastodonController.persistentContainer.status(for: statusID) { let status = mastodonController.persistentContainer.status(for: statusID) {
mention = status.mentions.first { (mention) in 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.username // Mastodon and Pleroma include @ in the text
|| text.dropFirst() == mention.acct // Misskey includes @ and uses the whole acct || 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 || 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 mastodonController = mastodonController,
let status = mastodonController.persistentContainer.status(for: statusID) { let status = mastodonController.persistentContainer.status(for: statusID) {
hashtag = status.hashtags.first { (hashtag) in hashtag = status.hashtags.first { (hashtag) in
hashtag.url == url URL(hashtag.url) == url
} }
} else { } else {
hashtag = nil hashtag = nil