diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 5201c5409..8fe09fe6d 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -183,6 +183,7 @@ DB00CA972632DDB600A54956 /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB00CA962632DDB600A54956 /* CommonOSLog */; }; DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; }; DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; }; + DB023295267F0AB800031745 /* ASMetaEditableTextNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB023294267F0AB800031745 /* ASMetaEditableTextNode.swift */; }; DB029E95266A20430062874E /* MastodonAuthenticationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB029E94266A20430062874E /* MastodonAuthenticationController.swift */; }; DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */; }; DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; }; @@ -202,6 +203,9 @@ DB1D84382657B275000346B3 /* SegmentedControlNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D84372657B275000346B3 /* SegmentedControlNavigateable.swift */; }; DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */; }; DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E347725F519300079D7DF /* PickServerItem.swift */; }; + DB1EE7AE267F3071000CC337 /* MastodonStatusContent+ParseResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1EE7AD267F3071000CC337 /* MastodonStatusContent+ParseResult.swift */; }; + DB1EE7B0267F3088000CC337 /* MastodonStatusContent+Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1EE7AF267F3088000CC337 /* MastodonStatusContent+Appearance.swift */; }; + DB1EE7B2267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1EE7B1267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift */; }; DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */; }; DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */; }; DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */; }; @@ -416,6 +420,7 @@ DBAC6499267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6498267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift */; }; DBAC649B267DF8C8007FE9FD /* ActivityIndicatorNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC649A267DF8C8007FE9FD /* ActivityIndicatorNode.swift */; }; DBAC649E267DFE43007FE9FD /* DiffableDataSources in Frameworks */ = {isa = PBXBuildFile; productRef = DBAC649D267DFE43007FE9FD /* DiffableDataSources */; }; + DBAC64A1267E6D02007FE9FD /* Fuzi in Frameworks */ = {isa = PBXBuildFile; productRef = DBAC64A0267E6D02007FE9FD /* Fuzi */; }; DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F672615DD60004B8251 /* UserProvider.swift */; }; DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */; }; DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */; }; @@ -765,6 +770,7 @@ CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-AppShared.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-AppShared/Pods-Mastodon-AppShared.debug.xcconfig"; sourceTree = ""; }; DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; + DB023294267F0AB800031745 /* ASMetaEditableTextNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASMetaEditableTextNode.swift; sourceTree = ""; }; DB029E94266A20430062874E /* MastodonAuthenticationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationController.swift; sourceTree = ""; }; DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReplyLoaderTableViewCell.swift; sourceTree = ""; }; DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = ""; }; @@ -786,6 +792,9 @@ DB1D84372657B275000346B3 /* SegmentedControlNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedControlNavigateable.swift; sourceTree = ""; }; DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = ""; }; DB1E347725F519300079D7DF /* PickServerItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PickServerItem.swift; sourceTree = ""; }; + DB1EE7AD267F3071000CC337 /* MastodonStatusContent+ParseResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonStatusContent+ParseResult.swift"; sourceTree = ""; }; + DB1EE7AF267F3088000CC337 /* MastodonStatusContent+Appearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonStatusContent+Appearance.swift"; sourceTree = ""; }; + DB1EE7B1267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+StatusNodeDelegate.swift"; sourceTree = ""; }; DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+LoadIndexedServerState.swift"; sourceTree = ""; }; DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSection.swift; sourceTree = ""; }; DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+Diffable.swift"; sourceTree = ""; }; @@ -1083,6 +1092,7 @@ DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */, DBAC6483267D0B21007FE9FD /* DifferenceKit in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, + DBAC64A1267E6D02007FE9FD /* Fuzi in Frameworks */, DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */, DBAC649E267DFE43007FE9FD /* DiffableDataSources in Frameworks */, 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */, @@ -1299,6 +1309,7 @@ 2D38F1FD25CD481700561493 /* StatusProvider.swift */, 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */, 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */, + DB1EE7B1267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift */, DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */, DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */, DB1D843526579DB5000346B3 /* StatusProvider+TableViewControllerNavigateable.swift */, @@ -1654,6 +1665,16 @@ path = Onboarding; sourceTree = ""; }; + DB023296267F0ABE00031745 /* Status */ = { + isa = PBXGroup; + children = ( + DBAC6484267D0F9E007FE9FD /* StatusNode.swift */, + DBAC6496267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift */, + DBAC6498267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift */, + ); + path = Status; + sourceTree = ""; + }; DB084B5125CBC56300F898ED /* CoreDataStack */ = { isa = PBXGroup; children = ( @@ -2307,6 +2328,8 @@ isa = PBXGroup; children = ( 2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */, + DB1EE7AD267F3071000CC337 /* MastodonStatusContent+ParseResult.swift */, + DB1EE7AF267F3088000CC337 /* MastodonStatusContent+Appearance.swift */, DB6F5E2E264E5518009108F4 /* MastodonRegex.swift */, DB35FC2E26130172006193C9 /* MastodonField.swift */, DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */, @@ -2353,9 +2376,8 @@ DBAC6486267D0FAC007FE9FD /* Node */ = { isa = PBXGroup; children = ( - DBAC6484267D0F9E007FE9FD /* StatusNode.swift */, - DBAC6496267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift */, - DBAC6498267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift */, + DB023296267F0ABE00031745 /* Status */, + DB023294267F0AB800031745 /* ASMetaEditableTextNode.swift */, ); path = Node; sourceTree = ""; @@ -2553,6 +2575,7 @@ DBAEDE5E267A0B1500D25FF5 /* Nuke */, DBAC6482267D0B21007FE9FD /* DifferenceKit */, DBAC649D267DFE43007FE9FD /* DiffableDataSources */, + DBAC64A0267E6D02007FE9FD /* Fuzi */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -2743,6 +2766,7 @@ DBAEDE5D267A0B1500D25FF5 /* XCRemoteSwiftPackageReference "Nuke" */, DBAC6481267D0B21007FE9FD /* XCRemoteSwiftPackageReference "DifferenceKit" */, DBAC649C267DFE43007FE9FD /* XCRemoteSwiftPackageReference "DiffableDataSources" */, + DBAC649F267E6D01007FE9FD /* XCRemoteSwiftPackageReference "Fuzi" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -3210,6 +3234,7 @@ DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, DBA94434265CBB5300C537E1 /* ProfileFieldSection.swift in Sources */, DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */, + DB023295267F0AB800031745 /* ASMetaEditableTextNode.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */, DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */, @@ -3283,6 +3308,7 @@ DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, 2D7867192625B77500211898 /* NotificationItem.swift in Sources */, + DB1EE7AE267F3071000CC337 /* MastodonStatusContent+ParseResult.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, @@ -3299,6 +3325,7 @@ DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, 2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */, + DB1EE7B0267F3088000CC337 /* MastodonStatusContent+Appearance.swift in Sources */, 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+Provider.swift in Sources */, 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */, 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */, @@ -3353,6 +3380,7 @@ DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */, DBBF1DC226524D2900E5B703 /* AutoCompleteTableViewCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, + DB1EE7B2267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift in Sources */, 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */, 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */, DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */, @@ -4186,6 +4214,14 @@ kind = branch; }; }; + DBAC649F267E6D01007FE9FD /* XCRemoteSwiftPackageReference "Fuzi" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/cezheng/Fuzi.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.1.3; + }; + }; DBAEDE5D267A0B1500D25FF5 /* XCRemoteSwiftPackageReference "Nuke" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/kean/Nuke.git"; @@ -4279,6 +4315,11 @@ package = DBAC649C267DFE43007FE9FD /* XCRemoteSwiftPackageReference "DiffableDataSources" */; productName = DiffableDataSources; }; + DBAC64A0267E6D02007FE9FD /* Fuzi */ = { + isa = XCSwiftPackageProductDependency; + package = DBAC649F267E6D01007FE9FD /* XCRemoteSwiftPackageReference "Fuzi" */; + productName = Fuzi; + }; DBAEDE5E267A0B1500D25FF5 /* Nuke */ = { isa = XCSwiftPackageProductDependency; package = DBAEDE5D267A0B1500D25FF5 /* XCRemoteSwiftPackageReference "Nuke" */; diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 50a025853..d2befef16 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ AppShared.xcscheme_^#shared#^_ orderHint - 19 + 20 CoreDataStack.xcscheme_^#shared#^_ @@ -32,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 20 + 19 SuppressBuildableAutocreation diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index f46eff823..785af99e3 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -64,6 +64,15 @@ "version": "1.2.0" } }, + { + "package": "Fuzi", + "repositoryURL": "https://github.com/cezheng/Fuzi.git", + "state": { + "branch": null, + "revision": "f08c8323da21e985f3772610753bcfc652c2103f", + "version": "3.1.3" + } + }, { "package": "KeychainAccess", "repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess.git", diff --git a/Mastodon/Helper/MastodonStatusContent+Appearance.swift b/Mastodon/Helper/MastodonStatusContent+Appearance.swift new file mode 100644 index 000000000..f627093c6 --- /dev/null +++ b/Mastodon/Helper/MastodonStatusContent+Appearance.swift @@ -0,0 +1,17 @@ +// +// MastodonStatusContent+Appearance.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-6-20. +// + +import UIKit + +extension MastodonStatusContent { + struct Appearance { + let attributes: [NSAttributedString.Key: Any] + let urlAttributes: [NSAttributedString.Key: Any] + let hashtagAttributes: [NSAttributedString.Key: Any] + let mentionAttributes: [NSAttributedString.Key: Any] + } +} diff --git a/Mastodon/Helper/MastodonStatusContent+ParseResult.swift b/Mastodon/Helper/MastodonStatusContent+ParseResult.swift new file mode 100644 index 000000000..f1f02fae1 --- /dev/null +++ b/Mastodon/Helper/MastodonStatusContent+ParseResult.swift @@ -0,0 +1,108 @@ +// +// MastodonStatusContent+ParseResult.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-6-20. +// + +import Foundation +import ActiveLabel + +extension MastodonStatusContent { + struct ParseResult: Hashable { + let document: String + let original: String + let trimmed: String + let activeEntities: [ActiveEntity] + + static func == (lhs: MastodonStatusContent.ParseResult, rhs: MastodonStatusContent.ParseResult) -> Bool { + return lhs.document == rhs.document + && lhs.original == rhs.original + && lhs.trimmed == rhs.trimmed + && lhs.activeEntities.count == rhs.activeEntities.count // FIXME: + } + + func hash(into hasher: inout Hasher) { + hasher.combine(document) + hasher.combine(original) + hasher.combine(trimmed) + hasher.combine(activeEntities.count) // FIXME: + } + + func trimmedAttributedString(appearance: MastodonStatusContent.Appearance) -> NSAttributedString { + let attributedString = NSMutableAttributedString(string: trimmed, attributes: appearance.attributes) + for entity in activeEntities { + switch entity.type { + case .url: + attributedString.addAttributes(appearance.urlAttributes, range: entity.range) + case .hashtag: + attributedString.addAttributes(appearance.hashtagAttributes, range: entity.range) + case .mention: + attributedString.addAttributes(appearance.mentionAttributes, range: entity.range) + default: + break + } + if let uri = entity.type.uri { + attributedString.addAttributes([ + .link: uri + ], range: entity.range) + } + } + return attributedString + } + } +} + +extension ActiveEntityType { + + static let appScheme = "mastodon" + + init?(url: URL) { + guard let scheme = url.scheme?.lowercased() else { return nil } + guard scheme == ActiveEntityType.appScheme else { + self = .url("", trimmed: "", url: url.absoluteString, userInfo: nil) + return + } + + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true), + let parameters = components.queryItems else { return nil } + + if let hashtag = parameters.first(where: { $0.name == "hashtag" }), let encoded = hashtag.value, let value = String(base64Encoded: encoded) { + self = .hashtag(value, userInfo: nil) + return + } + if let mention = parameters.first(where: { $0.name == "mention" }), let encoded = mention.value, let value = String(base64Encoded: encoded) { + self = .mention(value, userInfo: nil) + return + } + return nil + } + + var uri: URL? { + switch self { + case .url(_, _, let url, _): + return URL(string: url) + case .hashtag(let hashtag, _): + return URL(string: "\(ActiveEntityType.appScheme)://meta?hashtag=\(hashtag.base64Encoded)") + case .mention(let mention, _): + return URL(string: "\(ActiveEntityType.appScheme)://meta?mention=\(mention.base64Encoded)") + default: + return nil + } + } + +} + +extension String { + fileprivate var base64Encoded: String { + return Data(self.utf8).base64EncodedString() + } + + init?(base64Encoded: String) { + guard let data = Data(base64Encoded: base64Encoded), + let string = String(data: data, encoding: .utf8) else { + return nil + } + self = string + } +} diff --git a/Mastodon/Helper/MastodonStatusContent.swift b/Mastodon/Helper/MastodonStatusContent.swift index 5dbef4991..1e52f150c 100755 --- a/Mastodon/Helper/MastodonStatusContent.swift +++ b/Mastodon/Helper/MastodonStatusContent.swift @@ -5,10 +5,10 @@ // Created by MainasuK Cirno on 2021/2/1. // -import Foundation +import UIKit import Combine -import Kanna import ActiveLabel +import Fuzi enum MastodonStatusContent { @@ -125,30 +125,6 @@ extension String { } } -extension MastodonStatusContent { - struct ParseResult: Hashable { - let document: String - let original: String - let trimmed: String - let activeEntities: [ActiveEntity] - - static func == (lhs: MastodonStatusContent.ParseResult, rhs: MastodonStatusContent.ParseResult) -> Bool { - return lhs.document == rhs.document - && lhs.original == rhs.original - && lhs.trimmed == rhs.trimmed - && lhs.activeEntities.count == rhs.activeEntities.count // FIXME: - } - - func hash(into hasher: inout Hasher) { - hasher.combine(document) - hasher.combine(original) - hasher.combine(trimmed) - hasher.combine(activeEntities.count) // FIXME: - } - } -} - - extension MastodonStatusContent { class Node { @@ -165,7 +141,7 @@ extension MastodonStatusContent { } let tagName: String? - let classNames: Set + let attributes: [String : String] let href: String? let hrefEllipsis: String? @@ -175,56 +151,47 @@ extension MastodonStatusContent { level: Int, text: Substring, tagName: String?, - className: String?, + attributes: [String : String], href: String?, hrefEllipsis: String?, children: [Node] ) { let _classNames: Set = { - guard let className = className else { return Set() } + guard let className = attributes["class"] else { return Set() } return Set(className.components(separatedBy: " ")) }() let _type: Type? = { - if tagName == "a" && !_classNames.contains("mention") { - return .url - } - - if _classNames.contains("mention") { + if tagName == "a" { if _classNames.contains("u-url") { return .mention - } else if _classNames.contains("hashtag") { + } + if _classNames.contains("hashtag") { return .hashtag } + return .url + } else { + if _classNames.contains("emoji") { + return .emoji + } + return nil } - - if _classNames.contains("emoji") { - return .emoji - } - - return nil }() self.level = level self.type = _type self.text = text self.tagName = tagName - self.classNames = _classNames + self.attributes = attributes self.href = href self.hrefEllipsis = hrefEllipsis self.children = children } static func parse(document: String) throws -> MastodonStatusContent.Node { - let html = try HTML(html: document, encoding: .utf8) - - // add `\r\n` explicit due to Kanna text missing it after convert to text - // ref: https://github.com/tid-kijyun/Kanna/issues/150 - let brNodes = html.css("br").makeIterator() - while let brNode = brNodes.next() { - brNode.addNextSibling(try! HTML(html: "\r\n", encoding: .utf8).body!) - } + let document = document.replacingOccurrences(of: "
|
", with: "\r\n", options: .regularExpression, range: nil) + let html = try HTMLDocument(string: document) let body = html.body ?? nil - let text = body?.text ?? "" + let text = body?.stringValue ?? "" let level = 0 let children: [MastodonStatusContent.Node] = body.flatMap { body in return Node.parse(element: body, parentText: text[...], parentLevel: level + 1) @@ -232,8 +199,8 @@ extension MastodonStatusContent { let node = Node( level: level, text: text[...], - tagName: body?.tagName, - className: body?.className, + tagName: body?.tag, + attributes: body?.attributes ?? [:], href: nil, hrefEllipsis: nil, children: children @@ -246,13 +213,11 @@ extension MastodonStatusContent { let parent = element let scanner = Scanner(string: String(parentText)) scanner.charactersToBeSkipped = .none - - var element = parent.at_css(":first-child") + var children: [Node] = [] - - while let _element = element { - let _text = _element.text ?? "" - + for _element in parent.children { + let _text = _element.stringValue + // scan element text _ = scanner.scanUpToString(_text) let startIndexOffset = scanner.currentIndex.utf16Offset(in: scanner.string) @@ -261,27 +226,26 @@ extension MastodonStatusContent { continue } let endIndexOffset = scanner.currentIndex.utf16Offset(in: scanner.string) - + // locate substring let startIndex = parentText.utf16.index(parentText.utf16.startIndex, offsetBy: startIndexOffset) let endIndex = parentText.utf16.index(parentText.utf16.startIndex, offsetBy: endIndexOffset) let text = Substring(parentText.utf16[startIndex..%@%@: %@", diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusNodeDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusNodeDelegate.swift new file mode 100644 index 000000000..b8734a3c8 --- /dev/null +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusNodeDelegate.swift @@ -0,0 +1,16 @@ +// +// StatusProvider+StatusNodeDelegate.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-6-20. +// + +import Foundation +import ActiveLabel + +// MARK: - StatusViewDelegate +extension StatusNodeDelegate where Self: StatusProvider { + func statusNode(_ node: StatusNode, statusContentTextNode: ASMetaEditableTextNode, didSelectActiveEntityType type: ActiveEntityType) { + StatusProviderFacade.responseToStatusActiveLabelAction(provider: self, node: node, didSelectActiveEntityType: type) + } +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider.swift b/Mastodon/Protocol/StatusProvider/StatusProvider.swift index 8e27a2207..78bed66c5 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider.swift @@ -9,6 +9,7 @@ import UIKit import Combine import CoreData import CoreDataStack +import AsyncDisplayKit protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewController { // async @@ -21,4 +22,12 @@ protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewControl var tableViewDiffableDataSource: UITableViewDiffableDataSource? { get } func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? func items(indexPaths: [IndexPath]) -> [Item] + + func status(node: ASCellNode?, indexPath: IndexPath?) -> Status? +} + +extension StatusProvider { + func status(node: ASCellNode?, indexPath: IndexPath?) -> Status? { + fatalError("Needs implement this") + } } diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index a21422159..ff5b61583 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -12,6 +12,7 @@ import CoreData import CoreDataStack import MastodonSDK import ActiveLabel +import AsyncDisplayKit enum StatusProviderFacade { } @@ -144,51 +145,85 @@ extension StatusProviderFacade { break } } - + + static func responseToStatusActiveLabelAction(provider: StatusProvider, node: ASCellNode, didSelectActiveEntityType type: ActiveEntityType) { + switch type { + case .hashtag(let text, _): + let hashtagTimelienViewModel = HashtagTimelineViewModel(context: provider.context, hashtag: text) + provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelienViewModel), from: provider, transition: .show) + case .mention(let text, _): + coordinateToStatusMentionProfileScene(for: .primary, provider: provider, node: node, mention: text) + case .url(_, _, let url, _): + guard let url = URL(string: url) else { return } + if let domain = provider.context.authenticationService.activeMastodonAuthenticationBox.value?.domain, url.host == domain, + url.pathComponents.count >= 4, + url.pathComponents[0] == "/", + url.pathComponents[1] == "web", + url.pathComponents[2] == "statuses" { + let statusID = url.pathComponents[3] + let threadViewModel = RemoteThreadViewModel(context: provider.context, statusID: statusID) + provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show) + } else { + provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) + } + default: + break + } + } + + private static func coordinateToStatusMentionProfileScene(for target: Target, provider: StatusProvider, node: ASCellNode, mention: String) { + guard let status = provider.status(node: node, indexPath: nil) else { return } + coordinateToStatusMentionProfileScene(for: target, provider: provider, status: status, mention: mention) + } + private static func coordinateToStatusMentionProfileScene(for target: Target, provider: StatusProvider, cell: UITableViewCell, mention: String) { - guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } - let domain = activeMastodonAuthenticationBox.domain - provider.status(for: cell, indexPath: nil) .sink { [weak provider] status in guard let provider = provider else { return } - let _status: Status? = { - switch target { - case .primary: return status?.reblog ?? status - case .secondary: return status - } - }() - guard let status = _status else { return } - - // cannot continue without meta - guard let mentionMeta = (status.mentions ?? Set()).first(where: { $0.username == mention }) else { return } - - let userID = mentionMeta.id - - let profileViewModel: ProfileViewModel = { - // check if self - guard userID != activeMastodonAuthenticationBox.userID else { - return MeProfileViewModel(context: provider.context) - } - - let request = MastodonUser.sortedFetchRequest - request.fetchLimit = 1 - request.predicate = MastodonUser.predicate(domain: domain, id: userID) - let mastodonUser = provider.context.managedObjectContext.safeFetch(request).first - - if let mastodonUser = mastodonUser { - return CachedProfileViewModel(context: provider.context, mastodonUser: mastodonUser) - } else { - return RemoteProfileViewModel(context: provider.context, userID: userID) - } - }() - - DispatchQueue.main.async { - provider.coordinator.present(scene: .profile(viewModel: profileViewModel), from: provider, transition: .show) - } + guard let status = status else { return } + coordinateToStatusMentionProfileScene(for: target, provider: provider, status: status, mention: mention) } .store(in: &provider.disposeBag) } + + private static func coordinateToStatusMentionProfileScene(for target: Target, provider: StatusProvider, status: Status, mention: String) { + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let domain = activeMastodonAuthenticationBox.domain + + let status: Status = { + switch target { + case .primary: return status.reblog ?? status + case .secondary: return status + } + }() + + // cannot continue without meta + guard let mentionMeta = (status.mentions ?? Set()).first(where: { $0.username == mention }) else { return } + + let userID = mentionMeta.id + + let profileViewModel: ProfileViewModel = { + // check if self + guard userID != activeMastodonAuthenticationBox.userID else { + return MeProfileViewModel(context: provider.context) + } + + let request = MastodonUser.sortedFetchRequest + request.fetchLimit = 1 + request.predicate = MastodonUser.predicate(domain: domain, id: userID) + let mastodonUser = provider.context.managedObjectContext.safeFetch(request).first + + if let mastodonUser = mastodonUser { + return CachedProfileViewModel(context: provider.context, mastodonUser: mastodonUser) + } else { + return RemoteProfileViewModel(context: provider.context, userID: userID) + } + }() + + DispatchQueue.main.async { + provider.coordinator.present(scene: .profile(viewModel: profileViewModel), from: provider, transition: .show) + } + } } extension StatusProviderFacade { diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift index 38d843114..18a96f93d 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift @@ -10,6 +10,7 @@ import UIKit import Combine import CoreData import CoreDataStack +import AsyncDisplayKit // MARK: - StatusProvider extension HomeTimelineViewController: StatusProvider { @@ -83,6 +84,29 @@ extension HomeTimelineViewController: StatusProvider { } return items } + + func status(node: ASCellNode?, indexPath: IndexPath?) -> Status? { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return nil + } + + guard let indexPath = indexPath ?? node.flatMap({ self.node.indexPath(for: $0) }), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + return nil + } + + switch item { + case .homeTimelineIndex(let objectID, _): + guard let homeTimelineIndex = try? viewModel.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { + assertionFailure() + return nil + } + return homeTimelineIndex.status + default: + return nil + } + } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 68afb41e2..4502c05d1 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -576,4 +576,13 @@ extension HomeTimelineViewController: ASTableDelegate { viewModel.loadoldestStateMachine.enter(HomeTimelineViewModel.LoadOldestState.Loading.self) context.completeBatchFetching(true) } + + func tableNode(_ tableNode: ASTableNode, willDisplayRowWith node: ASCellNode) { + if let statusNode = node as? StatusNode { + statusNode.delegate = self + } + } } + +// MARK: - StatusNodeDelegate +extension HomeTimelineViewController: StatusNodeDelegate { } diff --git a/Mastodon/Scene/Share/View/Node/ASMetaEditableTextNode.swift b/Mastodon/Scene/Share/View/Node/ASMetaEditableTextNode.swift new file mode 100644 index 000000000..e98e81d57 --- /dev/null +++ b/Mastodon/Scene/Share/View/Node/ASMetaEditableTextNode.swift @@ -0,0 +1,21 @@ +// +// ASMetaEditableTextNode.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-6-20. +// + +import UIKit +import AsyncDisplayKit + +protocol ASMetaEditableTextNodeDelegate: AnyObject { + func metaEditableTextNode(_ textNode: ASMetaEditableTextNode, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool +} + +final class ASMetaEditableTextNode: ASEditableTextNode, UITextViewDelegate { + weak var metaEditableTextNodeDelegate: ASMetaEditableTextNodeDelegate? + + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + return metaEditableTextNodeDelegate?.metaEditableTextNode(self, shouldInteractWith: URL, in: characterRange, interaction: interaction) ?? false + } +} diff --git a/Mastodon/Scene/Share/View/Node/StatusNode.swift b/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift similarity index 59% rename from Mastodon/Scene/Share/View/Node/StatusNode.swift rename to Mastodon/Scene/Share/View/Node/Status/StatusNode.swift index 818711083..d4a3b3ba7 100644 --- a/Mastodon/Scene/Share/View/Node/StatusNode.swift +++ b/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift @@ -9,20 +9,44 @@ import UIKit import Combine import AsyncDisplayKit import CoreDataStack +import ActiveLabel + +protocol StatusNodeDelegate: AnyObject { + func statusNode(_ node: StatusNode, statusContentTextNode: ASMetaEditableTextNode, didSelectActiveEntityType type: ActiveEntityType) +} final class StatusNode: ASCellNode { var disposeBag = Set() + weak var delegate: StatusNodeDelegate? // needs assign on main queue static let avatarImageSize = CGSize(width: 42, height: 42) static let avatarImageCornerRadius: CGFloat = 4 + static let statusContentAppearance: MastodonStatusContent.Appearance = { + let linkAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)), + .foregroundColor: Asset.Colors.brandBlue.color + ] + return MastodonStatusContent.Appearance( + attributes: [ + .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)), + .foregroundColor: Asset.Colors.Label.primary.color + ], + urlAttributes: linkAttributes, + hashtagAttributes: linkAttributes, + mentionAttributes: linkAttributes + ) + }() + let avatarImageNode: ASNetworkImageNode = { let node = ASNetworkImageNode() node.contentMode = .scaleAspectFill node.defaultImage = UIImage.placeholder(color: .systemFill) + node.forcedSize = StatusNode.avatarImageSize node.cornerRadius = StatusNode.avatarImageCornerRadius // node.cornerRoundingType = .precomposited + // node.shouldRenderProgressImages = true return node }() @@ -30,6 +54,11 @@ final class StatusNode: ASCellNode { let nameDotTextNode = ASTextNode() let dateTextNode = ASTextNode() let usernameTextNode = ASTextNode() + let statusContentTextNode: ASMetaEditableTextNode = { + let node = ASMetaEditableTextNode() + node.scrollEnabled = false + return node + }() init(status: Status) { super.init() @@ -39,6 +68,7 @@ final class StatusNode: ASCellNode { if let url = (status.reblog ?? status).author.avatarImageURL() { avatarImageNode.url = url } + nameTextNode.attributedText = NSAttributedString(string: status.author.displayNameWithFallback, attributes: [ .foregroundColor: Asset.Colors.Label.primary.color, .font: UIFont.systemFont(ofSize: 17, weight: .semibold) @@ -65,10 +95,29 @@ final class StatusNode: ASCellNode { // } // .store(in: &self.disposeBag) // } + usernameTextNode.attributedText = NSAttributedString(string: "@" + status.author.acct, attributes: [ .foregroundColor: Asset.Colors.Label.secondary.color, .font: UIFont.systemFont(ofSize: 15, weight: .regular) ]) + + statusContentTextNode.metaEditableTextNodeDelegate = self + if let parseResult = try? MastodonStatusContent.parse( + content: (status.reblog ?? status).content, + emojiDict: (status.reblog ?? status).emojiDict + ) { + statusContentTextNode.attributedText = parseResult.trimmedAttributedString(appearance: StatusNode.statusContentAppearance) + } + } + + override func didEnterDisplayState() { + super.didEnterDisplayState() + + statusContentTextNode.textView.isEditable = false + statusContentTextNode.textView.textDragInteraction?.isEnabled = false + statusContentTextNode.textView.linkTextAttributes = [ + .foregroundColor: Asset.Colors.brandBlue.color + ] } override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { @@ -99,11 +148,26 @@ final class StatusNode: ASCellNode { headerStack.children = headerStackChildren let verticalStack = ASStackLayoutSpec.vertical() + verticalStack.spacing = 10 verticalStack.children = [ - headerStack + headerStack, + statusContentTextNode, ] return verticalStack } } + +// MARK: - ASEditableTextNodeDelegate +extension StatusNode: ASMetaEditableTextNodeDelegate { + func metaEditableTextNode(_ textNode: ASMetaEditableTextNode, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + guard let activityEntityType = ActiveEntityType(url: URL) else { + return false + } + defer { + delegate?.statusNode(self, statusContentTextNode: textNode, didSelectActiveEntityType: activityEntityType) + } + return false + } +} diff --git a/Mastodon/Scene/Share/View/Node/TimelineBottomLoaderNode.swift b/Mastodon/Scene/Share/View/Node/Status/TimelineBottomLoaderNode.swift similarity index 100% rename from Mastodon/Scene/Share/View/Node/TimelineBottomLoaderNode.swift rename to Mastodon/Scene/Share/View/Node/Status/TimelineBottomLoaderNode.swift diff --git a/Mastodon/Scene/Share/View/Node/TimelineMiddleLoaderNode.swift b/Mastodon/Scene/Share/View/Node/Status/TimelineMiddleLoaderNode.swift similarity index 100% rename from Mastodon/Scene/Share/View/Node/TimelineMiddleLoaderNode.swift rename to Mastodon/Scene/Share/View/Node/Status/TimelineMiddleLoaderNode.swift diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 31a7db5ba..71073729e 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -33,8 +33,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { application.registerForRemoteNotifications() #if DEBUG - PerformanceMonitor.shared().start() + // PerformanceMonitor.shared().start() // ASDisplayNode.shouldShowRangeDebugOverlay = true + // ASControlNode.enableHitTestDebug = true + // ASImageNode.shouldShowImageScalingOverlay = true #endif return true