Merge branch 'develop' into vision

# Conflicts:
#	Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift
#	Tusker/Screens/Timeline/TimelineViewController.swift
#	Tusker/Views/Status/TimelineStatusCollectionViewCell.swift
This commit is contained in:
Shadowfacts 2024-01-26 11:11:41 -05:00
commit 94f71541f8
78 changed files with 1247 additions and 684 deletions

View File

@ -1,3 +1,48 @@
## 2023.8
This update adds support for search operators and post translation, and improves support for displaying rich-text posts. See below for a full list of improvements and fixes.
Features/Improvements:
- Show search operators on Mastodon 4.2
- Use server-set preference for default post visibility, language, and (on Hometown) local-only
- Allow changing list reply policy and exclusivity options on Edit List screen
- Add Translate action to conversations (on supported Mastodon instances)
- Style block quotes correclty in rich-text posts
- Improve the appearance of lists in rich-text posts
- Add preference to underline links
- Compress uploaded video attachments to fit within instance limits
- Add preference to hide attachments in timelines
- Update visible timestamps after refresh notifications/timelines
- iPadOS: Allow switching between split screen and fullscreen navigation modes
- Pixelfed: Improve error message when uploading attachment fails
- Akkoma: Enable composing local-only posts
Bugfixes:
- Fix older notifications not loading if all initiially-loaded ones are grouped together
- Fix List timelines failing to refresh if they were initially empty
- Fix replies to posts with CWs always showing confirmation dialog when cancelling
- Fix Compose screen permitting setting the language to multiple/undefined
- Fix crash when uploading attachments without file extensions
- Fix Live Text button reappearing with swiping between attachment gallery pages
- Fix avatars on certain notifications flickering when refreshing
- Fix avatars on follow request notifications not being rounded
- Fix timeline jump button appearing incorrectly when Button Shapes acccessibility setting is on
- Fix public instance timeline screen not handling post deletion correctly
- Fix post that's reblogged and contains a followed hashtag not showing the reblogger
- Fix crash on launch when reblogged posts are visible
- Fix crash when showing display names with custom emoji in certain places
- Fix crash when showing trending hashtags without history data
- Fix potential crash on instance selector screen
- Fix potential crash if the app is dismissed while fast account switcher is animating
- Fix potential crash after deleting List on the Eplore screen
- Pixelfed: Fix error decoding certain posts
- VoiceOver: Fix history entries on Edit History screen not having descriptions
- iPadOS: Fix delay on app launch before "My Profile" sidebar item appears
- iPadOS: Fix language picker button not highlighting when hovered with the cursor
- macOS: Fix "New Post" window title appearing twice
- macOS: Fix Cmd+W sometimes closing non-foreground windows
- macOS: Fix visibility/local-only buttons not appearing in Compose toolbar
- macOS: Fix images copied from Safari not pasting on Compose screen
## 2023.7
This update adds support for iOS 17 and includes some minor changes.

View File

@ -1,5 +1,40 @@
# Changelog
## 2024.1 (111)
This build contains a complete rewrite of the HTML parsing pipeline for displaying posts. If you notice any issues with how post text appears—especially when it differs from on the web—please report it!
## 2023.8 (110)
Bugfixes:
- Fix potential crash after deleting List on Explore screen
## 2023.8 (109)
Features/Improvements:
- Add Translate action to conversations (on supported Mastodon instances)
- Improve share extension launch speed
- Add preference for hiding attachments in timelines
Bugfixes:
- Fix crash during state restoration when reblogged statuses are present
- Fix timeline state restoration using incorrect scroll position in certain circumstances
- Fix status that is reblogged and contains a followed hashtag not showing reblogger label
- macOS: Fix visibility/local-only buttons not appearing in Compose toolbar
- macOS: Fix images copied from Safari not pasting on Compose screen
## 2023.8 (107)
Features/Improvements:
- Style blockquotes in statuses
- Use server language preference for search operator suggestions
- Render IDN domains in the account switcher
Bugfixes:
- Fix crash when showing trending hashtags with improper history data
- Fix crash when uploading attachment w/o file extension
- Fix status deletions not being handled properly in logged out views
- Fix status history entries not having VoiceOver descriptions
- Fix avatars in follow request notifications not being rounded
- Fix potential crash if the app is dismissed while fast account switcher is animating
- Fix error decoding certain statuses on Pixelfed
## 2023.8 (106)
Bugfixes:
- Fix being able to set post language to multiple/undefined

View File

