forked from shadowfacts/Tusker
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:
commit
94f71541f8
|
@ -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
|
## 2023.7
|
||||||
This update adds support for iOS 17 and includes some minor changes.
|
This update adds support for iOS 17 and includes some minor changes.
|
||||||
|
|
||||||
|
|
35
CHANGELOG.md
35
CHANGELOG.md
|
@ -1,5 +1,40 @@
|
||||||
# Changelog
|
# 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)
|
## 2023.8 (106)
|
||||||
Bugfixes:
|
Bugfixes:
|
||||||
- Fix being able to set post language to multiple/undefined
|
- Fix being able to set post language to multiple/undefined
|
||||||
|
|
|
@ -114,13 +114,9 @@ class PostService: ObservableObject {
|
||||||
} catch let error as DraftAttachment.ExportError {
|
} catch let error as DraftAttachment.ExportError {
|
||||||
throw Error.attachmentData(index: index, cause: error)
|
throw Error.attachmentData(index: index, cause: error)
|
||||||
}
|
}
|
||||||
do {
|
let uploaded = try await uploadAttachment(index: index, data: data, utType: utType, description: attachment.attachmentDescription)
|
||||||
let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription)
|
|
||||||
attachments.append(uploaded.id)
|
attachments.append(uploaded.id)
|
||||||
currentStep += 1
|
currentStep += 1
|
||||||
} catch let error as Client.Error {
|
|
||||||
throw Error.attachmentUpload(index: index, cause: error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return attachments
|
return attachments
|
||||||
}
|
}
|
||||||
|
@ -138,10 +134,21 @@ class PostService: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func uploadAttachment(data: Data, utType: UTType, description: String?) async throws -> Attachment {
|
private func uploadAttachment(index: Int, data: Data, utType: UTType, description: String?) async throws -> Attachment {
|
||||||
let formAttachment = FormAttachment(mimeType: utType.preferredMIMEType!, data: data, fileName: "file.\(utType.preferredFilenameExtension!)")
|
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)
|
let req = Client.upload(attachment: formAttachment, description: description)
|
||||||
|
do {
|
||||||
return try await mastodonController.run(req).0
|
return try await mastodonController.run(req).0
|
||||||
|
} catch let error as Client.Error {
|
||||||
|
throw Error.attachmentUpload(index: index, cause: error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func textForPosting() -> String {
|
private func textForPosting() -> String {
|
||||||
|
@ -170,6 +177,7 @@ class PostService: ObservableObject {
|
||||||
|
|
||||||
enum Error: Swift.Error, LocalizedError {
|
enum Error: Swift.Error, LocalizedError {
|
||||||
case attachmentData(index: Int, cause: DraftAttachment.ExportError)
|
case attachmentData(index: Int, cause: DraftAttachment.ExportError)
|
||||||
|
case attachmentMissingMimeType(index: Int, type: UTType)
|
||||||
case attachmentUpload(index: Int, cause: Client.Error)
|
case attachmentUpload(index: Int, cause: Client.Error)
|
||||||
case posting(Client.Error)
|
case posting(Client.Error)
|
||||||
|
|
||||||
|
@ -177,6 +185,8 @@ class PostService: ObservableObject {
|
||||||
switch self {
|
switch self {
|
||||||
case let .attachmentData(index: index, cause: cause):
|
case let .attachmentData(index: index, cause: cause):
|
||||||
return "Attachment \(index + 1): \(cause.localizedDescription)"
|
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):
|
case let .attachmentUpload(index: index, cause: cause):
|
||||||
return "Attachment \(index + 1): \(cause.localizedDescription)"
|
return "Attachment \(index + 1): \(cause.localizedDescription)"
|
||||||
case let .posting(error):
|
case let .posting(error):
|
||||||
|
|
|
@ -90,7 +90,7 @@ class ToolbarController: ViewController {
|
||||||
cwButton
|
cwButton
|
||||||
|
|
||||||
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
|
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
|
// the button has a bunch of extra space by default, but combined with what we add it's too much
|
||||||
.padding(.horizontal, -8)
|
.padding(.horizontal, -8)
|
||||||
#endif
|
#endif
|
||||||
|
@ -99,7 +99,9 @@ class ToolbarController: ViewController {
|
||||||
|
|
||||||
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
|
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
|
||||||
localOnlyPicker
|
localOnlyPicker
|
||||||
#if !os(visionOS)
|
#if targetEnvironment(macCatalyst)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
#elseif !os(visionOS)
|
||||||
.padding(.horizontal, -8)
|
.padding(.horizontal, -8)
|
||||||
#endif
|
#endif
|
||||||
.disabled(draft.editedStatusID != nil)
|
.disabled(draft.editedStatusID != nil)
|
||||||
|
@ -125,7 +127,6 @@ class ToolbarController: ViewController {
|
||||||
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
|
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var cwButton: some View {
|
private var cwButton: some View {
|
||||||
|
|
|
@ -157,10 +157,11 @@ extension DraftAttachment: NSItemProviderReading {
|
||||||
var data = data
|
var data = data
|
||||||
var type = UTType(typeIdentifier)!
|
var type = UTType(typeIdentifier)!
|
||||||
|
|
||||||
// this seems to only occur when the item is a UIImage, rather than just image data,
|
// the type is .image in certain circumstances:
|
||||||
// which seems to only occur when sharing a screenshot directly from the markup screen
|
// - macOS: image copied from macOS Safari -> only UIImage(data: data) works
|
||||||
|
// - iOS: sharing screenshot from markup -> only NSKeyedUnarchiver works
|
||||||
if type == .image,
|
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() {
|
let pngData = image.pngData() {
|
||||||
data = pngData
|
data = pngData
|
||||||
type = .png
|
type = .png
|
||||||
|
|
|
@ -21,7 +21,8 @@ public class InstanceFeatures: ObservableObject {
|
||||||
@Published public private(set) var charsReservedPerURL = 23
|
@Published public private(set) var charsReservedPerURL = 23
|
||||||
@Published public private(set) var maxPollOptionChars: Int?
|
@Published public private(set) var maxPollOptionChars: Int?
|
||||||
@Published public private(set) var maxPollOptionsCount: 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 {
|
public var localOnlyPosts: Bool {
|
||||||
switch instanceType {
|
switch instanceType {
|
||||||
|
@ -240,6 +241,7 @@ public class InstanceFeatures: ObservableObject {
|
||||||
maxPollOptionsCount = pollsConfig.maxOptions
|
maxPollOptionsCount = pollsConfig.maxOptions
|
||||||
}
|
}
|
||||||
mediaAttachmentsConfiguration = instance.configuration?.mediaAttachments
|
mediaAttachmentsConfiguration = instance.configuration?.mediaAttachments
|
||||||
|
translation = instance.translation
|
||||||
|
|
||||||
_featuresUpdated.send()
|
_featuresUpdated.send()
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,26 +9,39 @@ import Foundation
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
public struct InstanceInfo {
|
public struct InstanceInfo {
|
||||||
public let version: String
|
public var version: String
|
||||||
public let maxStatusCharacters: Int?
|
public var maxStatusCharacters: Int?
|
||||||
public let configuration: Instance.Configuration?
|
public var configuration: InstanceV1.Configuration?
|
||||||
public let pollsConfiguration: Instance.PollsConfiguration?
|
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.version = version
|
||||||
self.maxStatusCharacters = maxStatusCharacters
|
self.maxStatusCharacters = maxStatusCharacters
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
self.pollsConfiguration = pollsConfiguration
|
self.pollsConfiguration = pollsConfiguration
|
||||||
|
self.translation = translation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension InstanceInfo {
|
extension InstanceInfo {
|
||||||
public init(instance: Instance) {
|
public init(v1 instance: InstanceV1) {
|
||||||
self.init(
|
self.init(
|
||||||
version: instance.version,
|
version: instance.version,
|
||||||
maxStatusCharacters: instance.maxStatusCharacters,
|
maxStatusCharacters: instance.maxStatusCharacters,
|
||||||
configuration: instance.configuration,
|
configuration: instance.configuration,
|
||||||
pollsConfiguration: instance.pollsConfiguration
|
pollsConfiguration: instance.pollsConfiguration,
|
||||||
|
translation: false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public mutating func update(v2: InstanceV2) {
|
||||||
|
translation = v2.configuration.translation.enabled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -231,8 +231,12 @@ public class Client {
|
||||||
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
|
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getInstance() -> Request<Instance> {
|
public static func getInstanceV1() -> Request<InstanceV1> {
|
||||||
return Request<Instance>(method: .get, path: "/api/v1/instance")
|
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]> {
|
public static func getCustomEmoji() -> Request<[Emoji]> {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// Instance.swift
|
// InstanceV1.swift
|
||||||
// Pachyderm
|
// Pachyderm
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 9/9/18.
|
// Created by Shadowfacts on 9/9/18.
|
||||||
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct Instance: Decodable, Sendable {
|
public struct InstanceV1: Decodable, Sendable {
|
||||||
public let uri: String
|
public let uri: String
|
||||||
public let title: String
|
public let title: String
|
||||||
public let description: String
|
public let description: String
|
||||||
|
@ -92,7 +92,7 @@ public struct Instance: Decodable, Sendable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Instance {
|
extension InstanceV1 {
|
||||||
public struct Stats: Decodable, Sendable {
|
public struct Stats: Decodable, Sendable {
|
||||||
public let domainCount: Int?
|
public let domainCount: Int?
|
||||||
public let statusCount: Int?
|
public let statusCount: Int?
|
||||||
|
@ -106,7 +106,7 @@ extension Instance {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Instance {
|
extension InstanceV1 {
|
||||||
public struct Configuration: Codable, Sendable {
|
public struct Configuration: Codable, Sendable {
|
||||||
public let statuses: StatusesConfiguration
|
public let statuses: StatusesConfiguration
|
||||||
public let mediaAttachments: MediaAttachmentsConfiguration
|
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 struct StatusesConfiguration: Codable, Sendable {
|
||||||
public let maxCharacters: Int
|
public let maxCharacters: Int
|
||||||
public let maxMediaAttachments: 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 struct MediaAttachmentsConfiguration: Codable, Sendable {
|
||||||
public let supportedMIMETypes: [String]
|
public let supportedMIMETypes: [String]
|
||||||
public let imageSizeLimit: Int
|
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 struct PollsConfiguration: Codable, Sendable {
|
||||||
public let maxOptions: Int
|
public let maxOptions: Int
|
||||||
public let maxCharactersPerOption: 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 struct Rule: Decodable, Identifiable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let text: String
|
public let text: String
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -66,7 +66,8 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID)
|
self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID)
|
||||||
self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID)
|
self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID)
|
||||||
self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog)
|
self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog)
|
||||||
self.content = try container.decode(String.self, forKey: .content)
|
// 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.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||||
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
|
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
|
||||||
self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount)
|
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")
|
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 {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case uri
|
case uri
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -26,13 +26,26 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func makeUIView(context: Context) -> UIButton {
|
public func makeUIView(context: Context) -> UIButton {
|
||||||
let button = UIButton()
|
let button = UIButton(configuration: makeConfiguration())
|
||||||
button.showsMenuAsPrimaryAction = true
|
button.showsMenuAsPrimaryAction = true
|
||||||
button.setContentHuggingPriority(.required, for: .horizontal)
|
button.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
return button
|
return button
|
||||||
}
|
}
|
||||||
|
|
||||||
public func updateUIView(_ button: UIButton, context: Context) {
|
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()
|
var config = UIButton.Configuration.borderless()
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
config.indicator = .popup
|
config.indicator = .popup
|
||||||
|
@ -43,16 +56,10 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
|
||||||
if buttonStyle.hasLabel {
|
if buttonStyle.hasLabel {
|
||||||
config.title = selectedOption.title
|
config.title = selectedOption.title
|
||||||
}
|
}
|
||||||
button.configuration = config
|
#if targetEnvironment(macCatalyst)
|
||||||
button.menu = UIMenu(children: options.map { opt in
|
config.macIdiomStyle = .bordered
|
||||||
let action = UIAction(title: opt.title, subtitle: opt.subtitle, image: opt.image, state: opt.value == selection ? .on : .off) { _ in
|
#endif
|
||||||
selection = opt.value
|
return config
|
||||||
}
|
|
||||||
action.accessibilityLabel = opt.accessibilityLabel
|
|
||||||
return action
|
|
||||||
})
|
|
||||||
button.accessibilityLabel = selectedOption.accessibilityLabel ?? selectedOption.title
|
|
||||||
button.isPointerInteractionEnabled = buttonStyle == .iconOnly
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Option {
|
public struct Option {
|
||||||
|
|
|
@ -62,6 +62,7 @@ public final class Preferences: Codable, ObservableObject {
|
||||||
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
|
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
|
||||||
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
|
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
|
||||||
self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false
|
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) {
|
if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) {
|
||||||
self.defaultPostVisibility = .visibility(existing)
|
self.defaultPostVisibility = .visibility(existing)
|
||||||
|
@ -127,6 +128,7 @@ public final class Preferences: Codable, ObservableObject {
|
||||||
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
|
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
|
||||||
try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode)
|
try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode)
|
||||||
try container.encode(underlineTextLinks, forKey: .underlineTextLinks)
|
try container.encode(underlineTextLinks, forKey: .underlineTextLinks)
|
||||||
|
try container.encode(showAttachmentsInTimeline, forKey: .showAttachmentsInTimeline)
|
||||||
|
|
||||||
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
|
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
|
||||||
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
|
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
|
||||||
|
@ -182,6 +184,7 @@ public final class Preferences: Codable, ObservableObject {
|
||||||
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
|
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
|
||||||
@Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode
|
@Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode
|
||||||
@Published public var underlineTextLinks = false
|
@Published public var underlineTextLinks = false
|
||||||
|
@Published public var showAttachmentsInTimeline = true
|
||||||
|
|
||||||
// MARK: Composing
|
// MARK: Composing
|
||||||
@Published public var defaultPostVisibility = PostVisibility.serverDefault
|
@Published public var defaultPostVisibility = PostVisibility.serverDefault
|
||||||
|
@ -253,6 +256,7 @@ public final class Preferences: Codable, ObservableObject {
|
||||||
case trailingStatusSwipeActions
|
case trailingStatusSwipeActions
|
||||||
case widescreenNavigationMode
|
case widescreenNavigationMode
|
||||||
case underlineTextLinks
|
case underlineTextLinks
|
||||||
|
case showAttachmentsInTimeline
|
||||||
|
|
||||||
case defaultPostVisibility
|
case defaultPostVisibility
|
||||||
case defaultReplyVisibility
|
case defaultReplyVisibility
|
||||||
|
|
|
@ -16,6 +16,11 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable {
|
||||||
public private(set) var username: String!
|
public private(set) var username: String!
|
||||||
public let accessToken: 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"
|
fileprivate static let tempAccountID = "temp"
|
||||||
|
|
||||||
static func id(instanceURL: URL, username: String?) -> String {
|
static func id(instanceURL: URL, username: String?) -> String {
|
||||||
|
@ -47,21 +52,47 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable {
|
||||||
self.accessToken = accessToken
|
self.accessToken = accessToken
|
||||||
}
|
}
|
||||||
|
|
||||||
init?(userDefaultsDict dict: [String: String]) {
|
init?(userDefaultsDict dict: [String: Any]) {
|
||||||
guard let id = dict["id"],
|
guard let id = dict["id"] as? String,
|
||||||
let instanceURL = dict["instanceURL"],
|
let instanceURL = dict["instanceURL"] as? String,
|
||||||
let url = URL(string: instanceURL),
|
let url = URL(string: instanceURL),
|
||||||
let clientID = dict["clientID"],
|
let clientID = dict["clientID"] as? String,
|
||||||
let secret = dict["clientSecret"],
|
let secret = dict["clientSecret"] as? String,
|
||||||
let accessToken = dict["accessToken"] else {
|
let accessToken = dict["accessToken"] as? String else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
self.id = id
|
self.id = id
|
||||||
self.instanceURL = url
|
self.instanceURL = url
|
||||||
self.clientID = clientID
|
self.clientID = clientID
|
||||||
self.clientSecret = secret
|
self.clientSecret = secret
|
||||||
self.username = dict["username"]
|
self.username = dict["username"] as? String
|
||||||
self.accessToken = accessToken
|
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
|
/// A filename-safe string for this account
|
||||||
|
|
|
@ -46,19 +46,7 @@ public class UserAccountsManager: ObservableObject {
|
||||||
}
|
}
|
||||||
set {
|
set {
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
let array = newValue.map { (info) -> [String: String] in
|
let array = newValue.map(\.userDefaultsDict)
|
||||||
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
|
|
||||||
}
|
|
||||||
defaults.set(array, forKey: accountsKey)
|
defaults.set(array, forKey: accountsKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -147,6 +135,17 @@ public class UserAccountsManager: ObservableObject {
|
||||||
mostRecentAccountID = account?.id
|
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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension Notification.Name {
|
public extension Notification.Name {
|
||||||
|
|
|
@ -28,7 +28,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
|
||||||
self.instanceFeatures = InstanceFeatures()
|
self.instanceFeatures = InstanceFeatures()
|
||||||
|
|
||||||
Task { @MainActor in
|
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
|
async let nodeInfo: NodeInfo? = await withCheckedContinuation({ continuation in
|
||||||
self.client.nodeInfo { response in
|
self.client.nodeInfo { response in
|
||||||
switch response {
|
switch response {
|
||||||
|
@ -40,7 +40,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
guard let instance = await instance else { return }
|
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
|
Task { @MainActor in
|
||||||
|
|
|
@ -53,18 +53,14 @@ class ShareViewController: UIViewController {
|
||||||
private func createDraft(account: UserAccountInfo) async -> Draft {
|
private func createDraft(account: UserAccountInfo) async -> Draft {
|
||||||
async let (text, attachments) = getDraftConfigurationFromExtensionContext()
|
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(
|
let draft = DraftsPersistentContainer.shared.createDraft(
|
||||||
accountID: account.id,
|
accountID: account.id,
|
||||||
text: await text,
|
text: await text,
|
||||||
contentWarning: "",
|
contentWarning: "",
|
||||||
inReplyToID: nil,
|
inReplyToID: nil,
|
||||||
visibility: visibility,
|
visibility: Preferences.shared.defaultPostVisibility.resolved(withServerDefault: account.serverDefaultVisibility.flatMap(Visibility.init(rawValue:))),
|
||||||
language: serverPrefs?.postingDefaultLanguage,
|
language: account.serverDefaultLanguage,
|
||||||
localOnly: !(serverPrefs?.postingDefaultFederation ?? true)
|
localOnly: !(account.serverDefaultFederation ?? true)
|
||||||
)
|
)
|
||||||
|
|
||||||
for attachment in await attachments {
|
for attachment in await attachments {
|
||||||
|
|
|
@ -32,7 +32,7 @@
|
||||||
D608470F2A245D1F00C17380 /* ActiveInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D608470E2A245D1F00C17380 /* ActiveInstance.swift */; };
|
D608470F2A245D1F00C17380 /* ActiveInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D608470E2A245D1F00C17380 /* ActiveInstance.swift */; };
|
||||||
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */; };
|
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */; };
|
||||||
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.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 */; };
|
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F232442372B005F8713 /* StatusMO.swift */; };
|
||||||
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F252442372B005F8713 /* AccountMO.swift */; };
|
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F252442372B005F8713 /* AccountMO.swift */; };
|
||||||
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F2B24423EAD005F8713 /* LazilyDecoding.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 */; };
|
D6D79F262A0C8D2700AB2315 /* FetchStatusSourceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */; };
|
||||||
D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F282A0D596B00AB2315 /* StatusEditHistoryViewController.swift */; };
|
D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F282A0D596B00AB2315 /* StatusEditHistoryViewController.swift */; };
|
||||||
D6D79F2B2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.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 */; };
|
D6D79F2F2A0D6A7F00AB2315 /* StatusEditPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */; };
|
||||||
D6D79F532A0FFE3200AB2315 /* ToggleableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F522A0FFE3200AB2315 /* ToggleableButton.swift */; };
|
D6D79F532A0FFE3200AB2315 /* ToggleableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F522A0FFE3200AB2315 /* ToggleableButton.swift */; };
|
||||||
D6D79F572A1160B800AB2315 /* AccountDisplayNameLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F562A1160B800AB2315 /* AccountDisplayNameLabel.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
D6D79F562A1160B800AB2315 /* AccountDisplayNameLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameLabel.swift; sourceTree = "<group>"; };
|
||||||
|
@ -786,10 +784,10 @@
|
||||||
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */,
|
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */,
|
||||||
D659F35E2953A212002D944A /* TTTKit in Frameworks */,
|
D659F35E2953A212002D944A /* TTTKit in Frameworks */,
|
||||||
D6B0026E29B5248800C70BE2 /* UserAccounts in Frameworks */,
|
D6B0026E29B5248800C70BE2 /* UserAccounts in Frameworks */,
|
||||||
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */,
|
|
||||||
D60088EF2980D8B5005B4D00 /* StoreKit.framework in Frameworks */,
|
D60088EF2980D8B5005B4D00 /* StoreKit.framework in Frameworks */,
|
||||||
D6CA6ED229EF6091003EC5DF /* TuskerPreferences in Frameworks */,
|
D6CA6ED229EF6091003EC5DF /* TuskerPreferences in Frameworks */,
|
||||||
D6552367289870790048A653 /* ScreenCorners in Frameworks */,
|
D6552367289870790048A653 /* ScreenCorners in Frameworks */,
|
||||||
|
D60BB3942B30076F00DAEA65 /* HTMLStreamer in Frameworks */,
|
||||||
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
|
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
|
||||||
D63CC702290EC0B8000E19DE /* Sentry in Frameworks */,
|
D63CC702290EC0B8000E19DE /* Sentry in Frameworks */,
|
||||||
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */,
|
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */,
|
||||||
|
@ -1574,7 +1572,6 @@
|
||||||
children = (
|
children = (
|
||||||
D6D79F282A0D596B00AB2315 /* StatusEditHistoryViewController.swift */,
|
D6D79F282A0D596B00AB2315 /* StatusEditHistoryViewController.swift */,
|
||||||
D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */,
|
D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */,
|
||||||
D6D79F2C2A0D61B400AB2315 /* StatusEditContentTextView.swift */,
|
|
||||||
D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */,
|
D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */,
|
||||||
);
|
);
|
||||||
path = "Status Edit History";
|
path = "Status Edit History";
|
||||||
|
@ -1699,7 +1696,6 @@
|
||||||
);
|
);
|
||||||
name = Tusker;
|
name = Tusker;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
D60CFFDA24A290BA00D00083 /* SwiftSoup */,
|
|
||||||
D6676CA427A8D0020052936B /* WebURLFoundationExtras */,
|
D6676CA427A8D0020052936B /* WebURLFoundationExtras */,
|
||||||
D674A50827F9128D00BA03AC /* Pachyderm */,
|
D674A50827F9128D00BA03AC /* Pachyderm */,
|
||||||
D6552366289870790048A653 /* ScreenCorners */,
|
D6552366289870790048A653 /* ScreenCorners */,
|
||||||
|
@ -1711,6 +1707,7 @@
|
||||||
D635237029B78A7D009ED5E7 /* TuskerComponents */,
|
D635237029B78A7D009ED5E7 /* TuskerComponents */,
|
||||||
D6BD395829B64426005FFD2B /* ComposeUI */,
|
D6BD395829B64426005FFD2B /* ComposeUI */,
|
||||||
D6CA6ED129EF6091003EC5DF /* TuskerPreferences */,
|
D6CA6ED129EF6091003EC5DF /* TuskerPreferences */,
|
||||||
|
D60BB3932B30076F00DAEA65 /* HTMLStreamer */,
|
||||||
);
|
);
|
||||||
productName = Tusker;
|
productName = Tusker;
|
||||||
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
|
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
|
||||||
|
@ -1821,10 +1818,10 @@
|
||||||
);
|
);
|
||||||
mainGroup = D6D4DDC3212518A000E1C4BB;
|
mainGroup = D6D4DDC3212518A000E1C4BB;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
|
|
||||||
D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */,
|
D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */,
|
||||||
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */,
|
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */,
|
||||||
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */,
|
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */,
|
||||||
|
D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */,
|
||||||
);
|
);
|
||||||
productRefGroup = D6D4DDCD212518A000E1C4BB /* Products */;
|
productRefGroup = D6D4DDCD212518A000E1C4BB /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
|
@ -2182,7 +2179,6 @@
|
||||||
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
|
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
|
||||||
D68A76F129539116001DA1B3 /* FlipView.swift in Sources */,
|
D68A76F129539116001DA1B3 /* FlipView.swift in Sources */,
|
||||||
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
|
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
|
||||||
D6D79F2D2A0D61B400AB2315 /* StatusEditContentTextView.swift in Sources */,
|
|
||||||
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
|
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
|
||||||
D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */,
|
D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */,
|
||||||
D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */,
|
D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */,
|
||||||
|
@ -2455,6 +2451,7 @@
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
INFOPLIST_FILE = TuskerUITests/Info.plist;
|
INFOPLIST_FILE = TuskerUITests/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
@ -2814,6 +2811,7 @@
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
INFOPLIST_FILE = TuskerUITests/Info.plist;
|
INFOPLIST_FILE = TuskerUITests/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
@ -2833,6 +2831,7 @@
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
INFOPLIST_FILE = TuskerUITests/Info.plist;
|
INFOPLIST_FILE = TuskerUITests/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
@ -2964,12 +2963,12 @@
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
|
D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/scinfu/SwiftSoup.git";
|
repositoryURL = "https://git.shadowfacts.net/shadowfacts/HTMLStreamer.git";
|
||||||
requirement = {
|
requirement = {
|
||||||
kind = upToNextMinorVersion;
|
kind = upToNextMinorVersion;
|
||||||
minimumVersion = 2.3.2;
|
minimumVersion = 0.1.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {
|
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {
|
||||||
|
@ -2977,7 +2976,7 @@
|
||||||
repositoryURL = "https://github.com/getsentry/sentry-cocoa.git";
|
repositoryURL = "https://github.com/getsentry/sentry-cocoa.git";
|
||||||
requirement = {
|
requirement = {
|
||||||
kind = upToNextMinorVersion;
|
kind = upToNextMinorVersion;
|
||||||
minimumVersion = 8.0.0;
|
minimumVersion = 8.15.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */ = {
|
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */ = {
|
||||||
|
@ -2999,10 +2998,10 @@
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
D60CFFDA24A290BA00D00083 /* SwiftSoup */ = {
|
D60BB3932B30076F00DAEA65 /* HTMLStreamer */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
|
package = D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */;
|
||||||
productName = SwiftSoup;
|
productName = HTMLStreamer;
|
||||||
};
|
};
|
||||||
D61ABEFB28F105DE00B29151 /* Pachyderm */ = {
|
D61ABEFB28F105DE00B29151 /* Pachyderm */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
|
|
@ -35,8 +35,7 @@ class DeleteStatusService {
|
||||||
reblogIDs = reblogs.map(\.id)
|
reblogIDs = reblogs.map(\.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
NotificationCenter.default.post(name: .statusDeleted, object: nil, userInfo: [
|
NotificationCenter.default.post(name: .statusDeleted, object: mastodonController, userInfo: [
|
||||||
"accountID": mastodonController.accountInfo!.id,
|
|
||||||
"statusIDs": [status.id] + reblogIDs,
|
"statusIDs": [status.id] + reblogIDs,
|
||||||
])
|
])
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
@ -36,11 +36,6 @@ class FetchStatusService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleStatusNotFound() {
|
private func handleStatusNotFound() {
|
||||||
// todo: what about when browsing on another instance?
|
|
||||||
guard let accountID = mastodonController.accountInfo?.id else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var reblogIDs = [String]()
|
var reblogIDs = [String]()
|
||||||
if let cached = mastodonController.persistentContainer.status(for: statusID) {
|
if let cached = mastodonController.persistentContainer.status(for: statusID) {
|
||||||
let reblogsReq = StatusMO.fetchRequest()
|
let reblogsReq = StatusMO.fetchRequest()
|
||||||
|
@ -50,8 +45,7 @@ class FetchStatusService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NotificationCenter.default.post(name: .statusDeleted, object: nil, userInfo: [
|
NotificationCenter.default.post(name: .statusDeleted, object: mastodonController, userInfo: [
|
||||||
"accountID": accountID,
|
|
||||||
"statusIDs": [statusID] + reblogIDs
|
"statusIDs": [statusID] + reblogIDs
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,7 +55,8 @@ class MastodonController: ObservableObject {
|
||||||
let instanceFeatures = InstanceFeatures()
|
let instanceFeatures = InstanceFeatures()
|
||||||
|
|
||||||
@Published private(set) var account: AccountMO?
|
@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 instanceInfo: InstanceInfo!
|
||||||
@Published private(set) var nodeInfo: NodeInfo!
|
@Published private(set) var nodeInfo: NodeInfo!
|
||||||
@Published private(set) var lists: [List] = []
|
@Published private(set) var lists: [List] = []
|
||||||
|
@ -65,7 +66,7 @@ class MastodonController: ObservableObject {
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
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?
|
private var ownInstanceRequest: URLSessionTask?
|
||||||
|
|
||||||
var loggedIn: Bool {
|
var loggedIn: Bool {
|
||||||
|
@ -111,9 +112,14 @@ class MastodonController: ObservableObject {
|
||||||
|
|
||||||
$instance
|
$instance
|
||||||
.compactMap { $0 }
|
.compactMap { $0 }
|
||||||
.sink { [unowned self] in
|
.combineLatest($instanceV2)
|
||||||
self.updateActiveInstance(from: $0)
|
.sink {[unowned self] (instance, v2) in
|
||||||
self.instanceInfo = InstanceInfo(instance: $0)
|
var info = InstanceInfo(v1: instance)
|
||||||
|
if let v2 {
|
||||||
|
info.update(v2: v2)
|
||||||
|
}
|
||||||
|
self.instanceInfo = info
|
||||||
|
self.updateActiveInstance(from: info)
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
@ -221,6 +227,10 @@ class MastodonController: ObservableObject {
|
||||||
|
|
||||||
_ = try await (ownAccount, ownInstance)
|
_ = try await (ownAccount, ownInstance)
|
||||||
|
|
||||||
|
if instanceFeatures.hasMastodonVersion(4, 0, 0) {
|
||||||
|
async let _ = try? getOwnInstanceV2()
|
||||||
|
}
|
||||||
|
|
||||||
loadLists()
|
loadLists()
|
||||||
_ = await loadFilters()
|
_ = await loadFilters()
|
||||||
await loadServerPreferences()
|
await loadServerPreferences()
|
||||||
|
@ -281,7 +291,7 @@ class MastodonController: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getOwnInstance(completion: ((Instance) -> Void)? = nil) {
|
func getOwnInstance(completion: ((InstanceV1) -> Void)? = nil) {
|
||||||
getOwnInstanceInternal(retryAttempt: 0) {
|
getOwnInstanceInternal(retryAttempt: 0) {
|
||||||
if case let .success(instance) = $0 {
|
if case let .success(instance) = $0 {
|
||||||
completion?(instance)
|
completion?(instance)
|
||||||
|
@ -290,7 +300,7 @@ class MastodonController: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func getOwnInstance() async throws -> Instance {
|
func getOwnInstance() async throws -> InstanceV1 {
|
||||||
return try await withCheckedThrowingContinuation({ continuation in
|
return try await withCheckedThrowingContinuation({ continuation in
|
||||||
getOwnInstanceInternal(retryAttempt: 0) { result in
|
getOwnInstanceInternal(retryAttempt: 0) { result in
|
||||||
continuation.resume(with: result)
|
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
|
// this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks
|
||||||
assert(Thread.isMainThread)
|
assert(Thread.isMainThread)
|
||||||
|
|
||||||
|
@ -310,7 +320,7 @@ class MastodonController: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
if ownInstanceRequest == nil {
|
if ownInstanceRequest == nil {
|
||||||
let request = Client.getInstance()
|
let request = Client.getInstanceV1()
|
||||||
ownInstanceRequest = run(request) { (response) in
|
ownInstanceRequest = run(request) { (response) in
|
||||||
switch response {
|
switch response {
|
||||||
case .failure(let error):
|
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 because the accountPreferences instance is bound to the view context
|
||||||
@MainActor
|
@MainActor
|
||||||
private func loadServerPreferences() async {
|
private func loadServerPreferences() async {
|
||||||
|
@ -375,15 +389,18 @@ class MastodonController: ObservableObject {
|
||||||
accountPreferences!.serverDefaultLanguage = prefs.postingDefaultLanguage
|
accountPreferences!.serverDefaultLanguage = prefs.postingDefaultLanguage
|
||||||
accountPreferences!.serverDefaultVisibility = prefs.postingDefaultVisibility
|
accountPreferences!.serverDefaultVisibility = prefs.postingDefaultVisibility
|
||||||
accountPreferences!.serverDefaultFederation = prefs.postingDefaultFederation ?? true
|
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
|
persistentContainer.performBackgroundTask { context in
|
||||||
if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first {
|
if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first {
|
||||||
existing.update(from: instance)
|
existing.update(from: info)
|
||||||
} else {
|
} else {
|
||||||
let new = ActiveInstance(context: context)
|
let new = ActiveInstance(context: context)
|
||||||
new.update(from: instance)
|
new.update(from: info)
|
||||||
}
|
}
|
||||||
if context.hasChanges {
|
if context.hasChanges {
|
||||||
try? context.save()
|
try? context.save()
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import LinkPresentation
|
import LinkPresentation
|
||||||
import SwiftSoup
|
import HTMLStreamer
|
||||||
|
|
||||||
class StatusActivityItemSource: NSObject, UIActivityItemSource {
|
class StatusActivityItemSource: NSObject, UIActivityItemSource {
|
||||||
let status: StatusMO
|
let status: StatusMO
|
||||||
|
@ -33,8 +33,8 @@ class StatusActivityItemSource: NSObject, UIActivityItemSource {
|
||||||
let metadata = LPLinkMetadata()
|
let metadata = LPLinkMetadata()
|
||||||
metadata.originalURL = status.url!
|
metadata.originalURL = status.url!
|
||||||
metadata.url = status.url!
|
metadata.url = status.url!
|
||||||
let doc = try! SwiftSoup.parse(status.content)
|
var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self)
|
||||||
let content = try! doc.text()
|
let content = converter.convert(html: status.content)
|
||||||
metadata.title = "\(status.account.displayName): \"\(content)\""
|
metadata.title = "\(status.account.displayName): \"\(content)\""
|
||||||
if let avatar = status.account.avatar,
|
if let avatar = status.account.avatar,
|
||||||
let data = ImageCache.avatars.getData(avatar),
|
let data = ImageCache.avatars.getData(avatar),
|
||||||
|
|
|
@ -22,18 +22,20 @@ public final class ActiveInstance: NSManagedObject {
|
||||||
@NSManaged public var maxStatusCharacters: Int
|
@NSManaged public var maxStatusCharacters: Int
|
||||||
@NSManaged private var configurationData: Data?
|
@NSManaged private var configurationData: Data?
|
||||||
@NSManaged private var pollsConfigurationData: Data?
|
@NSManaged private var pollsConfigurationData: Data?
|
||||||
|
@NSManaged public var translation: Bool
|
||||||
|
|
||||||
@LazilyDecoding(from: \ActiveInstance.configurationData, fallback: nil)
|
@LazilyDecoding(from: \ActiveInstance.configurationData, fallback: nil)
|
||||||
public var configuration: Instance.Configuration?
|
public var configuration: InstanceV1.Configuration?
|
||||||
|
|
||||||
@LazilyDecoding(from: \ActiveInstance.pollsConfigurationData, fallback: nil)
|
@LazilyDecoding(from: \ActiveInstance.pollsConfigurationData, fallback: nil)
|
||||||
public var pollsConfiguration: Instance.PollsConfiguration?
|
public var pollsConfiguration: InstanceV1.PollsConfiguration?
|
||||||
|
|
||||||
func update(from instance: Instance) {
|
func update(from info: InstanceInfo) {
|
||||||
self.version = instance.version
|
self.version = info.version
|
||||||
self.maxStatusCharacters = instance.maxStatusCharacters ?? 500
|
self.maxStatusCharacters = info.maxStatusCharacters ?? 500
|
||||||
self.configuration = instance.configuration
|
self.configuration = info.configuration
|
||||||
self.pollsConfiguration = instance.pollsConfiguration
|
self.pollsConfiguration = info.pollsConfiguration
|
||||||
|
self.translation = info.translation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,7 +45,8 @@ extension InstanceInfo {
|
||||||
version: activeInstance.version,
|
version: activeInstance.version,
|
||||||
maxStatusCharacters: activeInstance.maxStatusCharacters,
|
maxStatusCharacters: activeInstance.maxStatusCharacters,
|
||||||
configuration: activeInstance.configuration,
|
configuration: activeInstance.configuration,
|
||||||
pollsConfiguration: activeInstance.pollsConfiguration
|
pollsConfiguration: activeInstance.pollsConfiguration,
|
||||||
|
translation: activeInstance.translation
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,6 +54,7 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
|
||||||
@NSManaged public var reblog: StatusMO?
|
@NSManaged public var reblog: StatusMO?
|
||||||
@NSManaged public var localOnly: Bool
|
@NSManaged public var localOnly: Bool
|
||||||
@NSManaged public var lastFetchedAt: Date?
|
@NSManaged public var lastFetchedAt: Date?
|
||||||
|
@NSManaged public var language: String?
|
||||||
|
|
||||||
@LazilyDecoding(arrayFrom: \StatusMO.attachmentsData)
|
@LazilyDecoding(arrayFrom: \StatusMO.attachmentsData)
|
||||||
public var attachments: [Attachment]
|
public var attachments: [Attachment]
|
||||||
|
@ -139,6 +140,7 @@ extension StatusMO {
|
||||||
self.visibility = status.visibility
|
self.visibility = status.visibility
|
||||||
self.poll = status.poll
|
self.poll = status.poll
|
||||||
self.localOnly = status.localOnly ?? false
|
self.localOnly = status.localOnly ?? false
|
||||||
|
self.language = status.language
|
||||||
|
|
||||||
if let existing = container.account(for: status.account.id, in: context) {
|
if let existing = container.account(for: status.account.id, in: context) {
|
||||||
existing.updateFrom(apiAccount: status.account, container: container)
|
existing.updateFrom(apiAccount: status.account, container: container)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?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">
|
<entity name="Account" representedClassName="AccountMO" syncable="YES">
|
||||||
<attribute name="acct" attributeType="String"/>
|
<attribute name="acct" attributeType="String"/>
|
||||||
<attribute name="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
<attribute name="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
@ -41,6 +41,7 @@
|
||||||
<attribute name="configurationData" optional="YES" attributeType="Binary"/>
|
<attribute name="configurationData" optional="YES" attributeType="Binary"/>
|
||||||
<attribute name="maxStatusCharacters" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="maxStatusCharacters" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
<attribute name="pollsConfigurationData" optional="YES" attributeType="Binary"/>
|
<attribute name="pollsConfigurationData" optional="YES" attributeType="Binary"/>
|
||||||
|
<attribute name="translation" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
<attribute name="version" optional="YES" attributeType="String"/>
|
<attribute name="version" optional="YES" attributeType="String"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="Filter" representedClassName="FilterMO" syncable="YES">
|
<entity name="Filter" representedClassName="FilterMO" syncable="YES">
|
||||||
|
@ -109,6 +110,7 @@
|
||||||
<attribute name="id" attributeType="String"/>
|
<attribute name="id" attributeType="String"/>
|
||||||
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
|
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
|
||||||
<attribute name="inReplyToID" 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="lastFetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
<attribute name="mentionsData" attributeType="Binary"/>
|
<attribute name="mentionsData" attributeType="Binary"/>
|
||||||
|
@ -123,7 +125,8 @@
|
||||||
<attribute name="url" optional="YES" attributeType="URI"/>
|
<attribute name="url" optional="YES" attributeType="URI"/>
|
||||||
<attribute name="visibilityString" attributeType="String"/>
|
<attribute name="visibilityString" attributeType="String"/>
|
||||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/>
|
<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"/>
|
<relationship name="timelines" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="TimelineState" inverseName="statuses" inverseEntity="TimelineState"/>
|
||||||
<uniquenessConstraints>
|
<uniquenessConstraints>
|
||||||
<uniquenessConstraint>
|
<uniquenessConstraint>
|
||||||
|
|
|
@ -30,20 +30,31 @@ extension NSAttributedString {
|
||||||
extension NSMutableAttributedString {
|
extension NSMutableAttributedString {
|
||||||
|
|
||||||
func trimLeadingCharactersInSet(_ charSet: CharacterSet) {
|
func trimLeadingCharactersInSet(_ charSet: CharacterSet) {
|
||||||
var range = (string as NSString).rangeOfCharacter(from: charSet)
|
var end = string.startIndex
|
||||||
|
while end < string.endIndex && charSet.contains(string.unicodeScalars[end]) {
|
||||||
while range.length != 0 && range.location == 0 {
|
end = string.unicodeScalars.index(after: end)
|
||||||
replaceCharacters(in: range, with: "")
|
}
|
||||||
range = (string as NSString).rangeOfCharacter(from: charSet)
|
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) {
|
func trimTrailingCharactersInSet(_ charSet: CharacterSet) {
|
||||||
var range = (string as NSString).rangeOfCharacter(from: charSet, options: .backwards)
|
if string.isEmpty {
|
||||||
|
return
|
||||||
while range.length != 0 && range.length + range.location == length {
|
}
|
||||||
replaceCharacters(in: range, with: "")
|
var start = string.index(before: string.endIndex)
|
||||||
range = (string as NSString).rangeOfCharacter(from: charSet, options: .backwards)
|
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: "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -42,7 +42,7 @@ class Filterer {
|
||||||
|
|
||||||
var filtersChanged: ((Bool) -> Void)?
|
var filtersChanged: ((Bool) -> Void)?
|
||||||
|
|
||||||
var htmlConverter = HTMLConverter()
|
private var htmlConverter: HTMLConverter
|
||||||
private var hasSetup = false
|
private var hasSetup = false
|
||||||
private var matchers = [(NSRegularExpression, Result)]()
|
private var matchers = [(NSRegularExpression, Result)]()
|
||||||
private var cancellables = Set<AnyCancellable>()
|
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
|
// are no longer valid, without needing to go through and update each of them
|
||||||
private var generation = 0
|
private var generation = 0
|
||||||
|
|
||||||
init(mastodonController: MastodonController, context: FilterV1.Context) {
|
init(mastodonController: MastodonController, context: FilterV1.Context, htmlConverter: HTMLConverter) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
self.context = context
|
self.context = context
|
||||||
|
self.htmlConverter = htmlConverter
|
||||||
self.hideReblogsInTimelines = Preferences.shared.hideReblogsInTimelines
|
self.hideReblogsInTimelines = Preferences.shared.hideReblogsInTimelines
|
||||||
self.hideRepliesInTimelines = Preferences.shared.hideRepliesInTimelines
|
self.hideRepliesInTimelines = Preferences.shared.hideRepliesInTimelines
|
||||||
|
|
||||||
|
|
|
@ -7,11 +7,11 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import SwiftSoup
|
import HTMLStreamer
|
||||||
import WebURL
|
import WebURL
|
||||||
import WebURLFoundationExtras
|
import WebURLFoundationExtras
|
||||||
|
|
||||||
struct HTMLConverter {
|
class HTMLConverter {
|
||||||
|
|
||||||
static let defaultFont = UIFont.systemFont(ofSize: 17)
|
static let defaultFont = UIFont.systemFont(ofSize: 17)
|
||||||
static let defaultMonospaceFont = UIFont.monospacedSystemFont(ofSize: 17, weight: .regular)
|
static let defaultMonospaceFont = UIFont.monospacedSystemFont(ofSize: 17, weight: .regular)
|
||||||
|
@ -23,148 +23,47 @@ struct HTMLConverter {
|
||||||
return style
|
return style
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var font: UIFont = defaultFont
|
private var converter: AttributedStringConverter<Callbacks>
|
||||||
var monospaceFont: UIFont = defaultMonospaceFont
|
|
||||||
var color: UIColor = defaultColor
|
init(font: UIFont, monospaceFont: UIFont, color: UIColor, paragraphStyle: NSParagraphStyle) {
|
||||||
var paragraphStyle: NSParagraphStyle = defaultParagraphStyle
|
let config = AttributedStringConverterConfiguration(font: font, monospaceFont: monospaceFont, color: color, paragraphStyle: paragraphStyle)
|
||||||
|
self.converter = AttributedStringConverter(configuration: config)
|
||||||
|
}
|
||||||
|
|
||||||
func convert(_ html: String) -> NSAttributedString {
|
func convert(_ html: String) -> NSAttributedString {
|
||||||
let doc = try! SwiftSoup.parseBodyFragment(html)
|
converter.convert(html: 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
|
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 {
|
} else {
|
||||||
return NSAttributedString()
|
URL(string: string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func attributedTextForHTMLNode(_ node: Node, usePreformattedText: Bool = false) -> NSAttributedString? {
|
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {
|
||||||
switch node {
|
guard name == "span" else {
|
||||||
case let node as TextNode:
|
return .default
|
||||||
let text: String
|
}
|
||||||
if usePreformattedText {
|
let clazz = attributes.attributeValue(for: "class")
|
||||||
text = node.getWholeText()
|
if clazz == "invisible" {
|
||||||
|
return .skip
|
||||||
|
} else if clazz == "ellipsis" {
|
||||||
|
return .append("…")
|
||||||
} else {
|
} else {
|
||||||
text = node.text()
|
return .default
|
||||||
}
|
|
||||||
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))."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,12 +7,13 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
private let decoder = PropertyListDecoder()
|
private let decoder = PropertyListDecoder()
|
||||||
private let encoder = PropertyListEncoder()
|
private let encoder = PropertyListEncoder()
|
||||||
|
|
||||||
@propertyWrapper
|
@propertyWrapper
|
||||||
public struct LazilyDecoding<Enclosing: NSObject, Value: Codable> {
|
public struct LazilyDecoding<Enclosing: NSManagedObject, Value: Codable> {
|
||||||
|
|
||||||
private let keyPath: ReferenceWritableKeyPath<Enclosing, Data?>
|
private let keyPath: ReferenceWritableKeyPath<Enclosing, Data?>
|
||||||
private let fallback: Value
|
private let fallback: Value
|
||||||
|
|
|
@ -150,7 +150,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
minDate.addTimeInterval(-7 * 24 * 60 * 60)
|
minDate.addTimeInterval(-7 * 24 * 60 * 60)
|
||||||
|
|
||||||
let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest()
|
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)
|
let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq)
|
||||||
deleteStatusReq.resultType = .resultTypeCount
|
deleteStatusReq.resultType = .resultTypeCount
|
||||||
if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult {
|
if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult {
|
||||||
|
|
|
@ -26,11 +26,9 @@ struct ComposeReplyContentView: UIViewRepresentable {
|
||||||
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
|
||||||
view.adjustsFontForContentSizeCategory = true
|
view.adjustsFontForContentSizeCategory = true
|
||||||
view.defaultFont = TimelineStatusCollectionViewCell.contentFont
|
|
||||||
view.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
|
|
||||||
|
|
||||||
view.overrideMastodonController = mastodonController
|
view.overrideMastodonController = mastodonController
|
||||||
view.setTextFrom(status: status)
|
view.attributedText = TimelineStatusCollectionViewCell.htmlConverter.convert(status.content)
|
||||||
|
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
||||||
private let mastodonController: MastodonController
|
private let mastodonController: MastodonController
|
||||||
private let mainStatusID: String
|
private let mainStatusID: String
|
||||||
private let mainStatusState: CollapseState
|
private let mainStatusState: CollapseState
|
||||||
|
private var mainStatusTranslation: Translation?
|
||||||
var statusIDToScrollToOnLoad: String
|
var statusIDToScrollToOnLoad: String
|
||||||
var showStatusesAutomatically = false
|
var showStatusesAutomatically = false
|
||||||
|
|
||||||
|
@ -88,11 +89,14 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
||||||
cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil)
|
cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil)
|
||||||
cell.setShowThreadLinks(prev: item.2, next: item.3)
|
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.delegate = self
|
||||||
|
cell.translateStatus = { [unowned self] in
|
||||||
|
self.translateMainStatus()
|
||||||
|
}
|
||||||
cell.showStatusAutomatically = self.showStatusesAutomatically
|
cell.showStatusAutomatically = self.showStatusesAutomatically
|
||||||
cell.updateUI(statusID: item.0, state: item.1)
|
cell.updateUI(statusID: item.0, state: item.1, translation: item.2)
|
||||||
cell.setShowThreadLinks(prev: item.2, next: false)
|
cell.setShowThreadLinks(prev: item.3, next: false)
|
||||||
}
|
}
|
||||||
let expandThreadCell = UICollectionView.CellRegistration<ExpandThreadCollectionViewCell, ([ConversationNode], Bool)> { cell, indexPath, item in
|
let expandThreadCell = UICollectionView.CellRegistration<ExpandThreadCollectionViewCell, ([ConversationNode], Bool)> { cell, indexPath, item in
|
||||||
cell.updateUI(childThreads: item.0, inline: item.1)
|
cell.updateUI(childThreads: item.0, inline: item.1)
|
||||||
|
@ -104,7 +108,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
||||||
switch itemIdentifier {
|
switch itemIdentifier {
|
||||||
case let .status(id: id, node: _, state: state, prevLink: prevLink, nextLink: nextLink):
|
case let .status(id: id, node: _, state: state, prevLink: prevLink, nextLink: nextLink):
|
||||||
if id == self.mainStatusID {
|
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 {
|
} else {
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, prevLink, nextLink))
|
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 {
|
extension ConversationCollectionViewController {
|
||||||
|
|
|
@ -112,7 +112,7 @@ class ConversationViewController: UIViewController {
|
||||||
appearance.configureWithDefaultBackground()
|
appearance.configureWithDefaultBackground()
|
||||||
navigationItem.scrollEdgeAppearance = appearance
|
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() {
|
private func updateVisibilityBarButtonItem() {
|
||||||
|
@ -145,8 +145,6 @@ class ConversationViewController: UIViewController {
|
||||||
|
|
||||||
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
||||||
guard let userInfo = notification.userInfo,
|
guard let userInfo = notification.userInfo,
|
||||||
let accountID = mastodonController.accountInfo?.id,
|
|
||||||
userInfo["accountID"] as? String == accountID,
|
|
||||||
let statusIDs = userInfo["statusIDs"] as? [String],
|
let statusIDs = userInfo["statusIDs"] as? [String],
|
||||||
case .localID(let mainStatusID) = mode else {
|
case .localID(let mainStatusID) = mode else {
|
||||||
return
|
return
|
||||||
|
|
|
@ -261,13 +261,16 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteList(_ list: List, completion: @escaping (Bool) -> Void) {
|
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) })
|
let service = DeleteListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) })
|
||||||
if await service.run() {
|
if await service.run() {
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
snapshot.deleteItems([.list(list)])
|
snapshot.deleteItems([.list(list)])
|
||||||
await dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true) {
|
||||||
completion(true)
|
completion(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
completion(false)
|
completion(false)
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,8 +34,6 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
|
||||||
displayNameLabel.font = UIFontMetrics(forTextStyle: .title1).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
|
displayNameLabel.font = UIFontMetrics(forTextStyle: .title1).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
|
||||||
displayNameLabel.adjustsFontForContentSizeCategory = true
|
displayNameLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
|
||||||
noteTextView.defaultFont = .preferredFont(forTextStyle: .body)
|
|
||||||
noteTextView.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular))
|
|
||||||
noteTextView.adjustsFontForContentSizeCategory = true
|
noteTextView.adjustsFontForContentSizeCategory = true
|
||||||
noteTextView.textContainer.lineBreakMode = .byTruncatingTail
|
noteTextView.textContainer.lineBreakMode = .byTruncatingTail
|
||||||
noteTextView.textContainerInset = UIEdgeInsets(top: 16, left: 4, bottom: 16, right: 4)
|
noteTextView.textContainerInset = UIEdgeInsets(top: 16, left: 4, bottom: 16, right: 4)
|
||||||
|
@ -60,7 +58,7 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
|
||||||
|
|
||||||
displayNameLabel.updateForAccountDisplayName(account: account)
|
displayNameLabel.updateForAccountDisplayName(account: account)
|
||||||
|
|
||||||
noteTextView.setTextFromHtml(account.note)
|
noteTextView.setBodyTextFromHTML(account.note)
|
||||||
noteTextView.setEmojis(account.emojis, identifier: account.id)
|
noteTextView.setEmojis(account.emojis, identifier: account.id)
|
||||||
|
|
||||||
avatarImageView.image = nil
|
avatarImageView.image = nil
|
||||||
|
|
|
@ -54,8 +54,6 @@ class SuggestedProfileCardCollectionViewCell: UICollectionViewCell {
|
||||||
usernameLabel.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 15, weight: .light))
|
usernameLabel.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 15, weight: .light))
|
||||||
usernameLabel.adjustsFontForContentSizeCategory = true
|
usernameLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
|
||||||
noteTextView.defaultFont = .preferredFont(forTextStyle: .body)
|
|
||||||
noteTextView.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular))
|
|
||||||
noteTextView.adjustsFontForContentSizeCategory = true
|
noteTextView.adjustsFontForContentSizeCategory = true
|
||||||
noteTextView.textContainer.lineBreakMode = .byTruncatingTail
|
noteTextView.textContainer.lineBreakMode = .byTruncatingTail
|
||||||
|
|
||||||
|
@ -86,7 +84,7 @@ class SuggestedProfileCardCollectionViewCell: UICollectionViewCell {
|
||||||
avatarImageView.update(for: account.avatar)
|
avatarImageView.update(for: account.avatar)
|
||||||
headerImageView.update(for: account.header)
|
headerImageView.update(for: account.header)
|
||||||
usernameLabel.text = "@\(account.acct)"
|
usernameLabel.text = "@\(account.acct)"
|
||||||
noteTextView.setTextFromHtml(account.note)
|
noteTextView.setBodyTextFromHTML(account.note)
|
||||||
|
|
||||||
var config = UIButton.Configuration.plain()
|
var config = UIButton.Configuration.plain()
|
||||||
config.image = source.image
|
config.image = source.image
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import WebURLFoundationExtras
|
import WebURLFoundationExtras
|
||||||
import SwiftSoup
|
import HTMLStreamer
|
||||||
|
|
||||||
class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
|
class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
|
||||||
|
|
||||||
|
@ -78,8 +78,8 @@ class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
|
||||||
let provider = card.providerName!.trimmingCharacters(in: .whitespacesAndNewlines)
|
let provider = card.providerName!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
providerLabel.text = provider
|
providerLabel.text = provider
|
||||||
|
|
||||||
let description = try! SwiftSoup.parseBodyFragment(card.description).text()
|
var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self)
|
||||||
descriptionLabel.text = description
|
descriptionLabel.text = converter.convert(html: card.description)
|
||||||
descriptionLabel.isHidden = description.isEmpty
|
descriptionLabel.isHidden = description.isEmpty
|
||||||
|
|
||||||
let sorted = card.history!.sorted(by: { $0.day < $1.day })
|
let sorted = card.history!.sorted(by: { $0.day < $1.day })
|
||||||
|
|
|
@ -23,10 +23,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
|
||||||
|
|
||||||
init(mastodonController: MastodonController) {
|
init(mastodonController: MastodonController) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
self.filterer = Filterer(mastodonController: mastodonController, context: .public)
|
self.filterer = Filterer(mastodonController: mastodonController, context: .public, htmlConverter: TimelineStatusCollectionViewCell.htmlConverter)
|
||||||
self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont
|
|
||||||
self.filterer.htmlConverter.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
|
|
||||||
self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
|
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
@ -102,7 +99,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.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) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
@ -146,8 +143,6 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
|
||||||
|
|
||||||
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
||||||
guard let userInfo = notification.userInfo,
|
guard let userInfo = notification.userInfo,
|
||||||
let accountID = mastodonController.accountInfo?.id,
|
|
||||||
userInfo["accountID"] as? String == accountID,
|
|
||||||
let statusIDs = userInfo["statusIDs"] as? [String] else {
|
let statusIDs = userInfo["statusIDs"] as? [String] else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -172,7 +172,9 @@ class FastAccountSwitcherViewController: UIViewController {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
(self.view.window!.windowScene!.delegate as! MainSceneDelegate).showAddAccount()
|
if let sceneDelegate = self.view.window?.windowScene?.delegate as? MainSceneDelegate {
|
||||||
|
sceneDelegate.showAddAccount()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let account = UserAccountsManager.shared.accounts[newIndex - 1]
|
let account = UserAccountsManager.shared.accounts[newIndex - 1]
|
||||||
|
@ -186,7 +188,9 @@ class FastAccountSwitcherViewController: UIViewController {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
hide() {
|
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 {
|
} else {
|
||||||
hide()
|
hide()
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import UserAccounts
|
import UserAccounts
|
||||||
|
import WebURL
|
||||||
|
|
||||||
class FastSwitchingAccountView: UIView {
|
class FastSwitchingAccountView: UIView {
|
||||||
|
|
||||||
|
@ -126,7 +127,11 @@ class FastSwitchingAccountView: UIView {
|
||||||
|
|
||||||
private func setupAccount(account: UserAccountInfo) {
|
private func setupAccount(account: UserAccountInfo) {
|
||||||
usernameLabel.text = account.username
|
usernameLabel.text = account.username
|
||||||
|
if let domain = WebURL.Domain(account.instanceURL.host!) {
|
||||||
|
instanceLabel.text = domain.render(.uncheckedUnicodeString)
|
||||||
|
} else {
|
||||||
instanceLabel.text = account.instanceURL.host!
|
instanceLabel.text = account.instanceURL.host!
|
||||||
|
}
|
||||||
let controller = MastodonController.getForAccount(account)
|
let controller = MastodonController.getForAccount(account)
|
||||||
controller.getOwnAccount { [weak self] (result) in
|
controller.getOwnAccount { [weak self] (result) in
|
||||||
guard let self = self,
|
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() {
|
private func setupPlaceholder() {
|
||||||
|
|
|
@ -107,7 +107,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
||||||
|
|
||||||
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh \(predicateTitle)"))
|
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)
|
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) {
|
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
||||||
guard let userInfo = notification.userInfo,
|
guard let userInfo = notification.userInfo,
|
||||||
let accountID = mastodonController.accountInfo?.id,
|
|
||||||
userInfo["accountID"] as? String == accountID,
|
|
||||||
let statusIDs = userInfo["statusIDs"] as? [String] else {
|
let statusIDs = userInfo["statusIDs"] as? [String] else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import SwiftSoup
|
import HTMLStreamer
|
||||||
|
|
||||||
class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
|
class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
|
||||||
|
|
||||||
|
@ -161,9 +161,8 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
|
||||||
|
|
||||||
actionLabel.setEmojis(pairs: people.map { ($0.displayOrUserName, $0.emojis) }, identifier: group.id)
|
actionLabel.setEmojis(pairs: people.map { ($0.displayOrUserName, $0.emojis) }, identifier: group.id)
|
||||||
|
|
||||||
// todo: use htmlconverter
|
var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self)
|
||||||
let doc = try! SwiftSoup.parseBodyFragment(status.content)
|
statusContentLabel.text = converter.convert(html: status.content)
|
||||||
statusContentLabel.text = try! doc.text()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func updateUIForPreferences() {
|
@objc private func updateUIForPreferences() {
|
||||||
|
|
|
@ -23,6 +23,7 @@ class FollowRequestNotificationCollectionViewCell: UICollectionViewListCell {
|
||||||
$0.contentMode = .scaleAspectFill
|
$0.contentMode = .scaleAspectFill
|
||||||
$0.layer.masksToBounds = true
|
$0.layer.masksToBounds = true
|
||||||
$0.layer.cornerCurve = .continuous
|
$0.layer.cornerCurve = .continuous
|
||||||
|
$0.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
$0.widthAnchor.constraint(equalTo: $0.heightAnchor),
|
$0.widthAnchor.constraint(equalTo: $0.heightAnchor),
|
||||||
])
|
])
|
||||||
|
|
|
@ -36,10 +36,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
|
||||||
self.allowedTypes = allowedTypes
|
self.allowedTypes = allowedTypes
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
|
||||||
self.filterer = Filterer(mastodonController: mastodonController, context: .notifications)
|
self.filterer = Filterer(mastodonController: mastodonController, context: .notifications, htmlConverter: TimelineStatusCollectionViewCell.htmlConverter)
|
||||||
self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont
|
|
||||||
self.filterer.htmlConverter.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
|
|
||||||
self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
|
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
@ -123,7 +120,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
|
||||||
self.reapplyFilters(actionsChanged: actionsChanged)
|
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> {
|
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||||
|
@ -259,8 +256,6 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
|
||||||
|
|
||||||
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
||||||
guard let userInfo = notification.userInfo,
|
guard let userInfo = notification.userInfo,
|
||||||
let accountID = mastodonController.accountInfo?.id,
|
|
||||||
userInfo["accountID"] as? String == accountID,
|
|
||||||
let statusIDs = userInfo["statusIDs"] as? [String] else {
|
let statusIDs = userInfo["statusIDs"] as? [String] else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import SwiftSoup
|
import HTMLStreamer
|
||||||
|
|
||||||
class PollFinishedNotificationCollectionViewCell: UICollectionViewListCell {
|
class PollFinishedNotificationCollectionViewCell: UICollectionViewListCell {
|
||||||
|
|
||||||
|
@ -124,9 +124,8 @@ class PollFinishedNotificationCollectionViewCell: UICollectionViewListCell {
|
||||||
updateTimestamp()
|
updateTimestamp()
|
||||||
updateDisplayName(account: account)
|
updateDisplayName(account: account)
|
||||||
|
|
||||||
// todo: use htmlconverter
|
var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self)
|
||||||
let doc = try! SwiftSoup.parseBodyFragment(status.content)
|
contentLabel.text = converter.convert(html: status.content)
|
||||||
contentLabel.text = try! doc.text()
|
|
||||||
|
|
||||||
pollView.mastodonController = mastodonController
|
pollView.mastodonController = mastodonController
|
||||||
pollView.delegate = delegate
|
pollView.delegate = delegate
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import SwiftSoup
|
import HTMLStreamer
|
||||||
|
|
||||||
class StatusUpdatedNotificationCollectionViewCell: UICollectionViewListCell {
|
class StatusUpdatedNotificationCollectionViewCell: UICollectionViewListCell {
|
||||||
|
|
||||||
|
@ -120,9 +120,8 @@ class StatusUpdatedNotificationCollectionViewCell: UICollectionViewListCell {
|
||||||
updateTimestamp()
|
updateTimestamp()
|
||||||
updateDisplayName(account: account)
|
updateDisplayName(account: account)
|
||||||
|
|
||||||
// todo: use htmlconverter
|
var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self)
|
||||||
let doc = try! SwiftSoup.parseBodyFragment(status.content)
|
contentLabel.text = converter.convert(html: status.content)
|
||||||
contentLabel.text = try! doc.text()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func updateUIForPreferences() {
|
@objc private func updateUIForPreferences() {
|
||||||
|
|
|
@ -164,7 +164,7 @@ class InstanceSelectorTableViewController: UITableViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = Client(baseURL: url, session: .appDefault)
|
let client = Client(baseURL: url, session: .appDefault)
|
||||||
let request = Client.getInstance()
|
let request = Client.getInstanceV1()
|
||||||
client.run(request) { (response) in
|
client.run(request) { (response) in
|
||||||
var snapshot = self.dataSource.snapshot()
|
var snapshot = self.dataSource.snapshot()
|
||||||
if snapshot.indexOfSection(.selected) != nil {
|
if snapshot.indexOfSection(.selected) != nil {
|
||||||
|
@ -311,7 +311,7 @@ extension InstanceSelectorTableViewController {
|
||||||
case recommendedInstances
|
case recommendedInstances
|
||||||
}
|
}
|
||||||
enum Item: Equatable, Hashable {
|
enum Item: Equatable, Hashable {
|
||||||
case selected(URL, Instance)
|
case selected(URL, InstanceV1)
|
||||||
case recommended(InstanceSelector.Instance)
|
case recommended(InstanceSelector.Instance)
|
||||||
|
|
||||||
static func ==(lhs: Item, rhs: Item) -> Bool {
|
static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||||
|
|
|
@ -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
|
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.
|
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)
|
^[[swift-url](https://github.com/karwa/swift-url)](headingLevel: 2)
|
||||||
|
|
||||||
Apache License
|
Apache License
|
||||||
|
|
|
@ -121,12 +121,15 @@ struct AppearancePrefsView : View {
|
||||||
Toggle(isOn: $preferences.alwaysShowStatusVisibilityIcon) {
|
Toggle(isOn: $preferences.alwaysShowStatusVisibilityIcon) {
|
||||||
Text("Always Show Status Visibility Icons")
|
Text("Always Show Status Visibility Icons")
|
||||||
}
|
}
|
||||||
Toggle(isOn: $preferences.hideActionsInTimeline) {
|
|
||||||
Text("Hide Actions on Timeline")
|
|
||||||
}
|
|
||||||
Toggle(isOn: $preferences.showLinkPreviews) {
|
Toggle(isOn: $preferences.showLinkPreviews) {
|
||||||
Text("Show Link Previews")
|
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) {
|
Toggle(isOn: $preferences.underlineTextLinks) {
|
||||||
Text("Underline Links")
|
Text("Underline Links")
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserAccounts
|
import UserAccounts
|
||||||
|
import WebURL
|
||||||
|
|
||||||
struct PreferencesView: View {
|
struct PreferencesView: View {
|
||||||
let mastodonController: MastodonController
|
let mastodonController: MastodonController
|
||||||
|
@ -41,7 +42,12 @@ struct PreferencesView: View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(verbatim: account.username)
|
Text(verbatim: account.username)
|
||||||
.foregroundColor(.primary)
|
.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)
|
.font(.caption)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,10 +41,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.owner = owner
|
self.owner = owner
|
||||||
self.mastodonController = owner.mastodonController
|
self.mastodonController = owner.mastodonController
|
||||||
self.filterer = Filterer(mastodonController: mastodonController, context: .account)
|
self.filterer = Filterer(mastodonController: mastodonController, context: .account, htmlConverter: TimelineStatusCollectionViewCell.htmlConverter)
|
||||||
self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont
|
|
||||||
self.filterer.htmlConverter.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
|
|
||||||
self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
|
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
@ -67,18 +64,25 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
|
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
|
||||||
}
|
}
|
||||||
config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in
|
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
|
return sectionSeparatorConfiguration
|
||||||
}
|
}
|
||||||
var config = sectionSeparatorConfiguration
|
var config = sectionSeparatorConfiguration
|
||||||
if item.hideSeparators {
|
if item.hideSeparators {
|
||||||
config.topSeparatorVisibility = .hidden
|
config.topSeparatorVisibility = .hidden
|
||||||
config.bottomSeparatorVisibility = .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,
|
} else if case .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item,
|
||||||
filterer.isKnownHide(state: filterState) {
|
filterer.isKnownHide(state: filterState) {
|
||||||
config.topSeparatorVisibility = .hidden
|
config.topSeparatorVisibility = .hidden
|
||||||
config.bottomSeparatorVisibility = .hidden
|
config.bottomSeparatorVisibility = .hidden
|
||||||
} else if case .status(_, _, _, _) = item {
|
} else {
|
||||||
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
}
|
}
|
||||||
|
@ -88,6 +92,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
if case .header = dataSource.sectionIdentifier(for: sectionIndex) {
|
if case .header = dataSource.sectionIdentifier(for: sectionIndex) {
|
||||||
var config = UICollectionLayoutListConfiguration(appearance: .plain)
|
var config = UICollectionLayoutListConfiguration(appearance: .plain)
|
||||||
config.backgroundColor = .appBackground
|
config.backgroundColor = .appBackground
|
||||||
|
config.separatorConfiguration.bottomSeparatorInsets = .zero
|
||||||
return .list(using: config, layoutEnvironment: environment)
|
return .list(using: config, layoutEnvironment: environment)
|
||||||
} else {
|
} else {
|
||||||
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
|
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
|
||||||
|
@ -148,7 +153,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
self.reapplyFilters(actionsChanged: actionsChanged)
|
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> {
|
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||||
|
@ -376,8 +381,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
|
|
||||||
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
||||||
guard let userInfo = notification.userInfo,
|
guard let userInfo = notification.userInfo,
|
||||||
let accountID = mastodonController.accountInfo?.id,
|
|
||||||
userInfo["accountID"] as? String == accountID,
|
|
||||||
let statusIDs = userInfo["statusIDs"] as? [String] else {
|
let statusIDs = userInfo["statusIDs"] as? [String] else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,9 +7,13 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
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 {
|
struct ReportStatusView: View {
|
||||||
let status: StatusMO
|
let status: StatusMO
|
||||||
|
|
|
@ -65,12 +65,13 @@ class MastodonSearchController: UISearchController {
|
||||||
searchText.isEmpty || $0.contains(searchText)
|
searchText.isEmpty || $0.contains(searchText)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// TODO: use default language from preferences
|
|
||||||
var langSuggestions = [String]()
|
var langSuggestions = [String]()
|
||||||
if searchText.isEmpty || "language:en".contains(searchText) {
|
let defaultLanguage = searchResultsController.mastodonController.accountPreferences.serverDefaultLanguage ?? "en"
|
||||||
langSuggestions.append("language: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 match = languageRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.utf16.count)) {
|
||||||
let identifier = (searchText as NSString).substring(with: match.range(at: 1))
|
let identifier = (searchText as NSString).substring(with: match.range(at: 1))
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
|
@ -87,7 +88,7 @@ class MastodonSearchController: UISearchController {
|
||||||
if searchText.isEmpty || "from:me".contains(searchText) {
|
if searchText.isEmpty || "from:me".contains(searchText) {
|
||||||
fromSuggestions.append("from:me")
|
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 match = acctRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.utf16.count)) {
|
||||||
let matched = (searchText as NSString).substring(with: match.range)
|
let matched = (searchText as NSString).substring(with: match.range)
|
||||||
fromSuggestions.append("from:\(matched)")
|
fromSuggestions.append("from:\(matched)")
|
||||||
|
|
|
@ -46,6 +46,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
|
||||||
private let searchSubject = PassthroughSubject<String?, Never>()
|
private let searchSubject = PassthroughSubject<String?, Never>()
|
||||||
private var searchCancellable: AnyCancellable?
|
private var searchCancellable: AnyCancellable?
|
||||||
private var currentQuery: String?
|
private var currentQuery: String?
|
||||||
|
private var currentSearchResults: SearchResults?
|
||||||
|
|
||||||
init(mastodonController: MastodonController, scope: Scope = .all) {
|
init(mastodonController: MastodonController, scope: Scope = .all) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
@ -122,7 +123,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
|
||||||
|
|
||||||
userActivity = UserActivityManager.searchActivity(query: nil, accountID: mastodonController.accountInfo!.id)
|
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> {
|
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||||
|
@ -262,7 +263,14 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
|
||||||
switch response {
|
switch response {
|
||||||
case let .success(results, _):
|
case let .success(results, _):
|
||||||
guard self.currentQuery == query else { return }
|
guard self.currentQuery == query else { return }
|
||||||
|
self.mastodonController.persistentContainer.performBatchUpdates { (context, addAccounts, addStatuses) in
|
||||||
|
addAccounts(results.accounts)
|
||||||
|
addStatuses(results.statuses)
|
||||||
|
} completion: {
|
||||||
|
DispatchQueue.main.async {
|
||||||
self.showSearchResults(results)
|
self.showSearchResults(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
case let .failure(error):
|
case let .failure(error):
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.showSearchError(error)
|
self.showSearchError(error)
|
||||||
|
@ -271,16 +279,18 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
private func showSearchResults(_ results: SearchResults) {
|
private func showSearchResults(_ results: SearchResults) {
|
||||||
|
self.currentSearchResults = results
|
||||||
|
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
snapshot.deleteSections([.loadingIndicator])
|
snapshot.deleteSections([.loadingIndicator])
|
||||||
|
removeResults(from: &snapshot)
|
||||||
|
|
||||||
self.mastodonController.persistentContainer.performBatchUpdates({ (context, addAccounts, addStatuses) in
|
|
||||||
let resultTypes = self.scope.resultTypes
|
let resultTypes = self.scope.resultTypes
|
||||||
if !results.accounts.isEmpty && resultTypes.contains(.accounts) {
|
if !results.accounts.isEmpty && resultTypes.contains(.accounts) {
|
||||||
snapshot.appendSections([.accounts])
|
snapshot.appendSections([.accounts])
|
||||||
snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts)
|
snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts)
|
||||||
addAccounts(results.accounts)
|
|
||||||
}
|
}
|
||||||
if !results.hashtags.isEmpty && resultTypes.contains(.hashtags) {
|
if !results.hashtags.isEmpty && resultTypes.contains(.hashtags) {
|
||||||
snapshot.appendSections([.hashtags])
|
snapshot.appendSections([.hashtags])
|
||||||
|
@ -289,13 +299,9 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
|
||||||
if !results.statuses.isEmpty && resultTypes.contains(.statuses) {
|
if !results.statuses.isEmpty && resultTypes.contains(.statuses) {
|
||||||
snapshot.appendSections([.statuses])
|
snapshot.appendSections([.statuses])
|
||||||
snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses)
|
snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses)
|
||||||
addStatuses(results.statuses)
|
|
||||||
}
|
}
|
||||||
}, completion: {
|
|
||||||
DispatchQueue.main.async {
|
dataSource.apply(snapshot)
|
||||||
self.dataSource.apply(snapshot)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showSearchError(_ error: Client.Error) {
|
private func showSearchError(_ error: Client.Error) {
|
||||||
|
@ -311,8 +317,6 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
|
||||||
|
|
||||||
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
||||||
guard let userInfo = notification.userInfo,
|
guard let userInfo = notification.userInfo,
|
||||||
let accountID = mastodonController.accountInfo?.id,
|
|
||||||
userInfo["accountID"] as? String == accountID,
|
|
||||||
let statusIDs = userInfo["statusIDs"] as? [String] else {
|
let statusIDs = userInfo["statusIDs"] as? [String] else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -567,7 +571,9 @@ extension SearchResultsViewController: UISearchBarDelegate {
|
||||||
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
|
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
|
||||||
let newQuery = searchBar.searchQueryWithOperators
|
let newQuery = searchBar.searchQueryWithOperators
|
||||||
let newScope = Scope.allCases[selectedScope]
|
let newScope = Scope.allCases[selectedScope]
|
||||||
if self.scope == .all && currentQuery == newQuery {
|
if currentQuery == newQuery,
|
||||||
|
let currentSearchResults {
|
||||||
|
if self.scope == .all {
|
||||||
self.scope = newScope
|
self.scope = newScope
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
if snapshot.sectionIdentifiers.contains(.accounts) && scope != .people {
|
if snapshot.sectionIdentifiers.contains(.accounts) && scope != .people {
|
||||||
|
@ -580,6 +586,10 @@ extension SearchResultsViewController: UISearchBarDelegate {
|
||||||
snapshot.deleteSections([.statuses])
|
snapshot.deleteSections([.statuses])
|
||||||
}
|
}
|
||||||
dataSource.apply(snapshot)
|
dataSource.apply(snapshot)
|
||||||
|
} else {
|
||||||
|
self.scope = newScope
|
||||||
|
showSearchResults(currentSearchResults)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.scope = newScope
|
self.scope = newScope
|
||||||
performSearch(query: newQuery)
|
performSearch(query: newQuery)
|
||||||
|
|
|
@ -84,7 +84,7 @@ class StatusActionAccountListViewController: UIViewController {
|
||||||
|
|
||||||
view.backgroundColor = .appBackground
|
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) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
@ -99,8 +99,6 @@ class StatusActionAccountListViewController: UIViewController {
|
||||||
|
|
||||||
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
||||||
guard let userInfo = notification.userInfo,
|
guard let userInfo = notification.userInfo,
|
||||||
let accountID = mastodonController.accountInfo?.id,
|
|
||||||
userInfo["accountID"] as? String == accountID,
|
|
||||||
let statusIDs = userInfo["statusIDs"] as? [String] else {
|
let statusIDs = userInfo["statusIDs"] as? [String] else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,13 +61,31 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
|
||||||
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
|
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
|
||||||
}
|
}
|
||||||
|
|
||||||
private let contentContainer = StatusContentContainer<StatusEditContentTextView, StatusEditPollView>(useTopSpacer: false).configure {
|
private lazy var contentContainer = StatusContentContainer(arrangedSubviews: [
|
||||||
$0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont
|
contentTextView,
|
||||||
$0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
|
cardView,
|
||||||
$0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
|
attachmentsView,
|
||||||
|
pollView,
|
||||||
|
] as! [any StatusContentView], useTopSpacer: false).configure {
|
||||||
$0.setContentHuggingPriority(.defaultLow, for: .vertical)
|
$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?
|
weak var delegate: StatusEditCollectionViewCellDelegate?
|
||||||
private var mastodonController: MastodonController! { delegate?.apiController }
|
private var mastodonController: MastodonController! { delegate?.apiController }
|
||||||
|
|
||||||
|
@ -91,8 +109,76 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
|
||||||
fatalError("init(coder:) has not been implemented")
|
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
|
// MARK: Configure UI
|
||||||
|
|
||||||
|
@ -102,13 +188,14 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
|
||||||
|
|
||||||
timestampLabel.text = ConversationMainStatusCollectionViewCell.dateFormatter.string(from: edit.createdAt)
|
timestampLabel.text = ConversationMainStatusCollectionViewCell.dateFormatter.string(from: edit.createdAt)
|
||||||
|
|
||||||
contentContainer.contentTextView.setTextFrom(edit: edit, index: index)
|
contentTextView.attributedText = TimelineStatusCollectionViewCell.htmlConverter.convert(edit.content)
|
||||||
contentContainer.contentTextView.navigationDelegate = delegate
|
contentTextView.setEmojis(edit.emojis, identifier: index)
|
||||||
contentContainer.attachmentsView.delegate = self
|
contentTextView.navigationDelegate = delegate
|
||||||
contentContainer.attachmentsView.updateUI(attachments: edit.attachments)
|
attachmentsView.delegate = self
|
||||||
contentContainer.pollView.isHidden = edit.poll == nil
|
attachmentsView.updateUI(attachments: edit.attachments)
|
||||||
contentContainer.pollView.updateUI(poll: edit.poll, emojis: edit.emojis)
|
pollView.isHidden = edit.poll == nil
|
||||||
contentContainer.cardView.isHidden = true
|
pollView.updateUI(poll: edit.poll, emojis: edit.emojis)
|
||||||
|
cardView.isHidden = true
|
||||||
|
|
||||||
contentWarningLabel.text = edit.spoilerText
|
contentWarningLabel.text = edit.spoilerText
|
||||||
contentWarningLabel.isHidden = edit.spoilerText.isEmpty
|
contentWarningLabel.isHidden = edit.spoilerText.isEmpty
|
||||||
|
@ -151,9 +238,9 @@ extension StatusEditCollectionViewCell: AttachmentViewDelegate {
|
||||||
guard let delegate else {
|
guard let delegate else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let attachments = contentContainer.attachmentsView.attachments!
|
let attachments = attachmentsView.attachments!
|
||||||
let sourceViews = attachments.map {
|
let sourceViews = attachments.map {
|
||||||
contentContainer.attachmentsView.getAttachmentView(for: $0)
|
attachmentsView.getAttachmentView(for: $0)
|
||||||
}
|
}
|
||||||
let gallery = delegate.gallery(attachments: attachments, sourceViews: sourceViews, startIndex: index)
|
let gallery = delegate.gallery(attachments: attachments, sourceViews: sourceViews, startIndex: index)
|
||||||
return gallery
|
return gallery
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
||||||
}
|
|
|
@ -9,7 +9,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
class StatusEditPollView: UIStackView, StatusContentPollView {
|
class StatusEditPollView: UIStackView, StatusContentView {
|
||||||
|
|
||||||
private var titleLabels: [EmojiLabel] = []
|
private var titleLabels: [EmojiLabel] = []
|
||||||
|
|
||||||
|
|
|
@ -32,7 +32,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
|
|
||||||
let timeline: Timeline
|
let timeline: Timeline
|
||||||
weak var mastodonController: MastodonController!
|
weak var mastodonController: MastodonController!
|
||||||
let filterer: Filterer
|
private let filterer: Filterer
|
||||||
|
|
||||||
var persistsState = false
|
var persistsState = false
|
||||||
|
|
||||||
|
@ -61,10 +61,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
default:
|
default:
|
||||||
filterContext = .public
|
filterContext = .public
|
||||||
}
|
}
|
||||||
self.filterer = Filterer(mastodonController: mastodonController, context: filterContext)
|
self.filterer = Filterer(mastodonController: mastodonController, context: filterContext, htmlConverter: TimelineStatusCollectionViewCell.htmlConverter)
|
||||||
self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont
|
|
||||||
self.filterer.htmlConverter.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
|
|
||||||
self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
|
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
@ -164,7 +161,19 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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 {
|
if userActivity != nil {
|
||||||
userActivityNeedsUpdate
|
userActivityNeedsUpdate
|
||||||
|
@ -182,6 +191,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
// separate method because InstanceTimelineViewController needs to be able to customize it
|
// separate method because InstanceTimelineViewController needs to be able to customize it
|
||||||
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) {
|
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) {
|
||||||
cell.delegate = self
|
cell.delegate = self
|
||||||
|
cell.showAttachmentsInline = Preferences.shared.showAttachmentsInTimeline
|
||||||
if case .home = timeline {
|
if case .home = timeline {
|
||||||
cell.showFollowedHashtags = true
|
cell.showFollowedHashtags = true
|
||||||
} else {
|
} else {
|
||||||
|
@ -376,7 +386,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
_ = try await mastodonController.run(req)
|
_ = try await mastodonController.run(req)
|
||||||
} catch {
|
} catch {
|
||||||
stateRestorationLogger.error("TimelineViewController: failed to update timeline marker: \(String(describing: error))")
|
stateRestorationLogger.error("TimelineViewController: failed to update timeline marker: \(String(describing: error))")
|
||||||
|
|
||||||
#if canImport(Sentry)
|
#if canImport(Sentry)
|
||||||
|
if let error = error as? Client.Error,
|
||||||
|
case .networkError(_) = error.type {
|
||||||
|
return
|
||||||
|
}
|
||||||
let event = Event(error: error)
|
let event = Event(error: error)
|
||||||
event.message = SentryMessage(formatted: "Failed to update timeline marker: \(String(describing: error))")
|
event.message = SentryMessage(formatted: "Failed to update timeline marker: \(String(describing: error))")
|
||||||
SentrySDK.capture(event: event)
|
SentrySDK.capture(event: event)
|
||||||
|
@ -411,7 +426,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
}
|
}
|
||||||
let hasStatusesToRestore = await loadStatusesToRestore(position: position)
|
let hasStatusesToRestore = await loadStatusesToRestore(position: position)
|
||||||
if hasStatusesToRestore {
|
if hasStatusesToRestore {
|
||||||
applyItemsToRestore(position: position)
|
await applyItemsToRestore(position: position)
|
||||||
loaded = true
|
loaded = true
|
||||||
}
|
}
|
||||||
case .mastodon:
|
case .mastodon:
|
||||||
|
@ -436,7 +451,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
position.centerStatusID = centerStatusID
|
position.centerStatusID = centerStatusID
|
||||||
let hasStatusesToRestore = await loadStatusesToRestore(position: position)
|
let hasStatusesToRestore = await loadStatusesToRestore(position: position)
|
||||||
if hasStatusesToRestore {
|
if hasStatusesToRestore {
|
||||||
applyItemsToRestore(position: position)
|
await applyItemsToRestore(position: position)
|
||||||
}
|
}
|
||||||
mastodonController.persistentContainer.viewContext.delete(position)
|
mastodonController.persistentContainer.viewContext.delete(position)
|
||||||
}
|
}
|
||||||
|
@ -451,6 +466,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
if let status = mastodonController.persistentContainer.status(for: id) {
|
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
|
// touch the status so that, even if it's old, it doesn't get pruned when we go into the background
|
||||||
status.touch()
|
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 {
|
} else {
|
||||||
unloaded.append(id)
|
unloaded.append(id)
|
||||||
}
|
}
|
||||||
|
@ -519,7 +544,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func applyItemsToRestore(position: TimelinePosition) {
|
private func applyItemsToRestore(position: TimelinePosition) async {
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
snapshot.appendSections([.statuses])
|
snapshot.appendSections([.statuses])
|
||||||
let statusIDs = position.statusIDs
|
let statusIDs = position.statusIDs
|
||||||
|
@ -534,7 +559,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
]
|
]
|
||||||
SentrySDK.addBreadcrumb(crumb)
|
SentrySDK.addBreadcrumb(crumb)
|
||||||
#endif
|
#endif
|
||||||
dataSource.apply(snapshot, animatingDifferences: false) {
|
await apply(snapshot, animatingDifferences: false)
|
||||||
if let centerStatusID,
|
if let centerStatusID,
|
||||||
let index = statusIDs.firstIndex(of: centerStatusID) {
|
let index = statusIDs.firstIndex(of: centerStatusID) {
|
||||||
self.scrollToItem(item: items[index])
|
self.scrollToItem(item: items[index])
|
||||||
|
@ -543,7 +568,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
|
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func restoreStatusesFromMarkerPosition() async -> Bool {
|
private func restoreStatusesFromMarkerPosition() async -> Bool {
|
||||||
|
@ -570,7 +594,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
stateRestorationLogger.error("TimelineViewController: failed to load from timeline marker: \(String(describing: error))")
|
stateRestorationLogger.error("TimelineViewController: failed to load from timeline marker: \(String(describing: error))")
|
||||||
|
|
||||||
#if canImport(Sentry)
|
#if canImport(Sentry)
|
||||||
|
if let error = error as? Client.Error,
|
||||||
|
case .networkError(_) = error.type {
|
||||||
|
return false
|
||||||
|
}
|
||||||
let event = Event(error: error)
|
let event = Event(error: error)
|
||||||
event.message = SentryMessage(formatted: "Failed to load from timeline marker: \(String(describing: error))")
|
event.message = SentryMessage(formatted: "Failed to load from timeline marker: \(String(describing: error))")
|
||||||
SentrySDK.capture(event: event)
|
SentrySDK.capture(event: event)
|
||||||
|
@ -959,8 +988,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
|
|
||||||
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
||||||
guard let userInfo = notification.userInfo,
|
guard let userInfo = notification.userInfo,
|
||||||
let accountID = mastodonController.accountInfo?.id,
|
|
||||||
userInfo["accountID"] as? String == accountID,
|
|
||||||
let statusIDs = userInfo["statusIDs"] as? [String] else {
|
let statusIDs = userInfo["statusIDs"] as? [String] else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import SwiftSoup
|
import HTMLStreamer
|
||||||
|
|
||||||
class AccountCollectionViewCell: UICollectionViewListCell {
|
class AccountCollectionViewCell: UICollectionViewListCell {
|
||||||
|
|
||||||
|
@ -134,8 +134,8 @@ class AccountCollectionViewCell: UICollectionViewListCell {
|
||||||
displayNameLabel.setEmojis(account.emojis, identifier: account.id)
|
displayNameLabel.setEmojis(account.emojis, identifier: account.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
let doc = try! SwiftSoup.parseBodyFragment(account.note)
|
var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self)
|
||||||
noteLabel.text = try! doc.text()
|
noteLabel.text = converter.convert(html: account.note)
|
||||||
noteLabel.setEmojis(account.emojis, identifier: account.id)
|
noteLabel.setEmojis(account.emojis, identifier: account.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -30,8 +30,8 @@ class AttachmentsContainerView: UIView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var blurView: UIVisualEffectView?
|
private var blurView: UIVisualEffectView?
|
||||||
var hideButtonView: UIVisualEffectView?
|
private var hideButtonView: UIVisualEffectView?
|
||||||
var contentHidden: Bool! {
|
var contentHidden: Bool! {
|
||||||
didSet {
|
didSet {
|
||||||
guard let blurView = blurView,
|
guard let blurView = blurView,
|
||||||
|
@ -42,6 +42,8 @@ class AttachmentsContainerView: UIView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var label: UILabel?
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
commonInit()
|
commonInit()
|
||||||
|
@ -67,21 +69,26 @@ class AttachmentsContainerView: UIView {
|
||||||
|
|
||||||
// MARK: - User Interaface
|
// MARK: - User Interaface
|
||||||
|
|
||||||
func updateUI(attachments: [Attachment]) {
|
func updateUI(attachments: [Attachment], labelOnly: Bool = false) {
|
||||||
let newTokens = attachments.map { AttachmentToken(attachment: $0) }
|
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 {
|
guard self.attachmentTokens != newTokens else {
|
||||||
|
self.isHidden = attachments.isEmpty
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self.attachments = attachments
|
self.attachments = attachments
|
||||||
self.attachmentTokens = newTokens
|
self.attachmentTokens = newTokens
|
||||||
|
|
||||||
attachmentViews.allObjects.forEach { $0.removeFromSuperview() }
|
removeAttachmentViews()
|
||||||
attachmentViews.removeAllObjects()
|
hideButtonView?.isHidden = false
|
||||||
attachmentStacks.allObjects.forEach { $0.removeFromSuperview() }
|
|
||||||
attachmentStacks.removeAllObjects()
|
|
||||||
moreView?.removeFromSuperview()
|
|
||||||
|
|
||||||
var accessibilityElements = [Any]()
|
var accessibilityElements = [Any]()
|
||||||
|
|
||||||
|
@ -284,6 +291,14 @@ class AttachmentsContainerView: UIView {
|
||||||
self.accessibilityElements = accessibilityElements
|
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 {
|
private func createAttachmentView(index: Int, hSize: RelativeSize, vSize: RelativeSize) -> AttachmentView {
|
||||||
let attachmentView = AttachmentView(attachment: attachments[index], index: index)
|
let attachmentView = AttachmentView(attachment: attachments[index], index: index)
|
||||||
attachmentView.delegate = delegate
|
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
|
// MARK: - Interaction
|
||||||
|
|
||||||
@objc func blurViewTapped() {
|
@objc func blurViewTapped() {
|
||||||
|
|
|
@ -10,6 +10,13 @@ import UIKit
|
||||||
|
|
||||||
class ConfirmReblogStatusPreviewView: UIView {
|
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>?
|
private var avatarTask: Task<Void, Error>?
|
||||||
|
|
||||||
init(status: StatusMO) {
|
init(status: StatusMO) {
|
||||||
|
@ -60,17 +67,13 @@ class ConfirmReblogStatusPreviewView: UIView {
|
||||||
vStack.addArrangedSubview(displayNameLabel)
|
vStack.addArrangedSubview(displayNameLabel)
|
||||||
|
|
||||||
let contentView = StatusContentTextView()
|
let contentView = StatusContentTextView()
|
||||||
contentView.defaultFont = .preferredFont(forTextStyle: .caption2)
|
|
||||||
contentView.monospaceFont = UIFontMetrics(forTextStyle: .caption2).scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular))
|
|
||||||
contentView.isUserInteractionEnabled = false
|
contentView.isUserInteractionEnabled = false
|
||||||
contentView.isScrollEnabled = false
|
contentView.isScrollEnabled = false
|
||||||
contentView.backgroundColor = nil
|
contentView.backgroundColor = nil
|
||||||
contentView.textContainerInset = .zero
|
contentView.textContainerInset = .zero
|
||||||
contentView.adjustsFontForContentSizeCategory = true
|
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
|
// TODO: line limit
|
||||||
contentView.setTextFrom(status: status)
|
contentView.setTextFrom(status: status, content: ConfirmReblogStatusPreviewView.htmlConverter.convert(status.content))
|
||||||
contentView.translatesAutoresizingMaskIntoConstraints = false
|
contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
vStack.addArrangedSubview(contentView)
|
vStack.addArrangedSubview(contentView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
|
|
|
@ -7,7 +7,6 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import SwiftSoup
|
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import SafariServices
|
import SafariServices
|
||||||
import WebURL
|
import WebURL
|
||||||
|
@ -23,30 +22,19 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
weak var overrideMastodonController: MastodonController?
|
weak var overrideMastodonController: MastodonController?
|
||||||
var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController }
|
var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController }
|
||||||
|
|
||||||
private var htmlConverter = HTMLConverter()
|
private static let defaultBodyHTMLConverter = HTMLConverter(
|
||||||
var defaultFont: UIFont {
|
font: .preferredFont(forTextStyle: .body),
|
||||||
_read { yield htmlConverter.font }
|
monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
|
||||||
_modify { yield &htmlConverter.font }
|
color: .label,
|
||||||
}
|
paragraphStyle: .default
|
||||||
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(set) var hasEmojis = false
|
private(set) var hasEmojis = false
|
||||||
|
|
||||||
var emojiIdentifier: AnyHashable?
|
var emojiIdentifier: AnyHashable?
|
||||||
var emojiRequests: [ImageCache.Request] = []
|
var emojiRequests: [ImageCache.Request] = []
|
||||||
var emojiFont: UIFont { defaultFont }
|
var emojiFont: UIFont = .preferredFont(forTextStyle: .body)
|
||||||
var emojiTextColor: UIColor { defaultColor }
|
var emojiTextColor: UIColor = .label
|
||||||
|
|
||||||
// The link range currently being previewed
|
// The link range currently being previewed
|
||||||
private var currentPreviewedLinkRange: NSRange?
|
private var currentPreviewedLinkRange: NSRange?
|
||||||
|
@ -126,8 +114,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - HTML Parsing
|
// MARK: - HTML Parsing
|
||||||
func setTextFromHtml(_ html: String) {
|
func setBodyTextFromHTML(_ html: String) {
|
||||||
self.attributedText = htmlConverter.convert(html)
|
self.attributedText = ContentTextView.defaultBodyHTMLConverter.convert(html)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Interaction
|
// MARK: - Interaction
|
||||||
|
@ -213,10 +201,12 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
func getViewController(forLink url: URL, inRange range: NSRange) -> UIViewController? {
|
func getViewController(forLink url: URL, inRange range: NSRange) -> UIViewController? {
|
||||||
let text = (self.text as NSString).substring(with: range)
|
let text = (self.text as NSString).substring(with: range)
|
||||||
|
|
||||||
if let mention = getMention(for: url, text: text) {
|
if let mention = getMention(for: url, text: text),
|
||||||
return ProfileViewController(accountID: mention.id, mastodonController: mastodonController!)
|
let mastodonController {
|
||||||
} else if let tag = getHashtag(for: url, text: text) {
|
return ProfileViewController(accountID: mention.id, mastodonController: mastodonController)
|
||||||
return HashtagTimelineViewController(for: tag, 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" {
|
} else if url.scheme == "https" || url.scheme == "http" {
|
||||||
let vc = SFSafariViewController(url: url)
|
let vc = SFSafariViewController(url: url)
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
|
|
|
@ -67,7 +67,8 @@ class TrendingHashtagCollectionViewCell: UICollectionViewCell {
|
||||||
historyView.setHistory(hashtag.history)
|
historyView.setHistory(hashtag.history)
|
||||||
historyView.isHidden = hashtag.history == nil || hashtag.history!.count < 2
|
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 sorted = history.sorted(by: { $0.day < $1.day })
|
||||||
let lastTwo = sorted[(sorted.count - 2)...]
|
let lastTwo = sorted[(sorted.count - 2)...]
|
||||||
let accounts = lastTwo.map(\.accounts).reduce(0, +)
|
let accounts = lastTwo.map(\.accounts).reduce(0, +)
|
||||||
|
|
|
@ -16,7 +16,7 @@ class InstanceTableViewCell: UITableViewCell {
|
||||||
@IBOutlet weak var adultLabel: UILabel!
|
@IBOutlet weak var adultLabel: UILabel!
|
||||||
@IBOutlet weak var descriptionTextView: ContentTextView!
|
@IBOutlet weak var descriptionTextView: ContentTextView!
|
||||||
|
|
||||||
var instance: Instance?
|
var instance: InstanceV1?
|
||||||
var selectorInstance: InstanceSelector.Instance?
|
var selectorInstance: InstanceSelector.Instance?
|
||||||
|
|
||||||
var thumbnailURL: URL?
|
var thumbnailURL: URL?
|
||||||
|
@ -34,8 +34,6 @@ class InstanceTableViewCell: UITableViewCell {
|
||||||
adultLabel.layer.masksToBounds = true
|
adultLabel.layer.masksToBounds = true
|
||||||
adultLabel.layer.cornerRadius = 0.5 * adultLabel.bounds.height
|
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
|
descriptionTextView.adjustsFontForContentSizeCategory = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,17 +47,17 @@ class InstanceTableViewCell: UITableViewCell {
|
||||||
|
|
||||||
domainLabel.text = instance.domain
|
domainLabel.text = instance.domain
|
||||||
adultLabel.isHidden = instance.category != "adult"
|
adultLabel.isHidden = instance.category != "adult"
|
||||||
descriptionTextView.setTextFromHtml(instance.description)
|
descriptionTextView.setBodyTextFromHTML(instance.description)
|
||||||
updateThumbnail(url: instance.proxiedThumbnailURL)
|
updateThumbnail(url: instance.proxiedThumbnailURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUI(instance: Instance) {
|
func updateUI(instance: InstanceV1) {
|
||||||
self.instance = instance
|
self.instance = instance
|
||||||
self.selectorInstance = nil
|
self.selectorInstance = nil
|
||||||
|
|
||||||
domainLabel.text = URLComponents(string: instance.uri)?.host ?? instance.uri
|
domainLabel.text = URLComponents(string: instance.uri)?.host ?? instance.uri
|
||||||
adultLabel.isHidden = true
|
adultLabel.isHidden = true
|
||||||
descriptionTextView.setTextFromHtml(instance.shortDescription ?? instance.description)
|
descriptionTextView.setBodyTextFromHTML(instance.shortDescription ?? instance.description)
|
||||||
|
|
||||||
if let thumbnail = instance.thumbnail {
|
if let thumbnail = instance.thumbnail {
|
||||||
updateThumbnail(url: thumbnail)
|
updateThumbnail(url: thumbnail)
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
class StatusPollView: UIView, StatusContentPollView {
|
class StatusPollView: UIView, StatusContentView {
|
||||||
|
|
||||||
private static let formatter: DateComponentsFormatter = {
|
private static let formatter: DateComponentsFormatter = {
|
||||||
let f = DateComponentsFormatter()
|
let f = DateComponentsFormatter()
|
||||||
|
|
|
@ -14,12 +14,12 @@ import SafariServices
|
||||||
class ProfileFieldValueView: UIView {
|
class ProfileFieldValueView: UIView {
|
||||||
weak var navigationDelegate: TuskerNavigationDelegate?
|
weak var navigationDelegate: TuskerNavigationDelegate?
|
||||||
|
|
||||||
private static let converter: HTMLConverter = {
|
private static let converter = HTMLConverter(
|
||||||
var converter = HTMLConverter()
|
font: .preferredFont(forTextStyle: .body),
|
||||||
converter.font = .preferredFont(forTextStyle: .body)
|
monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
|
||||||
converter.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular))
|
color: .label,
|
||||||
return converter
|
paragraphStyle: .default
|
||||||
}()
|
)
|
||||||
|
|
||||||
private let account: AccountMO
|
private let account: AccountMO
|
||||||
private let field: Account.Field
|
private let field: Account.Field
|
||||||
|
|
|
@ -95,8 +95,6 @@ class ProfileHeaderView: UIView {
|
||||||
relationshipLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14))
|
relationshipLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14))
|
||||||
relationshipLabel.adjustsFontForContentSizeCategory = true
|
relationshipLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
|
||||||
noteTextView.defaultFont = .preferredFont(forTextStyle: .body)
|
|
||||||
noteTextView.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular))
|
|
||||||
noteTextView.adjustsFontForContentSizeCategory = true
|
noteTextView.adjustsFontForContentSizeCategory = true
|
||||||
|
|
||||||
pagesSegmentedControl = ScrollingSegmentedControl(frame: .zero)
|
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) ?? [])
|
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, source: .view(moreButton), fetchRelationship: false) ?? [])
|
||||||
|
|
||||||
noteTextView.navigationDelegate = delegate
|
noteTextView.navigationDelegate = delegate
|
||||||
noteTextView.setTextFromHtml(account.note)
|
noteTextView.setBodyTextFromHTML(account.note)
|
||||||
noteTextView.setEmojis(account.emojis, identifier: account.id)
|
noteTextView.setEmojis(account.emojis, identifier: account.id)
|
||||||
|
|
||||||
if accountID == mastodonController.account?.id {
|
if accountID == mastodonController.account?.id {
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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"/>
|
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<deployment identifier="iOS"/>
|
<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="Safe area layout guides" minToolsVersion="9.0"/>
|
||||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
|
@ -46,7 +46,7 @@
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</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">
|
<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>
|
<constraints>
|
||||||
<constraint firstAttribute="width" secondItem="vFa-g3-xIP" secondAttribute="height" multiplier="1:1" id="B01-24-GJj"/>
|
<constraint firstAttribute="width" secondItem="vFa-g3-xIP" secondAttribute="height" multiplier="1:1" id="B01-24-GJj"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
|
@ -54,7 +54,7 @@
|
||||||
<buttonConfiguration key="configuration" style="plain" image="ellipsis" catalog="system"/>
|
<buttonConfiguration key="configuration" style="plain" image="ellipsis" catalog="system"/>
|
||||||
</button>
|
</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">
|
<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"/>
|
<state key="normal" title="Button"/>
|
||||||
<buttonConfiguration key="configuration" style="plain" image="person.badge.plus" catalog="system" title="Follow" imagePadding="4"/>
|
<buttonConfiguration key="configuration" style="plain" image="person.badge.plus" catalog="system" title="Follow" imagePadding="4"/>
|
||||||
<connections>
|
<connections>
|
||||||
|
@ -123,13 +123,6 @@
|
||||||
</imageView>
|
</imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
</stackView>
|
</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>
|
</subviews>
|
||||||
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
|
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
|
||||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
<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="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="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="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="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="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 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="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="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"/>
|
<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="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="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="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 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 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"/>
|
<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>
|
<resources>
|
||||||
<image name="ellipsis" catalog="system" width="128" height="37"/>
|
<image name="ellipsis" catalog="system" width="128" height="37"/>
|
||||||
<image name="lock.fill" catalog="system" width="125" height="128"/>
|
<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">
|
<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>
|
||||||
<systemColor name="secondaryLabelColor">
|
<systemColor name="secondaryLabelColor">
|
||||||
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
|
<color red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" 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"/>
|
|
||||||
</systemColor>
|
</systemColor>
|
||||||
<systemColor name="systemBackgroundColor">
|
<systemColor name="systemBackgroundColor">
|
||||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
|
|
@ -17,6 +17,13 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
||||||
static let contentParagraphStyle = HTMLConverter.defaultParagraphStyle
|
static let contentParagraphStyle = HTMLConverter.defaultParagraphStyle
|
||||||
static let metaFont = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 15))
|
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 = {
|
static let dateFormatter: DateFormatter = {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateStyle = .medium
|
formatter.dateStyle = .medium
|
||||||
|
@ -117,18 +124,38 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
||||||
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
|
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
|
||||||
}
|
}
|
||||||
|
|
||||||
let contentContainer = StatusContentContainer<StatusContentTextView, StatusPollView>(useTopSpacer: true).configure {
|
private(set) lazy var contentContainer = StatusContentContainer(arrangedSubviews: [
|
||||||
$0.contentTextView.defaultFont = ConversationMainStatusCollectionViewCell.contentFont
|
contentTextView,
|
||||||
$0.contentTextView.monospaceFont = ConversationMainStatusCollectionViewCell.monospaceFont
|
cardView,
|
||||||
$0.contentTextView.paragraphStyle = ConversationMainStatusCollectionViewCell.contentParagraphStyle
|
attachmentsView,
|
||||||
$0.contentTextView.isSelectable = true
|
pollView,
|
||||||
$0.contentTextView.dataDetectorTypes = [.flightNumber, .address, .shipmentTrackingNumber, .phoneNumber]
|
] as! [any StatusContentView], useTopSpacer: true).configure {
|
||||||
if #available(iOS 16.0, *) {
|
|
||||||
$0.contentTextView.dataDetectorTypes.formUnion([.money, .physicalValue])
|
|
||||||
}
|
|
||||||
$0.setContentHuggingPriority(.defaultLow, for: .vertical)
|
$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 {
|
private lazy var favoritesCountButton = UIButton().configure {
|
||||||
$0.titleLabel!.adjustsFontForContentSizeCategory = true
|
$0.titleLabel!.adjustsFontForContentSizeCategory = true
|
||||||
$0.addTarget(self, action: #selector(favoritesCountPressed), for: .touchUpInside)
|
$0.addTarget(self, action: #selector(favoritesCountPressed), for: .touchUpInside)
|
||||||
|
@ -319,6 +346,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
||||||
var mastodonController: MastodonController! { delegate?.apiController }
|
var mastodonController: MastodonController! { delegate?.apiController }
|
||||||
weak var delegate: StatusCollectionViewCellDelegate?
|
weak var delegate: StatusCollectionViewCellDelegate?
|
||||||
var showStatusAutomatically = false
|
var showStatusAutomatically = false
|
||||||
|
var translateStatus: (() -> Void)?
|
||||||
|
|
||||||
var statusID: String!
|
var statusID: String!
|
||||||
var statusState: CollapseState!
|
var statusState: CollapseState!
|
||||||
|
@ -348,9 +376,9 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
||||||
accountDetailAccessibilityElement,
|
accountDetailAccessibilityElement,
|
||||||
contentWarningLabel,
|
contentWarningLabel,
|
||||||
collapseButton,
|
collapseButton,
|
||||||
contentContainer.contentTextView,
|
contentTextView,
|
||||||
contentContainer.attachmentsView,
|
attachmentsView,
|
||||||
contentContainer.pollView,
|
pollView,
|
||||||
favoritesCountButton,
|
favoritesCountButton,
|
||||||
reblogsCountButton,
|
reblogsCountButton,
|
||||||
timestampAndClientLabel,
|
timestampAndClientLabel,
|
||||||
|
@ -378,7 +406,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
||||||
|
|
||||||
// MARK: Configure UI
|
// 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 {
|
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||||
fatalError()
|
fatalError()
|
||||||
}
|
}
|
||||||
|
@ -388,7 +416,20 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
||||||
self.statusID = statusID
|
self.statusID = statusID
|
||||||
self.statusState = state
|
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
|
accountDetailToContentWarningSpacer.isHidden = collapseButton.isHidden
|
||||||
contentWarningToCollapseButtonSpacer.isHidden = collapseButton.isHidden
|
contentWarningToCollapseButtonSpacer.isHidden = collapseButton.isHidden
|
||||||
|
|
||||||
|
@ -413,6 +454,31 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
||||||
} else {
|
} else {
|
||||||
editTimestampButton.isHidden = true
|
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() {
|
private func createObservers() {
|
||||||
|
@ -517,6 +583,18 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
||||||
delegate?.show(StatusEditHistoryViewController(statusID: statusID, mastodonController: mastodonController), sender: nil)
|
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 {
|
private class ConversationMainStatusAccountDetailAccessibilityElement: UIAccessibilityElement {
|
||||||
|
@ -585,3 +663,13 @@ extension ConversationMainStatusCollectionViewCell: UIPointerInteractionDelegate
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class TranslateButton: UIButton, StatusContentView {
|
||||||
|
var statusContentFillsHorizontally: Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import SafariServices
|
import SafariServices
|
||||||
import WebURLFoundationExtras
|
import WebURLFoundationExtras
|
||||||
import SwiftSoup
|
import HTMLStreamer
|
||||||
|
|
||||||
class StatusCardView: UIView {
|
class StatusCardView: UIView {
|
||||||
|
|
||||||
|
@ -198,7 +198,8 @@ class StatusCardView: UIView {
|
||||||
titleLabel.text = title
|
titleLabel.text = title
|
||||||
titleLabel.isHidden = title.isEmpty
|
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.text = description
|
||||||
descriptionLabel.isHidden = description.isEmpty
|
descriptionLabel.isHidden = description.isEmpty
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,11 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate
|
||||||
var usernameLabel: UILabel { get }
|
var usernameLabel: UILabel { get }
|
||||||
var contentWarningLabel: EmojiLabel { get }
|
var contentWarningLabel: EmojiLabel { get }
|
||||||
var collapseButton: StatusCollapseButton { 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 replyButton: UIButton { get }
|
||||||
var favoriteButton: ToggleableButton { get }
|
var favoriteButton: ToggleableButton { get }
|
||||||
var reblogButton: ToggleableButton { get }
|
var reblogButton: ToggleableButton { get }
|
||||||
|
@ -44,6 +48,7 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate
|
||||||
var isGrayscale: Bool { get set }
|
var isGrayscale: Bool { get set }
|
||||||
var cancellables: Set<AnyCancellable> { get set }
|
var cancellables: Set<AnyCancellable> { get set }
|
||||||
|
|
||||||
|
func updateAttachmentsUI(status: StatusMO)
|
||||||
func updateUIForPreferences(status: StatusMO)
|
func updateUIForPreferences(status: StatusMO)
|
||||||
func updateStatusState(status: StatusMO)
|
func updateStatusState(status: StatusMO)
|
||||||
func estimateContentHeight() -> CGFloat
|
func estimateContentHeight() -> CGFloat
|
||||||
|
@ -83,27 +88,26 @@ extension StatusCollectionViewCell {
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
func doUpdateUI(status: StatusMO, precomputedContent: NSAttributedString? = nil) {
|
func doUpdateUI(status: StatusMO, content: NSAttributedString) {
|
||||||
statusID = status.id
|
statusID = status.id
|
||||||
accountID = status.account.id
|
accountID = status.account.id
|
||||||
|
|
||||||
updateAccountUI(account: status.account)
|
updateAccountUI(account: status.account)
|
||||||
|
|
||||||
contentContainer.contentTextView.setTextFrom(status: status, precomputed: precomputedContent)
|
contentTextView.setTextFrom(status: status, content: content)
|
||||||
contentContainer.contentTextView.navigationDelegate = delegate
|
contentTextView.navigationDelegate = delegate
|
||||||
contentContainer.attachmentsView.delegate = self
|
self.updateAttachmentsUI(status: status)
|
||||||
contentContainer.attachmentsView.updateUI(attachments: status.attachments)
|
pollView.isHidden = status.poll == nil
|
||||||
contentContainer.pollView.isHidden = status.poll == nil
|
pollView.mastodonController = mastodonController
|
||||||
contentContainer.pollView.mastodonController = mastodonController
|
pollView.delegate = delegate
|
||||||
contentContainer.pollView.delegate = delegate
|
pollView.updateUI(status: status, poll: status.poll)
|
||||||
contentContainer.pollView.updateUI(status: status, poll: status.poll)
|
|
||||||
if Preferences.shared.showLinkPreviews {
|
if Preferences.shared.showLinkPreviews {
|
||||||
contentContainer.cardView.updateUI(status: status)
|
cardView.updateUI(status: status)
|
||||||
contentContainer.cardView.isHidden = status.card == nil
|
cardView.isHidden = status.card == nil
|
||||||
contentContainer.cardView.navigationDelegate = delegate
|
cardView.navigationDelegate = delegate
|
||||||
contentContainer.cardView.actionProvider = delegate
|
cardView.actionProvider = delegate
|
||||||
} else {
|
} else {
|
||||||
contentContainer.cardView.isHidden = true
|
cardView.isHidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
updateUIForPreferences(status: status)
|
updateUIForPreferences(status: status)
|
||||||
|
@ -167,6 +171,11 @@ extension StatusCollectionViewCell {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateAttachmentsUI(status: StatusMO) {
|
||||||
|
attachmentsView.delegate = self
|
||||||
|
attachmentsView.updateUI(attachments: status.attachments)
|
||||||
|
}
|
||||||
|
|
||||||
func updateAccountUI(account: AccountMO) {
|
func updateAccountUI(account: AccountMO) {
|
||||||
avatarImageView.update(for: account.avatar)
|
avatarImageView.update(for: account.avatar)
|
||||||
displayNameLabel.updateForAccountDisplayName(account: account)
|
displayNameLabel.updateForAccountDisplayName(account: account)
|
||||||
|
@ -177,20 +186,20 @@ extension StatusCollectionViewCell {
|
||||||
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.avatarImageViewSize
|
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.avatarImageViewSize
|
||||||
|
|
||||||
let newCardHidden = !Preferences.shared.showLinkPreviews || status.card == nil
|
let newCardHidden = !Preferences.shared.showLinkPreviews || status.card == nil
|
||||||
if contentContainer.cardView.isHidden != newCardHidden {
|
if cardView.isHidden != newCardHidden {
|
||||||
delegate?.statusCellNeedsReconfigure(self, animated: false, completion: nil)
|
delegate?.statusCellNeedsReconfigure(self, animated: false, completion: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch Preferences.shared.attachmentBlurMode {
|
switch Preferences.shared.attachmentBlurMode {
|
||||||
case .never:
|
case .never:
|
||||||
contentContainer.attachmentsView.contentHidden = false
|
attachmentsView.contentHidden = false
|
||||||
case .always:
|
case .always:
|
||||||
contentContainer.attachmentsView.contentHidden = true
|
attachmentsView.contentHidden = true
|
||||||
default:
|
default:
|
||||||
if status.sensitive {
|
if status.sensitive {
|
||||||
contentContainer.attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
|
attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
|
||||||
} else {
|
} else {
|
||||||
contentContainer.attachmentsView.contentHidden = false
|
attachmentsView.contentHidden = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -206,8 +215,8 @@ extension StatusCollectionViewCell {
|
||||||
// only called when isGrayscale does not match the pref
|
// only called when isGrayscale does not match the pref
|
||||||
func updateGrayscaleableUI(status: StatusMO) {
|
func updateGrayscaleableUI(status: StatusMO) {
|
||||||
isGrayscale = Preferences.shared.grayscaleImages
|
isGrayscale = Preferences.shared.grayscaleImages
|
||||||
if contentContainer.contentTextView.hasEmojis {
|
if contentTextView.hasEmojis {
|
||||||
contentContainer.contentTextView.setEmojis(status.emojis, identifier: status.id)
|
contentTextView.setEmojis(status.emojis, identifier: status.id)
|
||||||
}
|
}
|
||||||
displayNameLabel.updateForAccountDisplayName(account: status.account)
|
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
|
// 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) ?? [])
|
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, source: .view(moreButton), includeStatusButtonActions: false) ?? [])
|
||||||
|
|
||||||
contentContainer.pollView.isHidden = status.poll == nil
|
pollView.isHidden = status.poll == nil
|
||||||
contentContainer.pollView.mastodonController = mastodonController
|
pollView.mastodonController = mastodonController
|
||||||
contentContainer.pollView.delegate = delegate
|
pollView.delegate = delegate
|
||||||
contentContainer.pollView.updateUI(status: status, poll: status.poll)
|
pollView.updateUI(status: status, poll: status.poll)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setShowThreadLinks(prev: Bool, next: Bool) {
|
func setShowThreadLinks(prev: Bool, next: Bool) {
|
||||||
|
@ -322,7 +331,7 @@ extension StatusCollectionViewCell {
|
||||||
func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? {
|
func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? {
|
||||||
guard let delegate = delegate,
|
guard let delegate = delegate,
|
||||||
let status = mastodonController.persistentContainer.status(for: statusID) else { return nil }
|
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)
|
let gallery = delegate.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index)
|
||||||
// TODO: PiP
|
// TODO: PiP
|
||||||
// gallery.avPlayerViewControllerDelegate = self
|
// gallery.avPlayerViewControllerDelegate = self
|
||||||
|
|
|
@ -8,45 +8,11 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
protocol StatusContentPollView: UIView {
|
class StatusContentContainer: UIView {
|
||||||
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat
|
// TODO: this is a weird place for this
|
||||||
}
|
static var cardViewHeight: CGFloat { 90 }
|
||||||
|
|
||||||
class StatusContentContainer<ContentView: ContentTextView, PollView: StatusContentPollView>: UIView {
|
private var arrangedSubviews: [any StatusContentView]
|
||||||
|
|
||||||
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 isHiddenObservations: [NSKeyValueObservation] = []
|
private var isHiddenObservations: [NSKeyValueObservation] = []
|
||||||
|
|
||||||
|
@ -61,8 +27,12 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
|
||||||
subviews.filter { !$0.isHidden }.map(\.bounds.height).reduce(0, +)
|
subviews.filter { !$0.isHidden }.map(\.bounds.height).reduce(0, +)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(useTopSpacer: Bool) {
|
init(arrangedSubviews: [any StatusContentView], useTopSpacer: Bool) {
|
||||||
self.useTopSpacer = useTopSpacer
|
var arrangedSubviews = arrangedSubviews
|
||||||
|
if useTopSpacer {
|
||||||
|
arrangedSubviews.insert(TopSpacerView(), at: 0)
|
||||||
|
}
|
||||||
|
self.arrangedSubviews = arrangedSubviews
|
||||||
|
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
@ -70,10 +40,14 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
|
||||||
subview.translatesAutoresizingMaskIntoConstraints = false
|
subview.translatesAutoresizingMaskIntoConstraints = false
|
||||||
addSubview(subview)
|
addSubview(subview)
|
||||||
|
|
||||||
|
if subview.statusContentFillsHorizontally {
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
|
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
|
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
|
// 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()
|
setNeedsUpdateConstraints()
|
||||||
|
|
||||||
isHiddenObservations = arrangedSubviews.map {
|
updateObservations()
|
||||||
$0.observe(\.isHidden) { [unowned self] _, _ in
|
|
||||||
self.setNeedsUpdateConstraints()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateObservations() {
|
||||||
|
isHiddenObservations = arrangedSubviews.map {
|
||||||
|
$0.observeIsHidden { [unowned self] in
|
||||||
|
self.setNeedsUpdateConstraints()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override func updateConstraints() {
|
override func updateConstraints() {
|
||||||
let visibleSubviews = IndexSet(arrangedSubviews.indices.filter { !arrangedSubviews[$0].isHidden })
|
let visibleSubviews = IndexSet(arrangedSubviews.indices.filter { !arrangedSubviews[$0].isHidden })
|
||||||
if self.visibleSubviews != visibleSubviews {
|
if self.visibleSubviews != visibleSubviews {
|
||||||
|
@ -129,6 +107,31 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
|
||||||
super.updateConstraints()
|
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) {
|
func setCollapsed(_ collapsed: Bool) {
|
||||||
guard collapsed != isCollapsed else {
|
guard collapsed != isCollapsed else {
|
||||||
return
|
return
|
||||||
|
@ -147,18 +150,67 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
|
||||||
// just roughly inline with the content height
|
// just roughly inline with the content height
|
||||||
func estimateVisibleSubviewHeight(effectiveWidth: CGFloat) -> CGFloat {
|
func estimateVisibleSubviewHeight(effectiveWidth: CGFloat) -> CGFloat {
|
||||||
var height: CGFloat = 0
|
var height: CGFloat = 0
|
||||||
height += contentTextView.sizeThatFits(CGSize(width: effectiveWidth, height: UIView.layoutFittingCompressedSize.height)).height
|
for view in arrangedSubviews where !view.isHidden {
|
||||||
if !cardView.isHidden {
|
height += view.estimateHeight(effectiveWidth: effectiveWidth)
|
||||||
height += StatusContentContainer.cardViewHeight
|
|
||||||
}
|
|
||||||
if !attachmentsView.isHidden {
|
|
||||||
height += effectiveWidth / attachmentsView.aspectRatio
|
|
||||||
}
|
|
||||||
if !pollView.isHidden {
|
|
||||||
let pollHeight = pollView.estimateHeight(effectiveWidth: effectiveWidth)
|
|
||||||
height += pollHeight
|
|
||||||
}
|
}
|
||||||
return height
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -20,6 +20,13 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
static let monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 16, weight: .regular))
|
static let monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 16, weight: .regular))
|
||||||
static let contentParagraphStyle = HTMLConverter.defaultParagraphStyle
|
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
|
private static let timelineReasonIconSize: CGFloat = 25
|
||||||
|
|
||||||
// MARK: Subviews
|
// MARK: Subviews
|
||||||
|
@ -186,25 +193,32 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
$0.setContentCompressionResistancePriority(.required, for: .vertical)
|
$0.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||||
}
|
}
|
||||||
|
|
||||||
let contentContainer = StatusContentContainer<StatusContentTextView, StatusPollView>(useTopSpacer: false).configure {
|
private(set) lazy var contentContainer = StatusContentContainer(arrangedSubviews: [
|
||||||
$0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont
|
contentTextView,
|
||||||
$0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
|
cardView,
|
||||||
$0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
|
attachmentsView,
|
||||||
|
pollView,
|
||||||
|
] as! [any StatusContentView], useTopSpacer: false).configure {
|
||||||
$0.setContentHuggingPriority(.defaultLow, for: .vertical)
|
$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
|
let cardView = StatusCardView().configure {
|
||||||
}
|
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
|
||||||
private var attachmentsView: AttachmentsContainerView {
|
|
||||||
contentContainer.attachmentsView
|
|
||||||
}
|
|
||||||
private var pollView: StatusPollView {
|
|
||||||
contentContainer.pollView
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let attachmentsView = AttachmentsContainerView()
|
||||||
|
|
||||||
|
let pollView = StatusPollView()
|
||||||
|
|
||||||
#if os(visionOS)
|
#if os(visionOS)
|
||||||
private lazy var actionsContainer = UIStackView(arrangedSubviews: [
|
private lazy var actionsContainer = UIStackView(arrangedSubviews: [
|
||||||
replyButton,
|
replyButton,
|
||||||
|
@ -333,6 +347,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
var showReplyIndicator = true
|
var showReplyIndicator = true
|
||||||
var showPinned: Bool = false
|
var showPinned: Bool = false
|
||||||
var showFollowedHashtags: Bool = false
|
var showFollowedHashtags: Bool = false
|
||||||
|
var showAttachmentsInline = true
|
||||||
|
|
||||||
// alas these need to be internal so they're accessible from the protocol extensions
|
// alas these need to be internal so they're accessible from the protocol extensions
|
||||||
var statusID: String!
|
var statusID: String!
|
||||||
|
@ -629,9 +644,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
if let reblogStatus {
|
if let reblogStatus {
|
||||||
hideTimelineReason = false
|
hideTimelineReason = false
|
||||||
updateRebloggerLabel(reblogger: reblogStatus.account)
|
updateRebloggerLabel(reblogger: reblogStatus.account)
|
||||||
}
|
} else if showFollowedHashtags {
|
||||||
|
|
||||||
if showFollowedHashtags {
|
|
||||||
let hashtags = mastodonController.followedHashtags.filter({ followed in status.hashtags.contains(where: { followed.name == $0.name }) })
|
let hashtags = mastodonController.followedHashtags.filter({ followed in status.hashtags.contains(where: { followed.name == $0.name }) })
|
||||||
if !hashtags.isEmpty {
|
if !hashtags.isEmpty {
|
||||||
hideTimelineReason = false
|
hideTimelineReason = false
|
||||||
|
@ -651,7 +664,12 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
mainContainerTopToReblogLabelConstraint.isActive = true
|
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)
|
doUpdateTimestamp(status: status)
|
||||||
timestampLabel.isHidden = showPinned
|
timestampLabel.isHidden = showPinned
|
||||||
|
@ -694,6 +712,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
baseUpdateStatusState(status: status)
|
baseUpdateStatusState(status: status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateAttachmentsUI(status: StatusMO) {
|
||||||
|
attachmentsView.delegate = self
|
||||||
|
attachmentsView.updateUI(attachments: status.attachments, labelOnly: !showAttachmentsInline)
|
||||||
|
}
|
||||||
|
|
||||||
func estimateContentHeight() -> CGFloat {
|
func estimateContentHeight() -> CGFloat {
|
||||||
let width = bounds.width /* leading spacing: */ - 16 /* avatar: */ - 50 /* spacing: 8 */ - 8 /* trailing spacing: */ - 16
|
let width = bounds.width /* leading spacing: */ - 16 /* avatar: */ - 50 /* spacing: 8 */ - 8 /* trailing spacing: */ - 16
|
||||||
return contentContainer.estimateVisibleSubviewHeight(effectiveWidth: width)
|
return contentContainer.estimateVisibleSubviewHeight(effectiveWidth: width)
|
||||||
|
|
|
@ -14,13 +14,9 @@ class StatusContentTextView: ContentTextView {
|
||||||
|
|
||||||
private var statusID: String?
|
private var statusID: String?
|
||||||
|
|
||||||
func setTextFrom(status: some StatusProtocol, precomputed attributedText: NSAttributedString? = nil) {
|
func setTextFrom(status: some StatusProtocol, content attributedText: NSAttributedString) {
|
||||||
statusID = status.id
|
statusID = status.id
|
||||||
if let attributedText {
|
|
||||||
self.attributedText = attributedText
|
self.attributedText = attributedText
|
||||||
} else {
|
|
||||||
setTextFromHtml(status.content)
|
|
||||||
}
|
|
||||||
setEmojis(status.emojis, identifier: status.id)
|
setEmojis(status.emojis, identifier: status.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,36 @@ class AttributedStringHelperTests: XCTestCase {
|
||||||
override func tearDown() {
|
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() {
|
func testCollapsingWhitespace() {
|
||||||
var str = NSAttributedString(string: "test 1\n")
|
var str = NSAttributedString(string: "test 1\n")
|
||||||
XCTAssertEqual(str.collapsingWhitespace(), NSAttributedString(string: "test 1\n"))
|
XCTAssertEqual(str.collapsingWhitespace(), NSAttributedString(string: "test 1\n"))
|
||||||
|
|
|
@ -9,8 +9,8 @@
|
||||||
// Configuration settings file format documentation can be found at:
|
// Configuration settings file format documentation can be found at:
|
||||||
// https://help.apple.com/xcode/#/dev745c5c974
|
// https://help.apple.com/xcode/#/dev745c5c974
|
||||||
|
|
||||||
MARKETING_VERSION = 2023.8
|
MARKETING_VERSION = 2024.1
|
||||||
CURRENT_PROJECT_VERSION = 106
|
CURRENT_PROJECT_VERSION = 111
|
||||||
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
|
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
|
||||||
|
|
||||||
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev
|
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev
|
||||||
|
|
Loading…
Reference in New Issue