@ -114,13 +114,9 @@ class PostService: ObservableObject {
} catch let error as DraftAttachment.ExportError {
throw Error.attachmentData(index: index, cause: error)
}
do {
let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription)
attachments.append(uploaded.id)
currentStep += 1
} catch let error as Client.Error {
throw Error.attachmentUpload(index: index, cause: error)
}
let uploaded = try await uploadAttachment(index: index, data: data, utType: utType, description: attachment.attachmentDescription)
attachments.append(uploaded.id)
currentStep += 1
}
return attachments
}
@ -138,10 +134,21 @@ class PostService: ObservableObject {
}
}
private func uploadAttachment(data: Data, utType: UTType, description: String?) async throws -> Attachment {
let formAttachment = FormAttachment(mimeType: utType.preferredMIMEType!, data: data, fileName: "file.\(utType.preferredFilenameExtension!)")
private func uploadAttachment(index: Int, data: Data, utType: UTType, description: String?) async throws -> Attachment {
guard let mimeType = utType.preferredMIMEType else {
throw Error.attachmentMissingMimeType(index: index, type: utType)
}
var filename = "file"
if let ext = utType.preferredFilenameExtension {
filename.append(".\(ext)")
}
let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: filename)
let req = Client.upload(attachment: formAttachment, description: description)
return try await mastodonController.run(req).0
do {
return try await mastodonController.run(req).0
} catch let error as Client.Error {
throw Error.attachmentUpload(index: index, cause: error)
}
}
private func textForPosting() -> String {
@ -170,6 +177,7 @@ class PostService: ObservableObject {
enum Error: Swift.Error, LocalizedError {
case attachmentData(index: Int, cause: DraftAttachment.ExportError)
case attachmentMissingMimeType(index: Int, type: UTType)
case attachmentUpload(index: Int, cause: Client.Error)
case posting(Client.Error)
@ -177,6 +185,8 @@ class PostService: ObservableObject {
switch self {
case let .attachmentData(index: index, cause: cause):
return "Attachment \(index + 1): \(cause.localizedDescription)"
case let .attachmentMissingMimeType(index: index, type: type):
return "Attachment \(index + 1): unknown MIME type for \(type.identifier)"
case let .attachmentUpload(index: index, cause: cause):
return "Attachment \(index + 1): \(cause.localizedDescription)"
case let .posting(error):

View File

@ -90,7 +90,7 @@ class ToolbarController: ViewController {
cwButton
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
#if !os(visionOS)
#if !targetEnvironment(macCatalyst) && !os(visionOS)
// the button has a bunch of extra space by default, but combined with what we add it's too much
.padding(.horizontal, -8)
#endif
@ -99,7 +99,9 @@ class ToolbarController: ViewController {
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
localOnlyPicker
#if !os(visionOS)
#if targetEnvironment(macCatalyst)
.padding(.leading, 4)
#elseif !os(visionOS)
.padding(.horizontal, -8)
#endif
.disabled(draft.editedStatusID != nil)
@ -125,7 +127,6 @@ class ToolbarController: ViewController {
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
}
}
.buttonStyle(.borderless)
}
private var cwButton: some View {

View File

@ -157,10 +157,11 @@ extension DraftAttachment: NSItemProviderReading {
var data = data
var type = UTType(typeIdentifier)!
// this seems to only occur when the item is a UIImage, rather than just image data,
// which seems to only occur when sharing a screenshot directly from the markup screen
// the type is .image in certain circumstances:
// - macOS: image copied from macOS Safari -> only UIImage(data: data) works
// - iOS: sharing screenshot from markup -> only NSKeyedUnarchiver works
if type == .image,
let image = try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIImage.self, from: data),
let image = UIImage(data: data) ?? (try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIImage.self, from: data)),
let pngData = image.pngData() {
data = pngData
type = .png

View File

@ -21,7 +21,8 @@ public class InstanceFeatures: ObservableObject {
@Published public private(set) var charsReservedPerURL = 23
@Published public private(set) var maxPollOptionChars: Int?
@Published public private(set) var maxPollOptionsCount: Int?
@Published public private(set) var mediaAttachmentsConfiguration: Instance.MediaAttachmentsConfiguration?
@Published public private(set) var mediaAttachmentsConfiguration: InstanceV1.MediaAttachmentsConfiguration?
@Published public private(set) var translation: Bool = false
public var localOnlyPosts: Bool {
switch instanceType {
@ -240,6 +241,7 @@ public class InstanceFeatures: ObservableObject {
maxPollOptionsCount = pollsConfig.maxOptions
}
mediaAttachmentsConfiguration = instance.configuration?.mediaAttachments
translation = instance.translation
_featuresUpdated.send()
}

View File

@ -9,26 +9,39 @@ import Foundation
import Pachyderm
public struct InstanceInfo {
public let version: String
public let maxStatusCharacters: Int?
public let configuration: Instance.Configuration?
public let pollsConfiguration: Instance.PollsConfiguration?
public var version: String
public var maxStatusCharacters: Int?
public var configuration: InstanceV1.Configuration?
public var pollsConfiguration: InstanceV1.PollsConfiguration?
public var translation: Bool
public init(version: String, maxStatusCharacters: Int?, configuration: Instance.Configuration?, pollsConfiguration: Instance.PollsConfiguration?) {
public init(
version: String,
maxStatusCharacters: Int?,
configuration: InstanceV1.Configuration?,
pollsConfiguration: InstanceV1.PollsConfiguration?,
translation: Bool
) {
self.version = version
self.maxStatusCharacters = maxStatusCharacters
self.configuration = configuration
self.pollsConfiguration = pollsConfiguration
self.translation = translation
}
}
extension InstanceInfo {
public init(instance: Instance) {
public init(v1 instance: InstanceV1) {
self.init(
version: instance.version,
maxStatusCharacters: instance.maxStatusCharacters,
configuration: instance.configuration,
pollsConfiguration: instance.pollsConfiguration
pollsConfiguration: instance.pollsConfiguration,
translation: false
)
}
public mutating func update(v2: InstanceV2) {
translation = v2.configuration.translation.enabled
}
}

View File

@ -231,8 +231,12 @@ public class Client {
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
}
public static func getInstance() -> Request<Instance> {
return Request<Instance>(method: .get, path: "/api/v1/instance")
public static func getInstanceV1() -> Request<InstanceV1> {
return Request<InstanceV1>(method: .get, path: "/api/v1/instance")
}
public static func getInstanceV2() -> Request<InstanceV2> {
return Request<InstanceV2>(method: .get, path: "/api/v2/instance")
}
public static func getCustomEmoji() -> Request<[Emoji]> {

View File

@ -1,5 +1,5 @@
//
// Instance.swift
// InstanceV1.swift
// Pachyderm
//
// Created by Shadowfacts on 9/9/18.
@ -8,7 +8,7 @@
import Foundation
public struct Instance: Decodable, Sendable {
public struct InstanceV1: Decodable, Sendable {
public let uri: String
public let title: String
public let description: String
@ -92,7 +92,7 @@ public struct Instance: Decodable, Sendable {
}
}
extension Instance {
extension InstanceV1 {
public struct Stats: Decodable, Sendable {
public let domainCount: Int?
public let statusCount: Int?
@ -106,7 +106,7 @@ extension Instance {
}
}
extension Instance {
extension InstanceV1 {
public struct Configuration: Codable, Sendable {
public let statuses: StatusesConfiguration
public let mediaAttachments: MediaAttachmentsConfiguration
@ -121,7 +121,8 @@ extension Instance {
}
}
extension Instance {
extension InstanceV1 {
// note: also used by InstanceV2
public struct StatusesConfiguration: Codable, Sendable {
public let maxCharacters: Int
public let maxMediaAttachments: Int
@ -135,7 +136,8 @@ extension Instance {
}
}
extension Instance {
extension InstanceV1 {
// note: also used by InstanceV2
public struct MediaAttachmentsConfiguration: Codable, Sendable {
public let supportedMIMETypes: [String]
public let imageSizeLimit: Int
@ -155,7 +157,8 @@ extension Instance {
}
}
extension Instance {
extension InstanceV1 {
// note: also used by InstanceV2
public struct PollsConfiguration: Codable, Sendable {
public let maxOptions: Int
public let maxCharactersPerOption: Int
@ -171,7 +174,8 @@ extension Instance {
}
}
extension Instance {
extension InstanceV1 {
// note: also used by InstanceV2
public struct Rule: Decodable, Identifiable, Sendable {
public let id: String
public let text: String

View File

@ -0,0 +1,125 @@
//
// InstanceV2.swift
// Pachyderm
//
// Created by Shadowfacts on 12/4/23.
//
import Foundation
public struct InstanceV2: Decodable, Sendable {
public let domain: String
public let title: String
public let version: String
public let sourceURL: String
public let description: String
public let usage: Usage
public let thumbnail: Thumbnail
public let languages: [String]
public let configuration: Configuration
public let registrations: Registrations
public let contact: Contact
public let rules: [InstanceV1.Rule]
private enum CodingKeys: String, CodingKey {
case domain
case title
case version
case sourceURL = "source_url"
case description
case usage
case thumbnail
case languages
case configuration
case registrations
case contact
case rules
}
}
extension InstanceV2 {
public struct Usage: Decodable, Sendable {
public let users: Users
}
public struct Users: Decodable, Sendable {
public let activeMonth: Int
private enum CodingKeys: String, CodingKey {
case activeMonth = "active_month"
}
}
}
extension InstanceV2 {
public struct Thumbnail: Decodable, Sendable {
public let url: String
public let blurhash: String?
public let versions: ThumbnailVersions
}
public struct ThumbnailVersions: Decodable, Sendable {
public let oneX: String?
public let twoX: String?
private enum CodingKeys: String, CodingKey {
case oneX = "@1x"
case twoX = "@2x"
}
}
}
extension InstanceV2 {
public struct Configuration: Decodable, Sendable {
public let urls: URLs
public let accounts: Accounts
public let statuses: InstanceV1.StatusesConfiguration
public let mediaAttachments: InstanceV1.MediaAttachmentsConfiguration
public let polls: InstanceV1.PollsConfiguration
public let translation: Translation
private enum CodingKeys: String, CodingKey {
case urls
case accounts
case statuses
case mediaAttachments = "media_attachments"
case polls
case translation
}
}
public struct URLs: Decodable, Sendable {
// the docs incorrectly say the key for this is "streaming_api"
public let streaming: String
}
public struct Accounts: Decodable, Sendable {
public let maxFeaturedTags: Int
private enum CodingKeys: String, CodingKey {
case maxFeaturedTags = "max_featured_tags"
}
}
public struct Translation: Decodable, Sendable {
public let enabled: Bool
}
}
extension InstanceV2 {
public struct Registrations: Decodable, Sendable {
public let enabled: Bool
public let approvalRequired: Bool
public let message: String?
private enum CodingKeys: String, CodingKey {
case enabled
case approvalRequired = "approval_required"
case message
}
}
}
extension InstanceV2 {
public struct Contact: Decodable, Sendable {
public let email: String
public let account: Account
}
}

View File

@ -66,7 +66,8 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID)
self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID)
self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog)
self.content = try container.decode(String.self, forKey: .content)
// pixelfed statuses may have null content
self.content = try container.decodeIfPresent(String.self, forKey: .content) ?? ""
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount)
@ -176,6 +177,10 @@ public final class Status: StatusProtocol, Decodable, Sendable {
return Request(method: .get, path: "/api/v1/statuses/\(statusID)/history")
}
public static func translate(_ statusID: String) -> Request<Translation> {
return Request(method: .post, path: "/api/v1/statuses/\(statusID)/translate")
}
private enum CodingKeys: String, CodingKey {
case id
case uri

View File

@ -0,0 +1,22 @@
//
// Translation.swift
// Pachyderm
//
// Created by Shadowfacts on 12/4/23.
//
import Foundation
public struct Translation: Decodable, Sendable {
public let content: String
public let spoilerText: String?
public let detectedSourceLanguage: String
public let provider: String
private enum CodingKeys: String, CodingKey {
case content
case spoilerText
case detectedSourceLanguage = "detected_source_language"
case provider
}
}

View File

@ -26,13 +26,26 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
}
public func makeUIView(context: Context) -> UIButton {
let button = UIButton()
let button = UIButton(configuration: makeConfiguration())
button.showsMenuAsPrimaryAction = true
button.setContentHuggingPriority(.required, for: .horizontal)
return button
}
public func updateUIView(_ button: UIButton, context: Context) {
button.configuration = makeConfiguration()
button.menu = UIMenu(children: options.map { opt in
let action = UIAction(title: opt.title, subtitle: opt.subtitle, image: opt.image, state: opt.value == selection ? .on : .off) { _ in
selection = opt.value
}
action.accessibilityLabel = opt.accessibilityLabel
return action
})
button.accessibilityLabel = selectedOption.accessibilityLabel ?? selectedOption.title
button.isPointerInteractionEnabled = buttonStyle == .iconOnly
}
private func makeConfiguration() -> UIButton.Configuration {
var config = UIButton.Configuration.borderless()
if #available(iOS 16.0, *) {
config.indicator = .popup
@ -43,16 +56,10 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
if buttonStyle.hasLabel {
config.title = selectedOption.title
}
button.configuration = config
button.menu = UIMenu(children: options.map { opt in
let action = UIAction(title: opt.title, subtitle: opt.subtitle, image: opt.image, state: opt.value == selection ? .on : .off) { _ in
selection = opt.value
}
action.accessibilityLabel = opt.accessibilityLabel
return action
})
button.accessibilityLabel = selectedOption.accessibilityLabel ?? selectedOption.title
button.isPointerInteractionEnabled = buttonStyle == .iconOnly
#if targetEnvironment(macCatalyst)
config.macIdiomStyle = .bordered
#endif
return config
}
public struct Option {

View File

@ -62,6 +62,7 @@ public final class Preferences: Codable, ObservableObject {
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false
self.showAttachmentsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentsInTimeline) ?? true
if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) {
self.defaultPostVisibility = .visibility(existing)
@ -127,6 +128,7 @@ public final class Preferences: Codable, ObservableObject {
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode)
try container.encode(underlineTextLinks, forKey: .underlineTextLinks)
try container.encode(showAttachmentsInTimeline, forKey: .showAttachmentsInTimeline)
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
@ -182,6 +184,7 @@ public final class Preferences: Codable, ObservableObject {
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
@Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode
@Published public var underlineTextLinks = false
@Published public var showAttachmentsInTimeline = true
// MARK: Composing
@Published public var defaultPostVisibility = PostVisibility.serverDefault
@ -253,6 +256,7 @@ public final class Preferences: Codable, ObservableObject {
case trailingStatusSwipeActions
case widescreenNavigationMode
case underlineTextLinks
case showAttachmentsInTimeline
case defaultPostVisibility
case defaultReplyVisibility

View File

@ -16,6 +16,11 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable {
public private(set) var username: String!
public let accessToken: String
// Sort of hack to be able to access these from the share extension.
public internal(set) var serverDefaultLanguage: String?
public internal(set) var serverDefaultVisibility: String?
public internal(set) var serverDefaultFederation: Bool?
fileprivate static let tempAccountID = "temp"
static func id(instanceURL: URL, username: String?) -> String {
@ -47,21 +52,47 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable {
self.accessToken = accessToken
}
init?(userDefaultsDict dict: [String: String]) {
guard let id = dict["id"],
let instanceURL = dict["instanceURL"],
init?(userDefaultsDict dict: [String: Any]) {
guard let id = dict["id"] as? String,
let instanceURL = dict["instanceURL"] as? String,
let url = URL(string: instanceURL),
let clientID = dict["clientID"],
let secret = dict["clientSecret"],
let accessToken = dict["accessToken"] else {
let clientID = dict["clientID"] as? String,
let secret = dict["clientSecret"] as? String,
let accessToken = dict["accessToken"] as? String else {
return nil
}
self.id = id
self.instanceURL = url
self.clientID = clientID
self.clientSecret = secret
self.username = dict["username"]
self.username = dict["username"] as? String
self.accessToken = accessToken
self.serverDefaultLanguage = dict["serverDefaultLanguage"] as? String
self.serverDefaultVisibility = dict["serverDefaultVisibility"] as? String
self.serverDefaultFederation = dict["serverDefaultFederation"] as? Bool
}
var userDefaultsDict: [String: Any] {
var dict: [String: Any] = [
"id": id,
"instanceURL": instanceURL.absoluteString,
"clientID": clientID,
"clientSecret": clientSecret,
"accessToken": accessToken,
]
if let username {
dict["username"] = username
}
if let serverDefaultLanguage {
dict["serverDefaultLanguage"] = serverDefaultLanguage
}
if let serverDefaultVisibility {
dict["serverDefaultVisibility"] = serverDefaultVisibility
}
if let serverDefaultFederation {
dict["serverDefaultFederation"] = serverDefaultFederation
}
return dict
}
/// A filename-safe string for this account

View File

@ -46,19 +46,7 @@ public class UserAccountsManager: ObservableObject {
}
set {
objectWillChange.send()
let array = newValue.map { (info) -> [String: String] in
var res = [
"id": info.id,
"instanceURL": info.instanceURL.absoluteString,
"clientID": info.clientID,
"clientSecret": info.clientSecret,
"accessToken": info.accessToken
]
if let username = info.username {
res["username"] = username
}
return res
}
let array = newValue.map(\.userDefaultsDict)
defaults.set(array, forKey: accountsKey)
}
}
@ -146,6 +134,17 @@ public class UserAccountsManager: ObservableObject {
public func setMostRecentAccount(_ account: UserAccountInfo?) {
mostRecentAccountID = account?.id
}
public func updateServerPreferences(_ account: UserAccountInfo, defaultLanguage: String?, defaultVisibility: String?, defaultFederation: Bool?) {
guard let index = accounts.firstIndex(where: { $0.id == account.id }) else {
return
}
var account = account
account.serverDefaultLanguage = defaultLanguage
account.serverDefaultVisibility = defaultVisibility
account.serverDefaultFederation = defaultFederation
accounts[index] = account
}
}

View File

@ -28,7 +28,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
self.instanceFeatures = InstanceFeatures()
Task { @MainActor in
async let instance = try? await run(Client.getInstance()).0
async let instance = try? await run(Client.getInstanceV1()).0
async let nodeInfo: NodeInfo? = await withCheckedContinuation({ continuation in
self.client.nodeInfo { response in
switch response {
@ -40,7 +40,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
}
})
guard let instance = await instance else { return }
self.instanceFeatures.update(instance: InstanceInfo(instance: instance), nodeInfo: await nodeInfo)
self.instanceFeatures.update(instance: InstanceInfo(v1: instance), nodeInfo: await nodeInfo)
}
Task { @MainActor in

View File

@ -53,18 +53,14 @@ class ShareViewController: UIViewController {
private func createDraft(account: UserAccountInfo) async -> Draft {
async let (text, attachments) = getDraftConfigurationFromExtensionContext()
// TODO: I really don't like that there's a network request in the hot path here, but we don't have easy access to AccountPreferences :/
let serverPrefs = try? await Client(baseURL: account.instanceURL, accessToken: account.accessToken).run(Client.getPreferences()).0
let visibility = Preferences.shared.defaultPostVisibility.resolved(withServerDefault: serverPrefs?.postingDefaultVisibility)
let draft = DraftsPersistentContainer.shared.createDraft(
accountID: account.id,
text: await text,
contentWarning: "",
inReplyToID: nil,
visibility: visibility,
language: serverPrefs?.postingDefaultLanguage,
localOnly: !(serverPrefs?.postingDefaultFederation ?? true)
visibility: Preferences.shared.defaultPostVisibility.resolved(withServerDefault: account.serverDefaultVisibility.flatMap(Visibility.init(rawValue:))),
language: account.serverDefaultLanguage,
localOnly: !(account.serverDefaultFederation ?? true)
)
for attachment in await attachments {

View File

@ -32,7 +32,7 @@
D608470F2A245D1F00C17380 /* ActiveInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D608470E2A245D1F00C17380 /* ActiveInstance.swift */; };
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */; };
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */; };
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; };
D60BB3942B30076F00DAEA65 /* HTMLStreamer in Frameworks */ = {isa = PBXBuildFile; productRef = D60BB3932B30076F00DAEA65 /* HTMLStreamer */; };
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F232442372B005F8713 /* StatusMO.swift */; };
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F252442372B005F8713 /* AccountMO.swift */; };
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */; };
@ -303,7 +303,6 @@
D6D79F262A0C8D2700AB2315 /* FetchStatusSourceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */; };
D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F282A0D596B00AB2315 /* StatusEditHistoryViewController.swift */; };
D6D79F2B2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */; };
D6D79F2D2A0D61B400AB2315 /* StatusEditContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2C2A0D61B400AB2315 /* StatusEditContentTextView.swift */; };
D6D79F2F2A0D6A7F00AB2315 /* StatusEditPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */; };
D6D79F532A0FFE3200AB2315 /* ToggleableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F522A0FFE3200AB2315 /* ToggleableButton.swift */; };
D6D79F572A1160B800AB2315 /* AccountDisplayNameLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F562A1160B800AB2315 /* AccountDisplayNameLabel.swift */; };
@ -712,7 +711,6 @@
D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchStatusSourceService.swift; sourceTree = "<group>"; };
D6D79F282A0D596B00AB2315 /* StatusEditHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditHistoryViewController.swift; sourceTree = "<group>"; };
D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditCollectionViewCell.swift; sourceTree = "<group>"; };
D6D79F2C2A0D61B400AB2315 /* StatusEditContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditContentTextView.swift; sourceTree = "<group>"; };
D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditPollView.swift; sourceTree = "<group>"; };
D6D79F522A0FFE3200AB2315 /* ToggleableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleableButton.swift; sourceTree = "<group>"; };
D6D79F562A1160B800AB2315 /* AccountDisplayNameLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameLabel.swift; sourceTree = "<group>"; };
@ -786,10 +784,10 @@
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */,
D659F35E2953A212002D944A /* TTTKit in Frameworks */,
D6B0026E29B5248800C70BE2 /* UserAccounts in Frameworks */,
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */,
D60088EF2980D8B5005B4D00 /* StoreKit.framework in Frameworks */,
D6CA6ED229EF6091003EC5DF /* TuskerPreferences in Frameworks */,
D6552367289870790048A653 /* ScreenCorners in Frameworks */,
D60BB3942B30076F00DAEA65 /* HTMLStreamer in Frameworks */,
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
D63CC702290EC0B8000E19DE /* Sentry in Frameworks */,
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */,
@ -1574,7 +1572,6 @@
children = (
D6D79F282A0D596B00AB2315 /* StatusEditHistoryViewController.swift */,
D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */,
D6D79F2C2A0D61B400AB2315 /* StatusEditContentTextView.swift */,
D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */,
);
path = "Status Edit History";
@ -1699,7 +1696,6 @@
);
name = Tusker;
packageProductDependencies = (
D60CFFDA24A290BA00D00083 /* SwiftSoup */,
D6676CA427A8D0020052936B /* WebURLFoundationExtras */,
D674A50827F9128D00BA03AC /* Pachyderm */,
D6552366289870790048A653 /* ScreenCorners */,
@ -1711,6 +1707,7 @@
D635237029B78A7D009ED5E7 /* TuskerComponents */,
D6BD395829B64426005FFD2B /* ComposeUI */,
D6CA6ED129EF6091003EC5DF /* TuskerPreferences */,
D60BB3932B30076F00DAEA65 /* HTMLStreamer */,
);
productName = Tusker;
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
@ -1821,10 +1818,10 @@
);
mainGroup = D6D4DDC3212518A000E1C4BB;
packageReferences = (
D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */,
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */,
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */,
D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */,
);
productRefGroup = D6D4DDCD212518A000E1C4BB /* Products */;
projectDirPath = "";
@ -2182,7 +2179,6 @@
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
D68A76F129539116001DA1B3 /* FlipView.swift in Sources */,
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
D6D79F2D2A0D61B400AB2315 /* StatusEditContentTextView.swift in Sources */,
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */,
D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */,
@ -2455,6 +2451,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = TuskerUITests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2814,6 +2811,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = TuskerUITests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2833,6 +2831,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = TuskerUITests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2964,12 +2963,12 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/scinfu/SwiftSoup.git";
repositoryURL = "https://git.shadowfacts.net/shadowfacts/HTMLStreamer.git";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 2.3.2;
minimumVersion = 0.1.0;
};
};
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {
@ -2977,7 +2976,7 @@
repositoryURL = "https://github.com/getsentry/sentry-cocoa.git";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 8.0.0;
minimumVersion = 8.15.0;
};
};
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */ = {
@ -2999,10 +2998,10 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
D60CFFDA24A290BA00D00083 /* SwiftSoup */ = {
D60BB3932B30076F00DAEA65 /* HTMLStreamer */ = {
isa = XCSwiftPackageProductDependency;
package = D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
productName = SwiftSoup;
package = D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */;
productName = HTMLStreamer;
};
D61ABEFB28F105DE00B29151 /* Pachyderm */ = {
isa = XCSwiftPackageProductDependency;

View File

@ -35,8 +35,7 @@ class DeleteStatusService {
reblogIDs = reblogs.map(\.id)
}
NotificationCenter.default.post(name: .statusDeleted, object: nil, userInfo: [
"accountID": mastodonController.accountInfo!.id,
NotificationCenter.default.post(name: .statusDeleted, object: mastodonController, userInfo: [
"statusIDs": [status.id] + reblogIDs,
])
} catch {

View File

@ -36,11 +36,6 @@ class FetchStatusService {
}
private func handleStatusNotFound() {
// todo: what about when browsing on another instance?
guard let accountID = mastodonController.accountInfo?.id else {
return
}
var reblogIDs = [String]()
if let cached = mastodonController.persistentContainer.status(for: statusID) {
let reblogsReq = StatusMO.fetchRequest()
@ -50,8 +45,7 @@ class FetchStatusService {
}
}
NotificationCenter.default.post(name: .statusDeleted, object: nil, userInfo: [
"accountID": accountID,
NotificationCenter.default.post(name: .statusDeleted, object: mastodonController, userInfo: [
"statusIDs": [statusID] + reblogIDs
])
}

View File

@ -55,7 +55,8 @@ class MastodonController: ObservableObject {
let instanceFeatures = InstanceFeatures()
@Published private(set) var account: AccountMO?
@Published private(set) var instance: Instance?
@Published private(set) var instance: InstanceV1?
@Published private var instanceV2: InstanceV2?
@Published private(set) var instanceInfo: InstanceInfo!
@Published private(set) var nodeInfo: NodeInfo!
@Published private(set) var lists: [List] = []
@ -65,7 +66,7 @@ class MastodonController: ObservableObject {
private var cancellables = Set<AnyCancellable>()
private var pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]()
private var pendingOwnInstanceRequestCallbacks = [(Result<InstanceV1, Client.Error>) -> Void]()
private var ownInstanceRequest: URLSessionTask?
var loggedIn: Bool {
@ -111,9 +112,14 @@ class MastodonController: ObservableObject {
$instance
.compactMap { $0 }
.sink { [unowned self] in
self.updateActiveInstance(from: $0)
self.instanceInfo = InstanceInfo(instance: $0)
.combineLatest($instanceV2)
.sink {[unowned self] (instance, v2) in
var info = InstanceInfo(v1: instance)
if let v2 {
info.update(v2: v2)
}
self.instanceInfo = info
self.updateActiveInstance(from: info)
}
.store(in: &cancellables)
@ -221,6 +227,10 @@ class MastodonController: ObservableObject {
_ = try await (ownAccount, ownInstance)
if instanceFeatures.hasMastodonVersion(4, 0, 0) {
async let _ = try? getOwnInstanceV2()
}
loadLists()
_ = await loadFilters()
await loadServerPreferences()
@ -281,7 +291,7 @@ class MastodonController: ObservableObject {
}
}
func getOwnInstance(completion: ((Instance) -> Void)? = nil) {
func getOwnInstance(completion: ((InstanceV1) -> Void)? = nil) {
getOwnInstanceInternal(retryAttempt: 0) {
if case let .success(instance) = $0 {
completion?(instance)
@ -290,7 +300,7 @@ class MastodonController: ObservableObject {
}
@MainActor
func getOwnInstance() async throws -> Instance {
func getOwnInstance() async throws -> InstanceV1 {
return try await withCheckedThrowingContinuation({ continuation in
getOwnInstanceInternal(retryAttempt: 0) { result in
continuation.resume(with: result)
@ -298,7 +308,7 @@ class MastodonController: ObservableObject {
})
}
private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<Instance, Client.Error>) -> Void)?) {
private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<InstanceV1, Client.Error>) -> Void)?) {
// this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks
assert(Thread.isMainThread)
@ -310,7 +320,7 @@ class MastodonController: ObservableObject {
}
if ownInstanceRequest == nil {
let request = Client.getInstance()
let request = Client.getInstanceV1()
ownInstanceRequest = run(request) { (response) in
switch response {
case .failure(let error):
@ -365,6 +375,10 @@ class MastodonController: ObservableObject {
}
}
private func getOwnInstanceV2() async throws {
self.instanceV2 = try await client.run(Client.getInstanceV2()).0
}
// MainActor because the accountPreferences instance is bound to the view context
@MainActor
private func loadServerPreferences() async {
@ -375,15 +389,18 @@ class MastodonController: ObservableObject {
accountPreferences!.serverDefaultLanguage = prefs.postingDefaultLanguage
accountPreferences!.serverDefaultVisibility = prefs.postingDefaultVisibility
accountPreferences!.serverDefaultFederation = prefs.postingDefaultFederation ?? true
if let accountInfo {
UserAccountsManager.shared.updateServerPreferences(accountInfo, defaultLanguage: prefs.postingDefaultLanguage, defaultVisibility: prefs.postingDefaultVisibility.rawValue, defaultFederation: prefs.postingDefaultFederation)
}
}
private func updateActiveInstance(from instance: Instance) {
private func updateActiveInstance(from info: InstanceInfo) {
persistentContainer.performBackgroundTask { context in
if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first {
existing.update(from: instance)
existing.update(from: info)
} else {
let new = ActiveInstance(context: context)
new.update(from: instance)
new.update(from: info)
}
if context.hasChanges {
try? context.save()

View File

@ -8,7 +8,7 @@
import UIKit
import LinkPresentation
import SwiftSoup
import HTMLStreamer
class StatusActivityItemSource: NSObject, UIActivityItemSource {
let status: StatusMO
@ -33,8 +33,8 @@ class StatusActivityItemSource: NSObject, UIActivityItemSource {
let metadata = LPLinkMetadata()
metadata.originalURL = status.url!
metadata.url = status.url!
let doc = try! SwiftSoup.parse(status.content)
let content = try! doc.text()
var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self)
let content = converter.convert(html: status.content)
metadata.title = "\(status.account.displayName): \"\(content)\""
if let avatar = status.account.avatar,
let data = ImageCache.avatars.getData(avatar),

View File

@ -22,18 +22,20 @@ public final class ActiveInstance: NSManagedObject {
@NSManaged public var maxStatusCharacters: Int
@NSManaged private var configurationData: Data?
@NSManaged private var pollsConfigurationData: Data?
@NSManaged public var translation: Bool
@LazilyDecoding(from: \ActiveInstance.configurationData, fallback: nil)
public var configuration: Instance.Configuration?
public var configuration: InstanceV1.Configuration?
@LazilyDecoding(from: \ActiveInstance.pollsConfigurationData, fallback: nil)
public var pollsConfiguration: Instance.PollsConfiguration?
public var pollsConfiguration: InstanceV1.PollsConfiguration?
func update(from instance: Instance) {
self.version = instance.version
self.maxStatusCharacters = instance.maxStatusCharacters ?? 500
self.configuration = instance.configuration
self.pollsConfiguration = instance.pollsConfiguration
func update(from info: InstanceInfo) {
self.version = info.version
self.maxStatusCharacters = info.maxStatusCharacters ?? 500
self.configuration = info.configuration
self.pollsConfiguration = info.pollsConfiguration
self.translation = info.translation
}
}
@ -43,7 +45,8 @@ extension InstanceInfo {
version: activeInstance.version,
maxStatusCharacters: activeInstance.maxStatusCharacters,
configuration: activeInstance.configuration,
pollsConfiguration: activeInstance.pollsConfiguration
pollsConfiguration: activeInstance.pollsConfiguration,
translation: activeInstance.translation
)
}
}

View File

@ -54,6 +54,7 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
@NSManaged public var reblog: StatusMO?
@NSManaged public var localOnly: Bool
@NSManaged public var lastFetchedAt: Date?
@NSManaged public var language: String?
@LazilyDecoding(arrayFrom: \StatusMO.attachmentsData)
public var attachments: [Attachment]
@ -139,6 +140,7 @@ extension StatusMO {
self.visibility = status.visibility
self.poll = status.poll
self.localOnly = status.localOnly ?? false
self.language = status.language
if let existing = container.account(for: status.account.id, in: context) {
existing.updateFrom(apiAccount: status.account, container: container)

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22225" systemVersion="23A344" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23B92" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@ -41,6 +41,7 @@
<attribute name="configurationData" optional="YES" attributeType="Binary"/>
<attribute name="maxStatusCharacters" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="pollsConfigurationData" optional="YES" attributeType="Binary"/>
<attribute name="translation" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="version" optional="YES" attributeType="String"/>
</entity>
<entity name="Filter" representedClassName="FilterMO" syncable="YES">
@ -109,6 +110,7 @@
<attribute name="id" attributeType="String"/>
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="language" optional="YES" attributeType="String"/>
<attribute name="lastFetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="mentionsData" attributeType="Binary"/>
@ -123,7 +125,8 @@
<attribute name="url" optional="YES" attributeType="URI"/>
<attribute name="visibilityString" attributeType="String"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="reblogs" inverseEntity="Status"/>
<relationship name="reblogs" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="reblog" inverseEntity="Status"/>
<relationship name="timelines" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="TimelineState" inverseName="statuses" inverseEntity="TimelineState"/>
<uniquenessConstraints>
<uniquenessConstraint>

View File

@ -30,20 +30,31 @@ extension NSAttributedString {
extension NSMutableAttributedString {
func trimLeadingCharactersInSet(_ charSet: CharacterSet) {
var range = (string as NSString).rangeOfCharacter(from: charSet)
while range.length != 0 && range.location == 0 {
replaceCharacters(in: range, with: "")
range = (string as NSString).rangeOfCharacter(from: charSet)
var end = string.startIndex
while end < string.endIndex && charSet.contains(string.unicodeScalars[end]) {
end = string.unicodeScalars.index(after: end)
}
if end > string.startIndex {
let length = string.utf16.distance(from: string.startIndex, to: end)
replaceCharacters(in: NSRange(location: 0, length: length), with: "")
}
}
func trimTrailingCharactersInSet(_ charSet: CharacterSet) {
var range = (string as NSString).rangeOfCharacter(from: charSet, options: .backwards)
while range.length != 0 && range.length + range.location == length {
replaceCharacters(in: range, with: "")
range = (string as NSString).rangeOfCharacter(from: charSet, options: .backwards)
if string.isEmpty {
return
}
var start = string.index(before: string.endIndex)
while start > string.startIndex && charSet.contains(string.unicodeScalars[start]) {
start = string.unicodeScalars.index(before: start)
}
if start < string.endIndex {
if start != string.startIndex || !charSet.contains(string.unicodeScalars[start]) {
start = string.unicodeScalars.index(after: start)
}
let location = string.utf16.distance(from: string.startIndex, to: start)
let length = string.utf16.distance(from: start, to: string.endIndex)
replaceCharacters(in: NSRange(location: location, length: length), with: "")
}
}

View File

@ -42,7 +42,7 @@ class Filterer {
var filtersChanged: ((Bool) -> Void)?
var htmlConverter = HTMLConverter()
private var htmlConverter: HTMLConverter
private var hasSetup = false
private var matchers = [(NSRegularExpression, Result)]()
private var cancellables = Set<AnyCancellable>()
@ -55,9 +55,10 @@ class Filterer {
// are no longer valid, without needing to go through and update each of them
private var generation = 0
init(mastodonController: MastodonController, context: FilterV1.Context) {
init(mastodonController: MastodonController, context: FilterV1.Context, htmlConverter: HTMLConverter) {
self.mastodonController = mastodonController
self.context = context
self.htmlConverter = htmlConverter
self.hideReblogsInTimelines = Preferences.shared.hideReblogsInTimelines
self.hideRepliesInTimelines = Preferences.shared.hideRepliesInTimelines

View File

@ -7,11 +7,11 @@
//
import UIKit
import SwiftSoup
import HTMLStreamer
import WebURL
import WebURLFoundationExtras
struct HTMLConverter {
class HTMLConverter {
static let defaultFont = UIFont.systemFont(ofSize: 17)
static let defaultMonospaceFont = UIFont.monospacedSystemFont(ofSize: 17, weight: .regular)
@ -23,148 +23,47 @@ struct HTMLConverter {
return style
}()
var font: UIFont = defaultFont
var monospaceFont: UIFont = defaultMonospaceFont
var color: UIColor = defaultColor
var paragraphStyle: NSParagraphStyle = defaultParagraphStyle
private var converter: AttributedStringConverter<Callbacks>
init(font: UIFont, monospaceFont: UIFont, color: UIColor, paragraphStyle: NSParagraphStyle) {
let config = AttributedStringConverterConfiguration(font: font, monospaceFont: monospaceFont, color: color, paragraphStyle: paragraphStyle)
self.converter = AttributedStringConverter(configuration: config)
}
func convert(_ html: String) -> NSAttributedString {
let doc = try! SwiftSoup.parseBodyFragment(html)
let body = doc.body()!
if let attributedText = attributedTextForHTMLNode(body) {
let mutAttrString = NSMutableAttributedString(attributedString: attributedText)
mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines)
mutAttrString.collapseWhitespace()
// Wait until the end and then fill in the unset paragraph styles, to avoid clobbering the list style.
mutAttrString.enumerateAttribute(.paragraphStyle, in: mutAttrString.fullRange, options: .longestEffectiveRangeNotRequired) { value, range, stop in
if value == nil {
mutAttrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: range)
}
}
return mutAttrString
} else {
return NSAttributedString()
}
converter.convert(html: html)
}
private func attributedTextForHTMLNode(_ node: Node, usePreformattedText: Bool = false) -> NSAttributedString? {
switch node {
case let node as TextNode:
let text: String
if usePreformattedText {
text = node.getWholeText()
} else {
text = node.text()
}
return NSAttributedString(string: text, attributes: [.font: font, .foregroundColor: color])
case let node as Element:
if node.tagName() == "ol" || node.tagName() == "ul" {
return attributedTextForList(node, usePreformattedText: usePreformattedText)
}
let attributed = NSMutableAttributedString(string: "", attributes: [.font: font, .foregroundColor: color])
for child in node.getChildNodes() {
var appendEllipsis = false
if node.tagName() == "a",
let el = child as? Element {
if el.hasClass("invisible") {
continue
} else if el.hasClass("ellipsis") {
appendEllipsis = true
}
}
if let childText = attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText || node.tagName() == "pre") {
attributed.append(childText)
}
if appendEllipsis {
attributed.append(NSAttributedString(""))
}
}
switch node.tagName() {
case "br":
// need to specify defaultFont here b/c otherwise it uses the default 12pt Helvetica which
// screws up its determination of the line height making multiple lines of emojis squash together
attributed.append(NSAttributedString(string: "\n", attributes: [.font: font]))
case "a":
let href = try! node.attr("href")
if let webURL = WebURL(href),
let url = URL(webURL) {
attributed.addAttribute(.link, value: url, range: attributed.fullRange)
} else if let url = URL(string: href) {
attributed.addAttribute(.link, value: url, range: attributed.fullRange)
}
case "p":
attributed.append(NSAttributedString(string: "\n\n", attributes: [.font: font]))
case "em", "i":
let currentFont: UIFont
if attributed.length == 0 {
currentFont = font
} else {
currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? font
}
attributed.addAttribute(.font, value: currentFont.withTraits(.traitItalic)!, range: attributed.fullRange)
case "strong", "b":
let currentFont: UIFont
if attributed.length == 0 {
currentFont = font
} else {
currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? font
}
attributed.addAttribute(.font, value: currentFont.withTraits(.traitBold)!, range: attributed.fullRange)
case "del":
attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange)
case "code":
attributed.addAttribute(.font, value: monospaceFont, range: attributed.fullRange)
case "pre":
attributed.append(NSAttributedString(string: "\n\n"))
attributed.addAttribute(.font, value: monospaceFont, range: attributed.fullRange)
default:
break
}
return attributed
default:
return nil
}
}
private func attributedTextForList(_ element: Element, usePreformattedText: Bool) -> NSAttributedString {
let list = element.tagName() == "ol" ? OrderedNumberTextList(markerFormat: .decimal, options: 0) : NSTextList(markerFormat: .disc, options: 0)
let paragraphStyle = paragraphStyle.mutableCopy() as! NSMutableParagraphStyle
// I don't like that I can't just use paragraphStyle.textLists, because it makes the list markers
// not use the monospace digit font (it seems to just use whatever font attribute is set for the whole thing),
// and it doesn't right align the list markers.
// Unfortunately, doing it manually means the list markers are incldued in the selectable text.
paragraphStyle.headIndent = 32
paragraphStyle.firstLineHeadIndent = 0
// Use 2 tab stops, one for the list marker, the second for the content.
paragraphStyle.tabStops = [NSTextTab(textAlignment: .right, location: 28), NSTextTab(textAlignment: .natural, location: 32)]
let str = NSMutableAttributedString(string: "")
var item = 1
for child in element.children() where child.tagName() == "li" {
if let childStr = attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText) {
str.append(NSAttributedString(string: "\t\(list.marker(forItemNumber: item))\t", attributes: [
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .monospacedDigitSystemFont(ofSize: 17, weight: .regular)),
]))
str.append(childStr)
str.append(NSAttributedString(string: "\n"))
item += 1
}
}
str.addAttribute(.paragraphStyle, value: paragraphStyle, range: str.fullRange)
return str
}
}
private class OrderedNumberTextList: NSTextList {
override func marker(forItemNumber itemNumber: Int) -> String {
"\(super.marker(forItemNumber: itemNumber))."
extension HTMLConverter {
struct Callbacks: HTMLConversionCallbacks {
static func makeURL(string: String) -> URL? {
// 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 #available(iOS 16.0, macOS 13.0, *),
let url = try? URL.ParseStrategy().parse(string) {
url
} else if let web = WebURL(string),
let url = URL(web) {
url
} else {
URL(string: string)
}
}
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {
guard name == "span" else {
return .default
}
let clazz = attributes.attributeValue(for: "class")
if clazz == "invisible" {
return .skip
} else if clazz == "ellipsis" {
return .append("")
} else {
return .default
}
}
}
}

View File

@ -7,12 +7,13 @@
//
import Foundation
import CoreData
private let decoder = PropertyListDecoder()
private let encoder = PropertyListEncoder()
@propertyWrapper
public struct LazilyDecoding<Enclosing: NSObject, Value: Codable> {
public struct LazilyDecoding<Enclosing: NSManagedObject, Value: Codable> {
private let keyPath: ReferenceWritableKeyPath<Enclosing, Data?>
private let fallback: Value

View File

@ -150,7 +150,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
minDate.addTimeInterval(-7 * 24 * 60 * 60)
let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest()
statusReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate)
statusReq.predicate = NSPredicate(format: "((lastFetchedAt = nil) OR (lastFetchedAt < %@)) AND (reblogs.@count = 0)", minDate as NSDate)
let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq)
deleteStatusReq.resultType = .resultTypeCount
if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult {

View File

@ -26,11 +26,9 @@ struct ComposeReplyContentView: UIViewRepresentable {
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
view.adjustsFontForContentSizeCategory = true
view.defaultFont = TimelineStatusCollectionViewCell.contentFont
view.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
view.overrideMastodonController = mastodonController
view.setTextFrom(status: status)
view.attributedText = TimelineStatusCollectionViewCell.htmlConverter.convert(status.content)
return view
}

View File

@ -15,6 +15,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
private let mastodonController: MastodonController
private let mainStatusID: String
private let mainStatusState: CollapseState
private var mainStatusTranslation: Translation?
var statusIDToScrollToOnLoad: String
var showStatusesAutomatically = false
@ -88,11 +89,14 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil)
cell.setShowThreadLinks(prev: item.2, next: item.3)
}
let mainStatusCell = UICollectionView.CellRegistration<ConversationMainStatusCollectionViewCell, (String, CollapseState, Bool)> { [unowned self] cell, indexPath, item in
let mainStatusCell = UICollectionView.CellRegistration<ConversationMainStatusCollectionViewCell, (String, CollapseState, Translation?, Bool)> { [unowned self] cell, indexPath, item in
cell.delegate = self
cell.translateStatus = { [unowned self] in
self.translateMainStatus()
}
cell.showStatusAutomatically = self.showStatusesAutomatically
cell.updateUI(statusID: item.0, state: item.1)
cell.setShowThreadLinks(prev: item.2, next: false)
cell.updateUI(statusID: item.0, state: item.1, translation: item.2)
cell.setShowThreadLinks(prev: item.3, next: false)
}
let expandThreadCell = UICollectionView.CellRegistration<ExpandThreadCollectionViewCell, ([ConversationNode], Bool)> { cell, indexPath, item in
cell.updateUI(childThreads: item.0, inline: item.1)
@ -104,7 +108,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
switch itemIdentifier {
case let .status(id: id, node: _, state: state, prevLink: prevLink, nextLink: nextLink):
if id == self.mainStatusID {
return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, prevLink))
return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, self.mainStatusTranslation, prevLink))
} else {
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, prevLink, nextLink))
}
@ -260,6 +264,30 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
}
}
private func translateMainStatus() {
Task { @MainActor in
let translation: Translation
do {
translation = try await mastodonController.run(Status.translate(mainStatusID)).0
} catch {
let config = ToastConfiguration(from: error, with: "Error Translating", in: self) { toast in
toast.dismissToast(animated: true)
self.translateMainStatus()
}
self.showToast(configuration: config, animated: true)
return
}
mainStatusTranslation = translation
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems(snapshot.itemIdentifiers(inSection: .mainStatus))
await MainActor.run {
dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
}
extension ConversationCollectionViewController {

View File

@ -112,7 +112,7 @@ class ConversationViewController: UIViewController {
appearance.configureWithDefaultBackground()
navigationItem.scrollEdgeAppearance = appearance
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
}
private func updateVisibilityBarButtonItem() {
@ -145,8 +145,6 @@ class ConversationViewController: UIViewController {
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String],
case .localID(let mainStatusID) = mode else {
return

View File

@ -261,13 +261,16 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
}
private func deleteList(_ list: List, completion: @escaping (Bool) -> Void) {
Task { @MainActor in
Task {
let service = DeleteListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) })
if await service.run() {
var snapshot = dataSource.snapshot()
snapshot.deleteItems([.list(list)])
await dataSource.apply(snapshot)
completion(true)
await MainActor.run {
dataSource.apply(snapshot, animatingDifferences: true) {
completion(true)
}
}
} else {
completion(false)
}

View File

@ -34,8 +34,6 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
displayNameLabel.font = UIFontMetrics(forTextStyle: .title1).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
displayNameLabel.adjustsFontForContentSizeCategory = true
noteTextView.defaultFont = .preferredFont(forTextStyle: .body)
noteTextView.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular))
noteTextView.adjustsFontForContentSizeCategory = true
noteTextView.textContainer.lineBreakMode = .byTruncatingTail
noteTextView.textContainerInset = UIEdgeInsets(top: 16, left: 4, bottom: 16, right: 4)
@ -60,7 +58,7 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
displayNameLabel.updateForAccountDisplayName(account: account)
noteTextView.setTextFromHtml(account.note)
noteTextView.setBodyTextFromHTML(account.note)
noteTextView.setEmojis(account.emojis, identifier: account.id)
avatarImageView.image = nil

View File

@ -54,8 +54,6 @@ class SuggestedProfileCardCollectionViewCell: UICollectionViewCell {
usernameLabel.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 15, weight: .light))
usernameLabel.adjustsFontForContentSizeCategory = true
noteTextView.defaultFont = .preferredFont(forTextStyle: .body)
noteTextView.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular))
noteTextView.adjustsFontForContentSizeCategory = true
noteTextView.textContainer.lineBreakMode = .byTruncatingTail
@ -86,7 +84,7 @@ class SuggestedProfileCardCollectionViewCell: UICollectionViewCell {
avatarImageView.update(for: account.avatar)
headerImageView.update(for: account.header)
usernameLabel.text = "@\(account.acct)"
noteTextView.setTextFromHtml(account.note)
noteTextView.setBodyTextFromHTML(account.note)
var config = UIButton.Configuration.plain()
config.image = source.image

View File

@ -9,7 +9,7 @@
import UIKit
import Pachyderm
import WebURLFoundationExtras
import SwiftSoup
import HTMLStreamer
class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
@ -78,8 +78,8 @@ class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
let provider = card.providerName!.trimmingCharacters(in: .whitespacesAndNewlines)
providerLabel.text = provider
let description = try! SwiftSoup.parseBodyFragment(card.description).text()
descriptionLabel.text = description
var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self)
descriptionLabel.text = converter.convert(html: card.description)
descriptionLabel.isHidden = description.isEmpty
let sorted = card.history!.sorted(by: { $0.day < $1.day })

View File

@ -23,10 +23,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
self.filterer = Filterer(mastodonController: mastodonController, context: .public)
self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont
self.filterer.htmlConverter.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
self.filterer = Filterer(mastodonController: mastodonController, context: .public, htmlConverter: TimelineStatusCollectionViewCell.htmlConverter)
super.init(nibName: nil, bundle: nil)
@ -102,7 +99,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
}
override func viewWillAppear(_ animated: Bool) {
@ -146,8 +143,6 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}

View File

@ -172,7 +172,9 @@ class FastAccountSwitcherViewController: UIViewController {
#endif
hide() {
(self.view.window!.windowScene!.delegate as! MainSceneDelegate).showAddAccount()
if let sceneDelegate = self.view.window?.windowScene?.delegate as? MainSceneDelegate {
sceneDelegate.showAddAccount()
}
}
} else {
let account = UserAccountsManager.shared.accounts[newIndex - 1]
@ -186,7 +188,9 @@ class FastAccountSwitcherViewController: UIViewController {
#endif
hide() {
(self.view.window!.windowScene!.delegate as! MainSceneDelegate).activateAccount(account, animated: true)
if let sceneDelegate = self.view.window?.windowScene?.delegate as? MainSceneDelegate {
sceneDelegate.activateAccount(account, animated: true)
}
}
} else {
hide()

View File

@ -8,6 +8,7 @@
import UIKit
import UserAccounts
import WebURL
class FastSwitchingAccountView: UIView {
@ -126,7 +127,11 @@ class FastSwitchingAccountView: UIView {
private func setupAccount(account: UserAccountInfo) {
usernameLabel.text = account.username
instanceLabel.text = account.instanceURL.host!
if let domain = WebURL.Domain(account.instanceURL.host!) {
instanceLabel.text = domain.render(.uncheckedUnicodeString)
} else {
instanceLabel.text = account.instanceURL.host!
}
let controller = MastodonController.getForAccount(account)
controller.getOwnAccount { [weak self] (result) in
guard let self = self,
@ -140,7 +145,7 @@ class FastSwitchingAccountView: UIView {
}
}
accessibilityLabel = "\(account.username!)@\(account.instanceURL.host!)"
accessibilityLabel = "\(account.username!)@\(instanceLabel.text!)"
}
private func setupPlaceholder() {

View File

@ -107,7 +107,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh \(predicateTitle)"))
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: mastodonController.persistentContainer.viewContext)
}
@ -205,8 +205,6 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}

View File

@ -8,7 +8,7 @@
import UIKit
import Pachyderm
import SwiftSoup
import HTMLStreamer
class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
@ -161,9 +161,8 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
actionLabel.setEmojis(pairs: people.map { ($0.displayOrUserName, $0.emojis) }, identifier: group.id)
// todo: use htmlconverter
let doc = try! SwiftSoup.parseBodyFragment(status.content)
statusContentLabel.text = try! doc.text()
var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self)
statusContentLabel.text = converter.convert(html: status.content)
}
@objc private func updateUIForPreferences() {

View File

@ -23,6 +23,7 @@ class FollowRequestNotificationCollectionViewCell: UICollectionViewListCell {
$0.contentMode = .scaleAspectFill
$0.layer.masksToBounds = true
$0.layer.cornerCurve = .continuous
$0.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
NSLayoutConstraint.activate([
$0.widthAnchor.constraint(equalTo: $0.heightAnchor),
])

View File

@ -36,10 +36,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
self.allowedTypes = allowedTypes
self.mastodonController = mastodonController
self.filterer = Filterer(mastodonController: mastodonController, context: .notifications)
self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont
self.filterer.htmlConverter.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
self.filterer = Filterer(mastodonController: mastodonController, context: .notifications, htmlConverter: TimelineStatusCollectionViewCell.htmlConverter)
super.init(nibName: nil, bundle: nil)
@ -123,7 +120,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
self.reapplyFilters(actionsChanged: actionsChanged)
}
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
@ -259,8 +256,6 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}

View File

@ -8,7 +8,7 @@
import UIKit
import Pachyderm
import SwiftSoup
import HTMLStreamer
class PollFinishedNotificationCollectionViewCell: UICollectionViewListCell {
@ -124,9 +124,8 @@ class PollFinishedNotificationCollectionViewCell: UICollectionViewListCell {
updateTimestamp()
updateDisplayName(account: account)
// todo: use htmlconverter
let doc = try! SwiftSoup.parseBodyFragment(status.content)
contentLabel.text = try! doc.text()
var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self)
contentLabel.text = converter.convert(html: status.content)
pollView.mastodonController = mastodonController
pollView.delegate = delegate

View File

@ -8,7 +8,7 @@
import UIKit
import Pachyderm
import SwiftSoup
import HTMLStreamer
class StatusUpdatedNotificationCollectionViewCell: UICollectionViewListCell {
@ -120,9 +120,8 @@ class StatusUpdatedNotificationCollectionViewCell: UICollectionViewListCell {
updateTimestamp()
updateDisplayName(account: account)
// todo: use htmlconverter
let doc = try! SwiftSoup.parseBodyFragment(status.content)
contentLabel.text = try! doc.text()
var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self)
contentLabel.text = converter.convert(html: status.content)
}
@objc private func updateUIForPreferences() {

View File

@ -164,7 +164,7 @@ class InstanceSelectorTableViewController: UITableViewController {
}
let client = Client(baseURL: url, session: .appDefault)
let request = Client.getInstance()
let request = Client.getInstanceV1()
client.run(request) { (response) in
var snapshot = self.dataSource.snapshot()
if snapshot.indexOfSection(.selected) != nil {
@ -311,7 +311,7 @@ extension InstanceSelectorTableViewController {
case recommendedInstances
}
enum Item: Equatable, Hashable {
case selected(URL, Instance)
case selected(URL, InstanceV1)
case recommended(InstanceSelector.Instance)
static func ==(lhs: Item, rhs: Item) -> Bool {

View File

@ -60,31 +60,6 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
^[[SwiftSoup](https://github.com/scinfu/swiftsoup)](headingLevel: 2)
Copyright (c) 2016 Nabil Chatbi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Symbols
Symbol outline not available for this file
To inspect a symbol, try clicking on the symbol directly in the code view.
Code navigation supports a limited number of languages. See which languages are supported.
^[[swift-url](https://github.com/karwa/swift-url)](headingLevel: 2)
Apache License

View File

@ -121,12 +121,15 @@ struct AppearancePrefsView : View {
Toggle(isOn: $preferences.alwaysShowStatusVisibilityIcon) {
Text("Always Show Status Visibility Icons")
}
Toggle(isOn: $preferences.hideActionsInTimeline) {
Text("Hide Actions on Timeline")
}
Toggle(isOn: $preferences.showLinkPreviews) {
Text("Show Link Previews")
}
Toggle(isOn: $preferences.showAttachmentsInTimeline) {
Text("Show Attachments on Timeline")
}
Toggle(isOn: $preferences.hideActionsInTimeline) {
Text("Hide Actions on Timeline")
}
Toggle(isOn: $preferences.underlineTextLinks) {
Text("Underline Links")
}

View File

@ -7,6 +7,7 @@
import SwiftUI
import UserAccounts
import WebURL
struct PreferencesView: View {
let mastodonController: MastodonController
@ -41,7 +42,12 @@ struct PreferencesView: View {
VStack(alignment: .leading) {
Text(verbatim: account.username)
.foregroundColor(.primary)
Text(verbatim: account.instanceURL.host!)
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

@ -41,10 +41,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
self.kind = kind
self.owner = owner
self.mastodonController = owner.mastodonController
self.filterer = Filterer(mastodonController: mastodonController, context: .account)
self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont
self.filterer.htmlConverter.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
self.filterer = Filterer(mastodonController: mastodonController, context: .account, htmlConverter: TimelineStatusCollectionViewCell.htmlConverter)
super.init(nibName: nil, bundle: nil)
@ -67,18 +64,25 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
}
config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in
guard let item = self.dataSource.itemIdentifier(for: indexPath) else {
guard let item = self.dataSource.itemIdentifier(for: indexPath),
let section = self.dataSource.sectionIdentifier(for: indexPath.section) else {
return sectionSeparatorConfiguration
}
var config = sectionSeparatorConfiguration
if item.hideSeparators {
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
} else if section == .header {
config.topSeparatorVisibility = .hidden
config.bottomSeparatorInsets = .zero
} else if indexPath.row == 0 && (section == .pinned || section == .entries) {
// TODO: row == 0 isn't technically right, the top post could be filtered out
config.topSeparatorInsets = .zero
} else if case .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item,
filterer.isKnownHide(state: filterState) {
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
} else if case .status(_, _, _, _) = item {
} else {
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
}
@ -88,6 +92,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
if case .header = dataSource.sectionIdentifier(for: sectionIndex) {
var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.backgroundColor = .appBackground
config.separatorConfiguration.bottomSeparatorInsets = .zero
return .list(using: config, layoutEnvironment: environment)
} else {
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
@ -148,7 +153,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
self.reapplyFilters(actionsChanged: actionsChanged)
}
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
@ -376,8 +381,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}

View File

@ -7,9 +7,13 @@
//
import SwiftUI
import SwiftSoup
private var converter = HTMLConverter()
private var converter = HTMLConverter(
font: .preferredFont(forTextStyle: .body),
monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
color: .label,
paragraphStyle: .default
)
struct ReportStatusView: View {
let status: StatusMO

View File

@ -65,12 +65,13 @@ class MastodonSearchController: UISearchController {
searchText.isEmpty || $0.contains(searchText)
}))
// TODO: use default language from preferences
var langSuggestions = [String]()
if searchText.isEmpty || "language:en".contains(searchText) {
langSuggestions.append("language:en")
let defaultLanguage = searchResultsController.mastodonController.accountPreferences.serverDefaultLanguage ?? "en"
let languageToken = "language:\(defaultLanguage)"
if searchText.isEmpty || languageToken.contains(searchText) {
langSuggestions.append(languageToken)
}
if searchText != "en",
if searchText != defaultLanguage,
let match = languageRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.utf16.count)) {
let identifier = (searchText as NSString).substring(with: match.range(at: 1))
if #available(iOS 16.0, *) {
@ -87,7 +88,7 @@ class MastodonSearchController: UISearchController {
if searchText.isEmpty || "from:me".contains(searchText) {
fromSuggestions.append("from:me")
}
if searchText != "me",
if searchText != "me" && searchText != "from:me",
let match = acctRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.utf16.count)) {
let matched = (searchText as NSString).substring(with: match.range)
fromSuggestions.append("from:\(matched)")

View File

@ -46,6 +46,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
private let searchSubject = PassthroughSubject<String?, Never>()
private var searchCancellable: AnyCancellable?
private var currentQuery: String?
private var currentSearchResults: SearchResults?
init(mastodonController: MastodonController, scope: Scope = .all) {
self.mastodonController = mastodonController
@ -122,7 +123,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
userActivity = UserActivityManager.searchActivity(query: nil, accountID: mastodonController.accountInfo!.id)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
@ -262,7 +263,14 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
switch response {
case let .success(results, _):
guard self.currentQuery == query else { return }
self.showSearchResults(results)
self.mastodonController.persistentContainer.performBatchUpdates { (context, addAccounts, addStatuses) in
addAccounts(results.accounts)
addStatuses(results.statuses)
} completion: {
DispatchQueue.main.async {
self.showSearchResults(results)
}
}
case let .failure(error):
DispatchQueue.main.async {
self.showSearchError(error)
@ -271,31 +279,29 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
}
}
@MainActor
private func showSearchResults(_ results: SearchResults) {
self.currentSearchResults = results
var snapshot = dataSource.snapshot()
snapshot.deleteSections([.loadingIndicator])
removeResults(from: &snapshot)
self.mastodonController.persistentContainer.performBatchUpdates({ (context, addAccounts, addStatuses) in
let resultTypes = self.scope.resultTypes
if !results.accounts.isEmpty && resultTypes.contains(.accounts) {
snapshot.appendSections([.accounts])
snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts)
addAccounts(results.accounts)
}
if !results.hashtags.isEmpty && resultTypes.contains(.hashtags) {
snapshot.appendSections([.hashtags])
snapshot.appendItems(results.hashtags.map { .hashtag($0) }, toSection: .hashtags)
}
if !results.statuses.isEmpty && resultTypes.contains(.statuses) {
snapshot.appendSections([.statuses])
snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses)
addStatuses(results.statuses)
}
}, completion: {
DispatchQueue.main.async {
self.dataSource.apply(snapshot)
}
})
let resultTypes = self.scope.resultTypes
if !results.accounts.isEmpty && resultTypes.contains(.accounts) {
snapshot.appendSections([.accounts])
snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts)
}
if !results.hashtags.isEmpty && resultTypes.contains(.hashtags) {
snapshot.appendSections([.hashtags])
snapshot.appendItems(results.hashtags.map { .hashtag($0) }, toSection: .hashtags)
}
if !results.statuses.isEmpty && resultTypes.contains(.statuses) {
snapshot.appendSections([.statuses])
snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses)
}
dataSource.apply(snapshot)
}
private func showSearchError(_ error: Client.Error) {
@ -311,8 +317,6 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}
@ -567,19 +571,25 @@ extension SearchResultsViewController: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
let newQuery = searchBar.searchQueryWithOperators
let newScope = Scope.allCases[selectedScope]
if self.scope == .all && currentQuery == newQuery {
self.scope = newScope
var snapshot = dataSource.snapshot()
if snapshot.sectionIdentifiers.contains(.accounts) && scope != .people {
snapshot.deleteSections([.accounts])
if currentQuery == newQuery,
let currentSearchResults {
if self.scope == .all {
self.scope = newScope
var snapshot = dataSource.snapshot()
if snapshot.sectionIdentifiers.contains(.accounts) && scope != .people {
snapshot.deleteSections([.accounts])
}
if snapshot.sectionIdentifiers.contains(.hashtags) && scope != .hashtags {
snapshot.deleteSections([.hashtags])
}
if snapshot.sectionIdentifiers.contains(.statuses) && scope != .posts {
snapshot.deleteSections([.statuses])
}
dataSource.apply(snapshot)
} else {
self.scope = newScope
showSearchResults(currentSearchResults)
}
if snapshot.sectionIdentifiers.contains(.hashtags) && scope != .hashtags {
snapshot.deleteSections([.hashtags])
}
if snapshot.sectionIdentifiers.contains(.statuses) && scope != .posts {
snapshot.deleteSections([.statuses])
}
dataSource.apply(snapshot)
} else {
self.scope = newScope
performSearch(query: newQuery)

View File

@ -84,7 +84,7 @@ class StatusActionAccountListViewController: UIViewController {
view.backgroundColor = .appBackground
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
}
override func viewWillAppear(_ animated: Bool) {
@ -99,8 +99,6 @@ class StatusActionAccountListViewController: UIViewController {
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}

View File

@ -61,13 +61,31 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
}
private let contentContainer = StatusContentContainer<StatusEditContentTextView, StatusEditPollView>(useTopSpacer: false).configure {
$0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont
$0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
$0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
private lazy var contentContainer = StatusContentContainer(arrangedSubviews: [
contentTextView,
cardView,
attachmentsView,
pollView,
] as! [any StatusContentView], useTopSpacer: false).configure {
$0.setContentHuggingPriority(.defaultLow, for: .vertical)
}
private let contentTextView = ContentTextView().configure {
$0.adjustsFontForContentSizeCategory = true
$0.isScrollEnabled = false
$0.backgroundColor = nil
$0.isEditable = false
$0.isSelectable = false
}
private let cardView = StatusCardView().configure {
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
}
private let attachmentsView = AttachmentsContainerView()
private let pollView = StatusEditPollView()
weak var delegate: StatusEditCollectionViewCellDelegate?
private var mastodonController: MastodonController! { delegate?.apiController }
@ -91,8 +109,76 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
fatalError("init(coder:) has not been implemented")
}
// todo: accessibility
// MARK: Accessibility
override var isAccessibilityElement: Bool {
get { true }
set {}
}
override var accessibilityAttributedLabel: NSAttributedString? {
get {
var str: AttributedString = ""
if statusState.collapsed ?? false {
if !edit.spoilerText.isEmpty {
str += AttributedString(edit.spoilerText)
str += ", "
}
str += "collapsed"
} else {
str += AttributedString(contentTextView.attributedText)
if edit.attachments.count > 0 {
let includeDescriptions: Bool
switch Preferences.shared.attachmentBlurMode {
case .useStatusSetting:
includeDescriptions = !Preferences.shared.blurMediaBehindContentWarning || edit.spoilerText.isEmpty
case .always:
includeDescriptions = true
case .never:
includeDescriptions = false
}
if includeDescriptions {
if edit.attachments.count == 1 {
let attachment = edit.attachments[0]
let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
str += AttributedString(", attachment: \(desc)")
} else {
for (index, attachment) in edit.attachments.enumerated() {
let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
str += AttributedString(", attachment \(index + 1): \(desc)")
}
}
} else {
str += AttributedString(", \(edit.attachments.count) attachment\(edit.attachments.count == 1 ? "" : "s")")
}
}
if edit.poll != nil {
str += ", poll"
}
}
return NSAttributedString(str)
}
set {}
}
override var accessibilityHint: String? {
get {
if statusState.collapsed ?? false {
return "Double tap to expand the post."
} else {
return nil
}
}
set {}
}
override func accessibilityActivate() -> Bool {
if statusState.collapsed ?? false {
collapseButtonPressed()
}
return true
}
// MARK: Configure UI
@ -102,13 +188,14 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
timestampLabel.text = ConversationMainStatusCollectionViewCell.dateFormatter.string(from: edit.createdAt)
contentContainer.contentTextView.setTextFrom(edit: edit, index: index)
contentContainer.contentTextView.navigationDelegate = delegate
contentContainer.attachmentsView.delegate = self
contentContainer.attachmentsView.updateUI(attachments: edit.attachments)
contentContainer.pollView.isHidden = edit.poll == nil
contentContainer.pollView.updateUI(poll: edit.poll, emojis: edit.emojis)
contentContainer.cardView.isHidden = true
contentTextView.attributedText = TimelineStatusCollectionViewCell.htmlConverter.convert(edit.content)
contentTextView.setEmojis(edit.emojis, identifier: index)
contentTextView.navigationDelegate = delegate
attachmentsView.delegate = self
attachmentsView.updateUI(attachments: edit.attachments)
pollView.isHidden = edit.poll == nil
pollView.updateUI(poll: edit.poll, emojis: edit.emojis)
cardView.isHidden = true
contentWarningLabel.text = edit.spoilerText
contentWarningLabel.isHidden = edit.spoilerText.isEmpty
@ -151,9 +238,9 @@ extension StatusEditCollectionViewCell: AttachmentViewDelegate {
guard let delegate else {
return nil
}
let attachments = contentContainer.attachmentsView.attachments!
let attachments = attachmentsView.attachments!
let sourceViews = attachments.map {
contentContainer.attachmentsView.getAttachmentView(for: $0)
attachmentsView.getAttachmentView(for: $0)
}
let gallery = delegate.gallery(attachments: attachments, sourceViews: sourceViews, startIndex: index)
return gallery

View File

@ -1,22 +0,0 @@
//
// StatusEditContentTextView.swift
// Tusker
//
// Created by Shadowfacts on 5/11/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import WebURL
class StatusEditContentTextView: ContentTextView {
func setTextFrom(edit: StatusEdit, index: Int) {
setTextFromHtml(edit.content)
setEmojis(edit.emojis, identifier: index)
}
// mention links aren't included in the edit content, nothing else to do
}

View File

@ -9,7 +9,7 @@
import UIKit
import Pachyderm
class StatusEditPollView: UIStackView, StatusContentPollView {
class StatusEditPollView: UIStackView, StatusContentView {
private var titleLabels: [EmojiLabel] = []

View File

@ -32,7 +32,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
let timeline: Timeline
weak var mastodonController: MastodonController!
let filterer: Filterer
private let filterer: Filterer
var persistsState = false
@ -61,10 +61,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
default:
filterContext = .public
}
self.filterer = Filterer(mastodonController: mastodonController, context: filterContext)
self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont
self.filterer.htmlConverter.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
self.filterer = Filterer(mastodonController: mastodonController, context: filterContext, htmlConverter: TimelineStatusCollectionViewCell.htmlConverter)
super.init(nibName: nil, bundle: nil)
@ -164,7 +161,19 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
}
.store(in: &cancellables)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
Preferences.shared.$showAttachmentsInTimeline
// skip the initial value
.dropFirst()
// publisher fires on willChange, wait the change is actually made
.receive(on: DispatchQueue.main)
.sink { [unowned self] _ in
var snapshot = self.dataSource.snapshot()
snapshot.reconfigureItems(snapshot.itemIdentifiers)
self.dataSource.apply(snapshot, animatingDifferences: false)
}
.store(in: &cancellables)
if userActivity != nil {
userActivityNeedsUpdate
@ -182,6 +191,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
// separate method because InstanceTimelineViewController needs to be able to customize it
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) {
cell.delegate = self
cell.showAttachmentsInline = Preferences.shared.showAttachmentsInTimeline
if case .home = timeline {
cell.showFollowedHashtags = true
} else {
@ -376,7 +386,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
_ = try await mastodonController.run(req)
} catch {
stateRestorationLogger.error("TimelineViewController: failed to update timeline marker: \(String(describing: error))")
#if canImport(Sentry)
if let error = error as? Client.Error,
case .networkError(_) = error.type {
return
}
let event = Event(error: error)
event.message = SentryMessage(formatted: "Failed to update timeline marker: \(String(describing: error))")
SentrySDK.capture(event: event)
@ -411,7 +426,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
let hasStatusesToRestore = await loadStatusesToRestore(position: position)
if hasStatusesToRestore {
applyItemsToRestore(position: position)
await applyItemsToRestore(position: position)
loaded = true
}
case .mastodon:
@ -436,7 +451,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
position.centerStatusID = centerStatusID
let hasStatusesToRestore = await loadStatusesToRestore(position: position)
if hasStatusesToRestore {
applyItemsToRestore(position: position)
await applyItemsToRestore(position: position)
}
mastodonController.persistentContainer.viewContext.delete(position)
}
@ -451,6 +466,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
if let status = mastodonController.persistentContainer.status(for: id) {
// touch the status so that, even if it's old, it doesn't get pruned when we go into the background
status.touch()
// there was a bug where th the reblogged status would get pruned even when it was still refernced by the reblog
// as a temporary workaround, until there are no longer user db's in this state,
// check if the reblog is invalid and reload the status if so
if let reblog = status.reblog,
// force the fault to fire
case _ = reblog.id,
reblog.isDeleted {
unloaded.append(id)
}
} else {
unloaded.append(id)
}
@ -519,7 +544,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
@MainActor
private func applyItemsToRestore(position: TimelinePosition) {
private func applyItemsToRestore(position: TimelinePosition) async {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses])
let statusIDs = position.statusIDs
@ -534,14 +559,13 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
]
SentrySDK.addBreadcrumb(crumb)
#endif
dataSource.apply(snapshot, animatingDifferences: false) {
if let centerStatusID,
let index = statusIDs.firstIndex(of: centerStatusID) {
self.scrollToItem(item: items[index])
stateRestorationLogger.info("TimelineViewController: restored statuses with center ID \(centerStatusID)")
} else {
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
}
await apply(snapshot, animatingDifferences: false)
if let centerStatusID,
let index = statusIDs.firstIndex(of: centerStatusID) {
self.scrollToItem(item: items[index])
stateRestorationLogger.info("TimelineViewController: restored statuses with center ID \(centerStatusID)")
} else {
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
}
}
@ -570,7 +594,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return true
} catch {
stateRestorationLogger.error("TimelineViewController: failed to load from timeline marker: \(String(describing: error))")
#if canImport(Sentry)
if let error = error as? Client.Error,
case .networkError(_) = error.type {
return false
}
let event = Event(error: error)
event.message = SentryMessage(formatted: "Failed to load from timeline marker: \(String(describing: error))")
SentrySDK.capture(event: event)
@ -959,8 +988,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}

View File

@ -7,7 +7,7 @@
//
import UIKit
import SwiftSoup
import HTMLStreamer
class AccountCollectionViewCell: UICollectionViewListCell {
@ -134,8 +134,8 @@ class AccountCollectionViewCell: UICollectionViewListCell {
displayNameLabel.setEmojis(account.emojis, identifier: account.id)
}
let doc = try! SwiftSoup.parseBodyFragment(account.note)
noteLabel.text = try! doc.text()
var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self)
noteLabel.text = converter.convert(html: account.note)
noteLabel.setEmojis(account.emojis, identifier: account.id)
}

View File

@ -30,8 +30,8 @@ class AttachmentsContainerView: UIView {
}
}
var blurView: UIVisualEffectView?
var hideButtonView: UIVisualEffectView?
private var blurView: UIVisualEffectView?
private var hideButtonView: UIVisualEffectView?
var contentHidden: Bool! {
didSet {
guard let blurView = blurView,
@ -42,6 +42,8 @@ class AttachmentsContainerView: UIView {
}
}
private var label: UILabel?
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
@ -67,21 +69,26 @@ class AttachmentsContainerView: UIView {
// MARK: - User Interaface
func updateUI(attachments: [Attachment]) {
func updateUI(attachments: [Attachment], labelOnly: Bool = false) {
let newTokens = attachments.map { AttachmentToken(attachment: $0) }
guard !labelOnly else {
self.attachments = attachments
self.attachmentTokens = newTokens
updateLabel(attachments: attachments)
return
}
guard self.attachmentTokens != newTokens else {
self.isHidden = attachments.isEmpty
return
}
self.attachments = attachments
self.attachmentTokens = newTokens
attachmentViews.allObjects.forEach { $0.removeFromSuperview() }
attachmentViews.removeAllObjects()
attachmentStacks.allObjects.forEach { $0.removeFromSuperview() }
attachmentStacks.removeAllObjects()
moreView?.removeFromSuperview()
removeAttachmentViews()
hideButtonView?.isHidden = false
var accessibilityElements = [Any]()
@ -284,6 +291,14 @@ class AttachmentsContainerView: UIView {
self.accessibilityElements = accessibilityElements
}
private func removeAttachmentViews() {
attachmentViews.allObjects.forEach { $0.removeFromSuperview() }
attachmentViews.removeAllObjects()
attachmentStacks.allObjects.forEach { $0.removeFromSuperview() }
attachmentStacks.removeAllObjects()
moreView?.removeFromSuperview()
}
private func createAttachmentView(index: Int, hSize: RelativeSize, vSize: RelativeSize) -> AttachmentView {
let attachmentView = AttachmentView(attachment: attachments[index], index: index)
attachmentView.delegate = delegate
@ -396,6 +411,35 @@ class AttachmentsContainerView: UIView {
])
}
private func updateLabel(attachments: [Attachment]) {
removeAttachmentViews()
blurView?.isHidden = true
hideButtonView?.isHidden = true
aspectRatioConstraint?.isActive = false
if label == nil {
if attachments.isEmpty {
accessibilityElements = []
return
}
label = UILabel()
label!.font = .preferredFont(forTextStyle: .body)
label!.adjustsFontForContentSizeCategory = true
label!.textColor = .secondaryLabel
label!.translatesAutoresizingMaskIntoConstraints = false
addSubview(label!)
NSLayoutConstraint.activate([
label!.leadingAnchor.constraint(equalTo: leadingAnchor),
label!.trailingAnchor.constraint(equalTo: trailingAnchor),
label!.topAnchor.constraint(equalTo: topAnchor),
label!.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
label!.text = "\(attachments.count) attachment\(attachments.count == 1 ? "" : "s")"
accessibilityElements = [label!]
}
// MARK: - Interaction
@objc func blurViewTapped() {

View File

@ -10,6 +10,13 @@ import UIKit
class ConfirmReblogStatusPreviewView: UIView {
private static let htmlConverter = HTMLConverter(
font: .preferredFont(forTextStyle: .caption2),
monospaceFont: UIFontMetrics(forTextStyle: .caption2).scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
color: .label,
paragraphStyle: .default
)
private var avatarTask: Task<Void, Error>?
init(status: StatusMO) {
@ -60,17 +67,13 @@ class ConfirmReblogStatusPreviewView: UIView {
vStack.addArrangedSubview(displayNameLabel)
let contentView = StatusContentTextView()
contentView.defaultFont = .preferredFont(forTextStyle: .caption2)
contentView.monospaceFont = UIFontMetrics(forTextStyle: .caption2).scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular))
contentView.isUserInteractionEnabled = false
contentView.isScrollEnabled = false
contentView.backgroundColor = nil
contentView.textContainerInset = .zero
contentView.adjustsFontForContentSizeCategory = true
// remove the extra line spacing applied by StatusContentTextView because, since we're using a smaller font, the regular 2pt looks big
contentView.paragraphStyle = .default
// TODO: line limit
contentView.setTextFrom(status: status)
contentView.setTextFrom(status: status, content: ConfirmReblogStatusPreviewView.htmlConverter.convert(status.content))
contentView.translatesAutoresizingMaskIntoConstraints = false
vStack.addArrangedSubview(contentView)
NSLayoutConstraint.activate([

View File

@ -7,7 +7,6 @@
//
import UIKit
import SwiftSoup
import Pachyderm
import SafariServices
import WebURL
@ -22,31 +21,20 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
weak var navigationDelegate: TuskerNavigationDelegate?
weak var overrideMastodonController: MastodonController?
var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController }
private var htmlConverter = HTMLConverter()
var defaultFont: UIFont {
_read { yield htmlConverter.font }
_modify { yield &htmlConverter.font }
}
var monospaceFont: UIFont {
_read { yield htmlConverter.monospaceFont }
_modify { yield &htmlConverter.monospaceFont }
}
var defaultColor: UIColor {
_read { yield htmlConverter.color }
_modify { yield &htmlConverter.color }
}
var paragraphStyle: NSParagraphStyle {
_read { yield htmlConverter.paragraphStyle }
_modify { yield &htmlConverter.paragraphStyle }
}
private static let defaultBodyHTMLConverter = HTMLConverter(
font: .preferredFont(forTextStyle: .body),
monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
color: .label,
paragraphStyle: .default
)
private(set) var hasEmojis = false
var emojiIdentifier: AnyHashable?
var emojiRequests: [ImageCache.Request] = []
var emojiFont: UIFont { defaultFont }
var emojiTextColor: UIColor { defaultColor }
var emojiFont: UIFont = .preferredFont(forTextStyle: .body)
var emojiTextColor: UIColor = .label
// The link range currently being previewed
private var currentPreviewedLinkRange: NSRange?
@ -126,8 +114,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
}
// MARK: - HTML Parsing
func setTextFromHtml(_ html: String) {
self.attributedText = htmlConverter.convert(html)
func setBodyTextFromHTML(_ html: String) {
self.attributedText = ContentTextView.defaultBodyHTMLConverter.convert(html)
}
// MARK: - Interaction
@ -213,10 +201,12 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
func getViewController(forLink url: URL, inRange range: NSRange) -> UIViewController? {
let text = (self.text as NSString).substring(with: range)
if let mention = getMention(for: url, text: text) {
return ProfileViewController(accountID: mention.id, mastodonController: mastodonController!)
} else if let tag = getHashtag(for: url, text: text) {
return HashtagTimelineViewController(for: tag, mastodonController: mastodonController!)
if let mention = getMention(for: url, text: text),
let mastodonController {
return ProfileViewController(accountID: mention.id, mastodonController: mastodonController)
} else if let tag = getHashtag(for: url, text: text),
let mastodonController {
return HashtagTimelineViewController(for: tag, mastodonController: mastodonController)
} else if url.scheme == "https" || url.scheme == "http" {
let vc = SFSafariViewController(url: url)
#if !os(visionOS)

View File

@ -67,7 +67,8 @@ class TrendingHashtagCollectionViewCell: UICollectionViewCell {
historyView.setHistory(hashtag.history)
historyView.isHidden = hashtag.history == nil || hashtag.history!.count < 2
if let history = hashtag.history {
if let history = hashtag.history,
history.count >= 2 {
let sorted = history.sorted(by: { $0.day < $1.day })
let lastTwo = sorted[(sorted.count - 2)...]
let accounts = lastTwo.map(\.accounts).reduce(0, +)

View File

@ -16,7 +16,7 @@ class InstanceTableViewCell: UITableViewCell {
@IBOutlet weak var adultLabel: UILabel!
@IBOutlet weak var descriptionTextView: ContentTextView!
var instance: Instance?
var instance: InstanceV1?
var selectorInstance: InstanceSelector.Instance?
var thumbnailURL: URL?
@ -34,8 +34,6 @@ class InstanceTableViewCell: UITableViewCell {
adultLabel.layer.masksToBounds = true
adultLabel.layer.cornerRadius = 0.5 * adultLabel.bounds.height
descriptionTextView.defaultFont = .preferredFont(forTextStyle: .body)
descriptionTextView.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular))
descriptionTextView.adjustsFontForContentSizeCategory = true
}
@ -49,17 +47,17 @@ class InstanceTableViewCell: UITableViewCell {
domainLabel.text = instance.domain
adultLabel.isHidden = instance.category != "adult"
descriptionTextView.setTextFromHtml(instance.description)
descriptionTextView.setBodyTextFromHTML(instance.description)
updateThumbnail(url: instance.proxiedThumbnailURL)
}
func updateUI(instance: Instance) {
func updateUI(instance: InstanceV1) {
self.instance = instance
self.selectorInstance = nil
domainLabel.text = URLComponents(string: instance.uri)?.host ?? instance.uri
adultLabel.isHidden = true
descriptionTextView.setTextFromHtml(instance.shortDescription ?? instance.description)
descriptionTextView.setBodyTextFromHTML(instance.shortDescription ?? instance.description)
if let thumbnail = instance.thumbnail {
updateThumbnail(url: thumbnail)

View File

@ -9,7 +9,7 @@
import UIKit
import Pachyderm
class StatusPollView: UIView, StatusContentPollView {
class StatusPollView: UIView, StatusContentView {
private static let formatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()

View File

@ -14,12 +14,12 @@ import SafariServices
class ProfileFieldValueView: UIView {
weak var navigationDelegate: TuskerNavigationDelegate?
private static let converter: HTMLConverter = {
var converter = HTMLConverter()
converter.font = .preferredFont(forTextStyle: .body)
converter.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular))
return converter
}()
private static let converter = HTMLConverter(
font: .preferredFont(forTextStyle: .body),
monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
color: .label,
paragraphStyle: .default
)
private let account: AccountMO
private let field: Account.Field

View File

@ -95,8 +95,6 @@ class ProfileHeaderView: UIView {
relationshipLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14))
relationshipLabel.adjustsFontForContentSizeCategory = true
noteTextView.defaultFont = .preferredFont(forTextStyle: .body)
noteTextView.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular))
noteTextView.adjustsFontForContentSizeCategory = true
pagesSegmentedControl = ScrollingSegmentedControl(frame: .zero)
@ -140,7 +138,7 @@ class ProfileHeaderView: UIView {
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, source: .view(moreButton), fetchRelationship: false) ?? [])
noteTextView.navigationDelegate = delegate
noteTextView.setTextFromHtml(account.note)
noteTextView.setBodyTextFromHTML(account.note)
noteTextView.setEmojis(account.emojis, identifier: account.id)
if accountID == mastodonController.account?.id {

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="22155" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22131"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -46,7 +46,7 @@
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="vFa-g3-xIP" customClass="ProfileHeaderButton" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="358" y="142" width="48" height="48"/>
<rect key="frame" x="358.5" y="142.5" width="47.5" height="47.5"/>
<constraints>
<constraint firstAttribute="width" secondItem="vFa-g3-xIP" secondAttribute="height" multiplier="1:1" id="B01-24-GJj"/>
</constraints>
@ -54,7 +54,7 @@
<buttonConfiguration key="configuration" style="plain" image="ellipsis" catalog="system"/>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="cr8-p9-xkc" customClass="ProfileHeaderButton" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="249" y="140" width="101" height="52"/>
<rect key="frame" x="249.5" y="140.5" width="101" height="51.5"/>
<state key="normal" title="Button"/>
<buttonConfiguration key="configuration" style="plain" image="person.badge.plus" catalog="system" title="Follow" imagePadding="4"/>
<connections>
@ -123,13 +123,6 @@
</imageView>
</subviews>
</stackView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5ja-fK-Fqz">
<rect key="frame" x="16" y="861.5" width="398" height="0.5"/>
<color key="backgroundColor" systemColor="separatorColor"/>
<constraints>
<constraint firstAttribute="height" constant="0.5" id="VwS-gV-q8M"/>
</constraints>
</view>
</subviews>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
@ -139,11 +132,9 @@
<constraint firstItem="wT9-2J-uSY" firstAttribute="centerY" secondItem="dgG-dR-lSv" secondAttribute="bottom" id="7gb-T3-Xe7"/>
<constraint firstItem="vcl-Gl-kXl" firstAttribute="top" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="8" symbolic="YES" id="7ss-Mf-YYH"/>
<constraint firstItem="vcl-Gl-kXl" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="8ho-WU-MxW"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="5ja-fK-Fqz" secondAttribute="bottom" id="9ZS-Ey-eKd"/>
<constraint firstItem="jwU-EH-hmC" firstAttribute="top" secondItem="vcl-Gl-kXl" secondAttribute="bottom" id="9lx-Fn-M0U"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="u4P-3i-gEq" secondAttribute="bottom" id="9zc-N2-mfI"/>
<constraint firstItem="vFa-g3-xIP" firstAttribute="bottom" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="-8" id="AXS-bG-20Q"/>
<constraint firstAttribute="trailing" secondItem="5ja-fK-Fqz" secondAttribute="trailing" id="EMk-dp-yJV"/>
<constraint firstItem="jwU-EH-hmC" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="TkY-oK-if4" secondAttribute="bottom" id="HBR-rg-RxO"/>
<constraint firstItem="dgG-dR-lSv" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="VD1-yc-KSa"/>
<constraint firstItem="wT9-2J-uSY" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="WNS-AR-ff2"/>
@ -153,7 +144,6 @@
<constraint firstItem="cr8-p9-xkc" firstAttribute="trailing" secondItem="vFa-g3-xIP" secondAttribute="leading" constant="-8" id="f1L-S8-l6H"/>
<constraint firstItem="u4P-3i-gEq" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="hgl-UR-o3W"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="dgG-dR-lSv" secondAttribute="trailing" id="j0d-hY-815"/>
<constraint firstItem="5ja-fK-Fqz" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="jPG-WM-9km"/>
<constraint firstItem="jwU-EH-hmC" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="oGR-6M-gdd"/>
<constraint firstAttribute="trailing" secondItem="u4P-3i-gEq" secondAttribute="trailing" constant="16" id="ph6-NT-A02"/>
<constraint firstItem="u4P-3i-gEq" firstAttribute="top" secondItem="wT9-2J-uSY" secondAttribute="bottom" priority="999" constant="8" id="tKQ-6d-Z55"/>
@ -181,15 +171,12 @@
<resources>
<image name="ellipsis" catalog="system" width="128" height="37"/>
<image name="lock.fill" catalog="system" width="125" height="128"/>
<image name="person.badge.plus" catalog="system" width="128" height="125"/>
<image name="person.badge.plus" catalog="system" width="128" height="124"/>
<systemColor name="labelColor">
<color red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="separatorColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.28999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<color red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>

View File

@ -17,6 +17,13 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
static let contentParagraphStyle = HTMLConverter.defaultParagraphStyle
static let metaFont = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 15))
private static let htmlConverter = HTMLConverter(
font: ConversationMainStatusCollectionViewCell.contentFont,
monospaceFont: ConversationMainStatusCollectionViewCell.monospaceFont,
color: .label,
paragraphStyle: ConversationMainStatusCollectionViewCell.contentParagraphStyle
)
static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
@ -117,18 +124,38 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
}
let contentContainer = StatusContentContainer<StatusContentTextView, StatusPollView>(useTopSpacer: true).configure {
$0.contentTextView.defaultFont = ConversationMainStatusCollectionViewCell.contentFont
$0.contentTextView.monospaceFont = ConversationMainStatusCollectionViewCell.monospaceFont
$0.contentTextView.paragraphStyle = ConversationMainStatusCollectionViewCell.contentParagraphStyle
$0.contentTextView.isSelectable = true
$0.contentTextView.dataDetectorTypes = [.flightNumber, .address, .shipmentTrackingNumber, .phoneNumber]
if #available(iOS 16.0, *) {
$0.contentTextView.dataDetectorTypes.formUnion([.money, .physicalValue])
}
private(set) lazy var contentContainer = StatusContentContainer(arrangedSubviews: [
contentTextView,
cardView,
attachmentsView,
pollView,
] as! [any StatusContentView], useTopSpacer: true).configure {
$0.setContentHuggingPriority(.defaultLow, for: .vertical)
}
let contentTextView = StatusContentTextView().configure {
$0.adjustsFontForContentSizeCategory = true
$0.isScrollEnabled = false
$0.backgroundColor = nil
$0.isEditable = false
$0.isSelectable = true
$0.emojiFont = ConversationMainStatusCollectionViewCell.contentFont
$0.dataDetectorTypes = [.flightNumber, .address, .shipmentTrackingNumber, .phoneNumber]
if #available(iOS 16.0, *) {
$0.dataDetectorTypes.formUnion([.money, .physicalValue])
}
}
private var translateButton: TranslateButton?
let cardView = StatusCardView().configure {
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
}
let attachmentsView = AttachmentsContainerView()
let pollView = StatusPollView()
private lazy var favoritesCountButton = UIButton().configure {
$0.titleLabel!.adjustsFontForContentSizeCategory = true
$0.addTarget(self, action: #selector(favoritesCountPressed), for: .touchUpInside)
@ -319,6 +346,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
var mastodonController: MastodonController! { delegate?.apiController }
weak var delegate: StatusCollectionViewCellDelegate?
var showStatusAutomatically = false
var translateStatus: (() -> Void)?
var statusID: String!
var statusState: CollapseState!
@ -348,9 +376,9 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
accountDetailAccessibilityElement,
contentWarningLabel,
collapseButton,
contentContainer.contentTextView,
contentContainer.attachmentsView,
contentContainer.pollView,
contentTextView,
attachmentsView,
pollView,
favoritesCountButton,
reblogsCountButton,
timestampAndClientLabel,
@ -378,7 +406,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
// MARK: Configure UI
func updateUI(statusID: String, state: CollapseState) {
func updateUI(statusID: String, state: CollapseState, translation: Translation?) {
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
fatalError()
}
@ -388,7 +416,20 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
self.statusID = statusID
self.statusState = state
doUpdateUI(status: status)
let html = translation?.content ?? status.content
let attributedContent = ConversationMainStatusCollectionViewCell.htmlConverter.convert(html)
let collapsedContent = NSMutableAttributedString(attributedString: attributedContent)
collapsedContent.collapseWhitespace()
collapsedContent.trimLeadingCharactersInSet(.whitespacesAndNewlines)
collapsedContent.trimTrailingCharactersInSet(.whitespacesAndNewlines)
doUpdateUI(status: status, content: collapsedContent)
if !status.spoilerText.isEmpty,
let translated = translation?.spoilerText {
contentWarningLabel.text = translated
contentWarningLabel.setEmojis(status.emojis, identifier: "\(statusID)_translated")
}
accountDetailToContentWarningSpacer.isHidden = collapseButton.isHidden
contentWarningToCollapseButtonSpacer.isHidden = collapseButton.isHidden
@ -413,6 +454,31 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
} else {
editTimestampButton.isHidden = true
}
if mastodonController.instanceFeatures.translation,
let preferredLanguage = mastodonController.accountPreferences.serverDefaultLanguage,
preferredLanguage != status.language {
var config = UIButton.Configuration.tinted()
config.image = UIImage(systemName: "globe")!
if let translation {
let lang = Locale.current.localizedString(forLanguageCode: translation.detectedSourceLanguage) ?? translation.detectedSourceLanguage
config.title = "Translated from \(lang)"
} else {
config.title = "Translate"
}
if let translateButton {
translateButton.configuration = config
} else {
let button = TranslateButton(configuration: config)
button.addTarget(self, action: #selector(translatePressed), for: .touchUpInside)
translateButton = button
contentContainer.insertArrangedSubview(button, after: contentTextView)
}
translateButton!.isEnabled = translation == nil
} else if let translateButton {
contentContainer.removeArrangedSubview(translateButton)
}
}
private func createObservers() {
@ -517,6 +583,18 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
delegate?.show(StatusEditHistoryViewController(statusID: statusID, mastodonController: mastodonController), sender: nil)
}
@objc private func translatePressed() {
guard let translateButton,
let translateStatus else {
return
}
var config = translateButton.configuration!
config.showsActivityIndicator = true
translateButton.configuration = config
// activity indicator will be hidden when translation finishes and the cell is reconfigured
translateStatus()
}
}
private class ConversationMainStatusAccountDetailAccessibilityElement: UIAccessibilityElement {
@ -585,3 +663,13 @@ extension ConversationMainStatusCollectionViewCell: UIPointerInteractionDelegate
return nil
}
}
private class TranslateButton: UIButton, StatusContentView {
var statusContentFillsHorizontally: Bool {
false
}
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
0
}
}

View File

@ -10,7 +10,7 @@ import UIKit
import Pachyderm
import SafariServices
import WebURLFoundationExtras
import SwiftSoup
import HTMLStreamer
class StatusCardView: UIView {
@ -198,7 +198,8 @@ class StatusCardView: UIView {
titleLabel.text = title
titleLabel.isHidden = title.isEmpty
let description = try! SwiftSoup.parseBodyFragment(card.description).text()
var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self)
let description = converter.convert(html: card.description)
descriptionLabel.text = description
descriptionLabel.isHidden = description.isEmpty

View File

@ -24,7 +24,11 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate
var usernameLabel: UILabel { get }
var contentWarningLabel: EmojiLabel { get }
var collapseButton: StatusCollapseButton { get }
var contentContainer: StatusContentContainer<StatusContentTextView, StatusPollView> { get }
var contentContainer: StatusContentContainer { get }
var contentTextView: StatusContentTextView { get }
var pollView: StatusPollView { get }
var cardView: StatusCardView { get }
var attachmentsView: AttachmentsContainerView { get }
var replyButton: UIButton { get }
var favoriteButton: ToggleableButton { get }
var reblogButton: ToggleableButton { get }
@ -44,6 +48,7 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate
var isGrayscale: Bool { get set }
var cancellables: Set<AnyCancellable> { get set }
func updateAttachmentsUI(status: StatusMO)
func updateUIForPreferences(status: StatusMO)
func updateStatusState(status: StatusMO)
func estimateContentHeight() -> CGFloat
@ -83,27 +88,26 @@ extension StatusCollectionViewCell {
.store(in: &cancellables)
}
func doUpdateUI(status: StatusMO, precomputedContent: NSAttributedString? = nil) {
func doUpdateUI(status: StatusMO, content: NSAttributedString) {
statusID = status.id
accountID = status.account.id
updateAccountUI(account: status.account)
contentContainer.contentTextView.setTextFrom(status: status, precomputed: precomputedContent)
contentContainer.contentTextView.navigationDelegate = delegate
contentContainer.attachmentsView.delegate = self
contentContainer.attachmentsView.updateUI(attachments: status.attachments)
contentContainer.pollView.isHidden = status.poll == nil
contentContainer.pollView.mastodonController = mastodonController
contentContainer.pollView.delegate = delegate
contentContainer.pollView.updateUI(status: status, poll: status.poll)
contentTextView.setTextFrom(status: status, content: content)
contentTextView.navigationDelegate = delegate
self.updateAttachmentsUI(status: status)
pollView.isHidden = status.poll == nil
pollView.mastodonController = mastodonController
pollView.delegate = delegate
pollView.updateUI(status: status, poll: status.poll)
if Preferences.shared.showLinkPreviews {
contentContainer.cardView.updateUI(status: status)
contentContainer.cardView.isHidden = status.card == nil
contentContainer.cardView.navigationDelegate = delegate
contentContainer.cardView.actionProvider = delegate
cardView.updateUI(status: status)
cardView.isHidden = status.card == nil
cardView.navigationDelegate = delegate
cardView.actionProvider = delegate
} else {
contentContainer.cardView.isHidden = true
cardView.isHidden = true
}
updateUIForPreferences(status: status)
@ -167,6 +171,11 @@ extension StatusCollectionViewCell {
return true
}
func updateAttachmentsUI(status: StatusMO) {
attachmentsView.delegate = self
attachmentsView.updateUI(attachments: status.attachments)
}
func updateAccountUI(account: AccountMO) {
avatarImageView.update(for: account.avatar)
displayNameLabel.updateForAccountDisplayName(account: account)
@ -177,20 +186,20 @@ extension StatusCollectionViewCell {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.avatarImageViewSize
let newCardHidden = !Preferences.shared.showLinkPreviews || status.card == nil
if contentContainer.cardView.isHidden != newCardHidden {
if cardView.isHidden != newCardHidden {
delegate?.statusCellNeedsReconfigure(self, animated: false, completion: nil)
}
switch Preferences.shared.attachmentBlurMode {
case .never:
contentContainer.attachmentsView.contentHidden = false
attachmentsView.contentHidden = false
case .always:
contentContainer.attachmentsView.contentHidden = true
attachmentsView.contentHidden = true
default:
if status.sensitive {
contentContainer.attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
} else {
contentContainer.attachmentsView.contentHidden = false
attachmentsView.contentHidden = false
}
}
@ -206,8 +215,8 @@ extension StatusCollectionViewCell {
// only called when isGrayscale does not match the pref
func updateGrayscaleableUI(status: StatusMO) {
isGrayscale = Preferences.shared.grayscaleImages
if contentContainer.contentTextView.hasEmojis {
contentContainer.contentTextView.setEmojis(status.emojis, identifier: status.id)
if contentTextView.hasEmojis {
contentTextView.setEmojis(status.emojis, identifier: status.id)
}
displayNameLabel.updateForAccountDisplayName(account: status.account)
}
@ -230,10 +239,10 @@ extension StatusCollectionViewCell {
// do not include reply action here, because the cell already contains a button for it
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, source: .view(moreButton), includeStatusButtonActions: false) ?? [])
contentContainer.pollView.isHidden = status.poll == nil
contentContainer.pollView.mastodonController = mastodonController
contentContainer.pollView.delegate = delegate
contentContainer.pollView.updateUI(status: status, poll: status.poll)
pollView.isHidden = status.poll == nil
pollView.mastodonController = mastodonController
pollView.delegate = delegate
pollView.updateUI(status: status, poll: status.poll)
}
func setShowThreadLinks(prev: Bool, next: Bool) {
@ -322,7 +331,7 @@ extension StatusCollectionViewCell {
func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? {
guard let delegate = delegate,
let status = mastodonController.persistentContainer.status(for: statusID) else { return nil }
let sourceViews = status.attachments.map(contentContainer.attachmentsView.getAttachmentView(for:))
let sourceViews = status.attachments.map(attachmentsView.getAttachmentView(for:))
let gallery = delegate.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index)
// TODO: PiP
// gallery.avPlayerViewControllerDelegate = self

View File

@ -8,45 +8,11 @@
import UIKit
protocol StatusContentPollView: UIView {
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat
}
class StatusContentContainer<ContentView: ContentTextView, PollView: StatusContentPollView>: UIView {
class StatusContentContainer: UIView {
// TODO: this is a weird place for this
static var cardViewHeight: CGFloat { 90 }
private var useTopSpacer = false
private let topSpacer = UIView().configure {
$0.backgroundColor = .clear
// other 4pt is provided by this view's own spacing
$0.heightAnchor.constraint(equalToConstant: 4).isActive = true
}
let contentTextView = ContentView().configure {
$0.adjustsFontForContentSizeCategory = true
$0.isScrollEnabled = false
$0.backgroundColor = nil
$0.isEditable = false
$0.isSelectable = false
}
private static var cardViewHeight: CGFloat { 90 }
let cardView = StatusCardView().configure {
NSLayoutConstraint.activate([
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight),
])
}
let attachmentsView = AttachmentsContainerView()
let pollView = PollView()
private var arrangedSubviews: [UIView] {
if useTopSpacer {
return [topSpacer, contentTextView, cardView, attachmentsView, pollView]
} else {
return [contentTextView, cardView, attachmentsView, pollView]
}
}
private var arrangedSubviews: [any StatusContentView]
private var isHiddenObservations: [NSKeyValueObservation] = []
@ -61,8 +27,12 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
subviews.filter { !$0.isHidden }.map(\.bounds.height).reduce(0, +)
}
init(useTopSpacer: Bool) {
self.useTopSpacer = useTopSpacer
init(arrangedSubviews: [any StatusContentView], useTopSpacer: Bool) {
var arrangedSubviews = arrangedSubviews
if useTopSpacer {
arrangedSubviews.insert(TopSpacerView(), at: 0)
}
self.arrangedSubviews = arrangedSubviews
super.init(frame: .zero)
@ -70,10 +40,14 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
subview.translatesAutoresizingMaskIntoConstraints = false
addSubview(subview)
NSLayoutConstraint.activate([
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
])
if subview.statusContentFillsHorizontally {
NSLayoutConstraint.activate([
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
])
} else {
subview.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
}
}
// this constraint needs to have low priority so that during the collapse/expand animation, the content container is the view that shrinks/expands
@ -82,17 +56,21 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
setNeedsUpdateConstraints()
isHiddenObservations = arrangedSubviews.map {
$0.observe(\.isHidden) { [unowned self] _, _ in
self.setNeedsUpdateConstraints()
}
}
updateObservations()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateObservations() {
isHiddenObservations = arrangedSubviews.map {
$0.observeIsHidden { [unowned self] in
self.setNeedsUpdateConstraints()
}
}
}
override func updateConstraints() {
let visibleSubviews = IndexSet(arrangedSubviews.indices.filter { !arrangedSubviews[$0].isHidden })
if self.visibleSubviews != visibleSubviews {
@ -129,6 +107,31 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
super.updateConstraints()
}
func insertArrangedSubview(_ view: any StatusContentView, after: any StatusContentView) {
view.translatesAutoresizingMaskIntoConstraints = false
addSubview(view)
if view.statusContentFillsHorizontally {
NSLayoutConstraint.activate([
view.leadingAnchor.constraint(equalTo: leadingAnchor),
view.trailingAnchor.constraint(equalTo: trailingAnchor),
])
} else {
view.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
}
let index = arrangedSubviews.firstIndex(where: { $0 === after })!
arrangedSubviews.insert(view, at: index + 1)
setNeedsUpdateConstraints()
updateObservations()
}
func removeArrangedSubview(_ view: any StatusContentView) {
view.removeFromSuperview()
arrangedSubviews.removeAll(where: { $0 === view })
setNeedsUpdateConstraints()
updateObservations()
}
func setCollapsed(_ collapsed: Bool) {
guard collapsed != isCollapsed else {
return
@ -147,18 +150,67 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
// just roughly inline with the content height
func estimateVisibleSubviewHeight(effectiveWidth: CGFloat) -> CGFloat {
var height: CGFloat = 0
height += contentTextView.sizeThatFits(CGSize(width: effectiveWidth, height: UIView.layoutFittingCompressedSize.height)).height
if !cardView.isHidden {
height += StatusContentContainer.cardViewHeight
}
if !attachmentsView.isHidden {
height += effectiveWidth / attachmentsView.aspectRatio
}
if !pollView.isHidden {
let pollHeight = pollView.estimateHeight(effectiveWidth: effectiveWidth)
height += pollHeight
for view in arrangedSubviews where !view.isHidden {
height += view.estimateHeight(effectiveWidth: effectiveWidth)
}
return height
}
}
extension StatusContentContainer {
private class TopSpacerView: UIView, StatusContentView {
init() {
super.init(frame: .zero)
backgroundColor = .clear
// other 4pt is provided by this view's own spacing
heightAnchor.constraint(equalToConstant: 4).isActive = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
4
}
}
}
private extension UIView {
func observeIsHidden(_ f: @escaping () -> Void) -> NSKeyValueObservation {
self.observe(\.isHidden) { _, _ in
f()
}
}
}
protocol StatusContentView: UIView {
var statusContentFillsHorizontally: Bool { get }
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat
}
extension StatusContentView {
var statusContentFillsHorizontally: Bool {
true
}
}
extension ContentTextView: StatusContentView {
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
sizeThatFits(CGSize(width: effectiveWidth, height: UIView.layoutFittingCompressedSize.height)).height
}
}
extension StatusCardView: StatusContentView {
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
StatusContentContainer.cardViewHeight
}
}
extension AttachmentsContainerView: StatusContentView {
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
effectiveWidth / aspectRatio
}
}

View File

@ -20,6 +20,13 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
static let monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 16, weight: .regular))
static let contentParagraphStyle = HTMLConverter.defaultParagraphStyle
static let htmlConverter = HTMLConverter(
font: TimelineStatusCollectionViewCell.contentFont,
monospaceFont: TimelineStatusCollectionViewCell.monospaceFont,
color: .label,
paragraphStyle: TimelineStatusCollectionViewCell.contentParagraphStyle
)
private static let timelineReasonIconSize: CGFloat = 25
// MARK: Subviews
@ -186,25 +193,32 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
$0.setContentCompressionResistancePriority(.required, for: .vertical)
}
let contentContainer = StatusContentContainer<StatusContentTextView, StatusPollView>(useTopSpacer: false).configure {
$0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont
$0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
$0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
private(set) lazy var contentContainer = StatusContentContainer(arrangedSubviews: [
contentTextView,
cardView,
attachmentsView,
pollView,
] as! [any StatusContentView], useTopSpacer: false).configure {
$0.setContentHuggingPriority(.defaultLow, for: .vertical)
}
private var contentTextView: StatusContentTextView {
contentContainer.contentTextView
let contentTextView = StatusContentTextView().configure {
$0.adjustsFontForContentSizeCategory = true
$0.isScrollEnabled = false
$0.backgroundColor = nil
$0.isEditable = false
$0.isSelectable = false
$0.emojiFont = TimelineStatusCollectionViewCell.contentFont
}
private var cardView: StatusCardView {
contentContainer.cardView
}
private var attachmentsView: AttachmentsContainerView {
contentContainer.attachmentsView
}
private var pollView: StatusPollView {
contentContainer.pollView
let cardView = StatusCardView().configure {
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
}
let attachmentsView = AttachmentsContainerView()
let pollView = StatusPollView()
#if os(visionOS)
private lazy var actionsContainer = UIStackView(arrangedSubviews: [
replyButton,
@ -333,6 +347,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
var showReplyIndicator = true
var showPinned: Bool = false
var showFollowedHashtags: Bool = false
var showAttachmentsInline = true
// alas these need to be internal so they're accessible from the protocol extensions
var statusID: String!
@ -629,9 +644,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
if let reblogStatus {
hideTimelineReason = false
updateRebloggerLabel(reblogger: reblogStatus.account)
}
if showFollowedHashtags {
} else if showFollowedHashtags {
let hashtags = mastodonController.followedHashtags.filter({ followed in status.hashtags.contains(where: { followed.name == $0.name }) })
if !hashtags.isEmpty {
hideTimelineReason = false
@ -651,7 +664,12 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
mainContainerTopToReblogLabelConstraint.isActive = true
}
doUpdateUI(status: status, precomputedContent: precomputedContent)
let content = precomputedContent ?? TimelineStatusCollectionViewCell.htmlConverter.convert(status.content)
let collapsedContent = NSMutableAttributedString(attributedString: content)
collapsedContent.collapseWhitespace()
collapsedContent.trimLeadingCharactersInSet(.whitespacesAndNewlines)
collapsedContent.trimTrailingCharactersInSet(.whitespacesAndNewlines)
doUpdateUI(status: status, content: collapsedContent)
doUpdateTimestamp(status: status)
timestampLabel.isHidden = showPinned
@ -694,6 +712,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
baseUpdateStatusState(status: status)
}
func updateAttachmentsUI(status: StatusMO) {
attachmentsView.delegate = self
attachmentsView.updateUI(attachments: status.attachments, labelOnly: !showAttachmentsInline)
}
func estimateContentHeight() -> CGFloat {
let width = bounds.width /* leading spacing: */ - 16 /* avatar: */ - 50 /* spacing: 8 */ - 8 /* trailing spacing: */ - 16
return contentContainer.estimateVisibleSubviewHeight(effectiveWidth: width)

View File

@ -14,13 +14,9 @@ class StatusContentTextView: ContentTextView {
private var statusID: String?
func setTextFrom(status: some StatusProtocol, precomputed attributedText: NSAttributedString? = nil) {
func setTextFrom(status: some StatusProtocol, content attributedText: NSAttributedString) {
statusID = status.id
if let attributedText {
self.attributedText = attributedText
} else {
setTextFromHtml(status.content)
}
self.attributedText = attributedText
setEmojis(status.emojis, identifier: status.id)
}

View File

@ -16,6 +16,36 @@ class AttributedStringHelperTests: XCTestCase {
override func tearDown() {
}
func testTrimLeading() {
let a = NSMutableAttributedString(string: " a ")
a.trimLeadingCharactersInSet(.whitespaces)
XCTAssertEqual(a, NSAttributedString(string: "a "))
let b = NSMutableAttributedString(string: " ")
b.trimLeadingCharactersInSet(.whitespaces)
XCTAssertEqual(b, NSAttributedString(string: ""))
let c = NSMutableAttributedString(string: "")
c.trimLeadingCharactersInSet(.whitespaces)
XCTAssertEqual(c, NSAttributedString(string: ""))
let d = NSMutableAttributedString(string: "abc")
d.trimLeadingCharactersInSet(.whitespaces)
XCTAssertEqual(d, NSAttributedString(string: "abc"))
}
func testTrimTrailing() {
let a = NSMutableAttributedString(string: " a ")
a.trimTrailingCharactersInSet(.whitespaces)
XCTAssertEqual(a, NSAttributedString(string: " a"))
let b = NSMutableAttributedString(string: " ")
b.trimTrailingCharactersInSet(.whitespaces)
XCTAssertEqual(b, NSAttributedString(string: ""))
let c = NSMutableAttributedString(string: "")
c.trimTrailingCharactersInSet(.whitespaces)
XCTAssertEqual(c, NSAttributedString(string: ""))
let d = NSMutableAttributedString(string: "abc")
d.trimTrailingCharactersInSet(.whitespaces)
XCTAssertEqual(d, NSAttributedString(string: "abc"))
}
func testCollapsingWhitespace() {
var str = NSAttributedString(string: "test 1\n")

View File

@ -9,8 +9,8 @@
// Configuration settings file format documentation can be found at:
// https://help.apple.com/xcode/#/dev745c5c974
MARKETING_VERSION = 2023.8
CURRENT_PROJECT_VERSION = 106
MARKETING_VERSION = 2024.1
CURRENT_PROJECT_VERSION = 111
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev