diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 9803118..7a5ea32 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -52,10 +52,22 @@ D020F51224ECA309005AB084 /* MastodonContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F51024ECA309005AB084 /* MastodonContext.swift */; }; D020F51424ECBA60005AB084 /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F51324ECBA60005AB084 /* LazyView.swift */; }; D020F51524ECBA60005AB084 /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F51324ECBA60005AB084 /* LazyView.swift */; }; + D02D86D924EF61E4004583CC /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02D86D724EF61E4004583CC /* StatusTableViewCell.swift */; }; + D02D86DA24EF61E4004583CC /* StatusTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D02D86D824EF61E4004583CC /* StatusTableViewCell.xib */; }; + D02D86E424EF9848004583CC /* TouchFallthroughTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02D86E324EF9848004583CC /* TouchFallthroughTextView.swift */; }; + D02D86E624EF998B004583CC /* HTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02D86E524EF998B004583CC /* HTML.swift */; }; + D02D86E724EF998B004583CC /* HTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02D86E524EF998B004583CC /* HTML.swift */; }; + D02D86EC24EF9CA3004583CC /* CodingUserInfoKey+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02D86EB24EF9CA3004583CC /* CodingUserInfoKey+Extensions.swift */; }; + D02D86ED24EF9CA3004583CC /* CodingUserInfoKey+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02D86EB24EF9CA3004583CC /* CodingUserInfoKey+Extensions.swift */; }; + D02D86EF24EFB13A004583CC /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02D86EE24EFB13A004583CC /* Date+Extensions.swift */; }; + D02D86F024EFB13A004583CC /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02D86EE24EFB13A004583CC /* Date+Extensions.swift */; }; + D02D870524EFBB79004583CC /* String+UIKitExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02D870424EFBB79004583CC /* String+UIKitExtensions.swift */; }; D03658D124EDD80900AC17EC /* ContextEndpoint+Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03658D024EDD80900AC17EC /* ContextEndpoint+Stubbing.swift */; }; D03658D224EDD80900AC17EC /* ContextEndpoint+Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03658D024EDD80900AC17EC /* ContextEndpoint+Stubbing.swift */; }; D03DF45B24E62A68007A8CD5 /* DeletionEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03DF45A24E62A68007A8CD5 /* DeletionEndpoint.swift */; }; D03DF45C24E62A68007A8CD5 /* DeletionEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03DF45A24E62A68007A8CD5 /* DeletionEndpoint.swift */; }; + D042650824F058280096ED10 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = D042650724F058280096ED10 /* Localizable.stringsdict */; }; + D042650924F058280096ED10 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = D042650724F058280096ED10 /* Localizable.stringsdict */; }; D047FAAE24C3E21200AF17C5 /* MetatextApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D047FA8524C3E21000AF17C5 /* MetatextApp.swift */; }; D047FAAF24C3E21200AF17C5 /* MetatextApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D047FA8524C3E21000AF17C5 /* MetatextApp.swift */; }; D047FAB224C3E21200AF17C5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D047FA8724C3E21200AF17C5 /* Assets.xcassets */; }; @@ -132,6 +144,11 @@ D081A40624D0F1A8001B016E /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D081A40424D0F1A8001B016E /* String+Extensions.swift */; }; D0A1CA7424DAC2F1003063E9 /* KingfisherOptionsInfo+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1CA7324DAC2F1003063E9 /* KingfisherOptionsInfo+Extensions.swift */; }; D0A1CA7524DAC2F1003063E9 /* KingfisherOptionsInfo+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1CA7324DAC2F1003063E9 /* KingfisherOptionsInfo+Extensions.swift */; }; + D0A2453724EF346800B07068 /* StatusListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A2453624EF346800B07068 /* StatusListViewController.swift */; }; + D0A2453924EF364100B07068 /* StatusListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A2453824EF364100B07068 /* StatusListView.swift */; }; + D0A2453F24EF55D000B07068 /* StatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A2453E24EF55D000B07068 /* StatusViewModel.swift */; }; + D0A2454124EF563000B07068 /* StatusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A2454024EF563000B07068 /* StatusService.swift */; }; + D0A2454224EF563000B07068 /* StatusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A2454024EF563000B07068 /* StatusService.swift */; }; D0A652AD24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A652AC24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift */; }; D0A652AE24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A652AC24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift */; }; D0B23F0D24D210E90066F411 /* NSError+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B23F0C24D210E90066F411 /* NSError+Extensions.swift */; }; @@ -142,8 +159,6 @@ D0BEC93C24C96FD500E864C4 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC93A24C96FD500E864C4 /* RootView.swift */; }; D0BEC94724CA22C400E864C4 /* StatusesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC94624CA22C400E864C4 /* StatusesViewModel.swift */; }; D0BEC94824CA22C400E864C4 /* StatusesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC94624CA22C400E864C4 /* StatusesViewModel.swift */; }; - D0BEC94A24CA231200E864C4 /* StatusesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC94924CA231200E864C4 /* StatusesView.swift */; }; - D0BEC94B24CA231200E864C4 /* StatusesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC94924CA231200E864C4 /* StatusesView.swift */; }; D0C963FB24CC359D003BD330 /* AlertItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C963FA24CC359D003BD330 /* AlertItem.swift */; }; D0C963FC24CC359D003BD330 /* AlertItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C963FA24CC359D003BD330 /* AlertItem.swift */; }; D0C963FE24CC3812003BD330 /* Publisher+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C963FD24CC3812003BD330 /* Publisher+Extensions.swift */; }; @@ -187,6 +202,7 @@ D0E5363024E5436C00FB1CE1 /* PushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5362F24E5436C00FB1CE1 /* PushNotification.swift */; }; D0E5363124E5453E00FB1CE1 /* PushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5362F24E5436C00FB1CE1 /* PushNotification.swift */; }; D0E5363224E5453F00FB1CE1 /* PushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5362F24E5436C00FB1CE1 /* PushNotification.swift */; }; + D0E900CE24F1F28A00B55F5A /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E900CD24F1F28A00B55F5A /* UIColor+Extensions.swift */; }; D0EC8DC224DF7D9C00A08489 /* IdentityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */; }; D0EC8DC324DF7D9C00A08489 /* IdentityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */; }; D0EC8DC524DF842700A08489 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC424DF842700A08489 /* KeychainService.swift */; }; @@ -286,8 +302,16 @@ D020F50D24ECA25F005AB084 /* ContextEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextEndpoint.swift; sourceTree = ""; }; D020F51024ECA309005AB084 /* MastodonContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonContext.swift; sourceTree = ""; }; D020F51324ECBA60005AB084 /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = ""; }; + D02D86D724EF61E4004583CC /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = ""; }; + D02D86D824EF61E4004583CC /* StatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusTableViewCell.xib; sourceTree = ""; }; + D02D86E324EF9848004583CC /* TouchFallthroughTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TouchFallthroughTextView.swift; sourceTree = ""; }; + D02D86E524EF998B004583CC /* HTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTML.swift; sourceTree = ""; }; + D02D86EB24EF9CA3004583CC /* CodingUserInfoKey+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CodingUserInfoKey+Extensions.swift"; sourceTree = ""; }; + D02D86EE24EFB13A004583CC /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; + D02D870424EFBB79004583CC /* String+UIKitExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+UIKitExtensions.swift"; sourceTree = ""; }; D03658D024EDD80900AC17EC /* ContextEndpoint+Stubbing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContextEndpoint+Stubbing.swift"; sourceTree = ""; }; D03DF45A24E62A68007A8CD5 /* DeletionEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletionEndpoint.swift; sourceTree = ""; }; + D042650724F058280096ED10 /* Localizable.stringsdict */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.stringsdict; path = Localizable.stringsdict; sourceTree = ""; }; D047FA8524C3E21000AF17C5 /* MetatextApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetatextApp.swift; sourceTree = ""; }; D047FA8724C3E21200AF17C5 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D047FA8C24C3E21200AF17C5 /* Metatext.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metatext.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -331,12 +355,15 @@ D075817B24E6659A0081F6A3 /* NotificationTypesPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTypesPreferencesView.swift; sourceTree = ""; }; D081A40424D0F1A8001B016E /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; D0A1CA7324DAC2F1003063E9 /* KingfisherOptionsInfo+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KingfisherOptionsInfo+Extensions.swift"; sourceTree = ""; }; + D0A2453624EF346800B07068 /* StatusListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusListViewController.swift; sourceTree = ""; }; + D0A2453824EF364100B07068 /* StatusListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusListView.swift; sourceTree = ""; }; + D0A2453E24EF55D000B07068 /* StatusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusViewModel.swift; sourceTree = ""; }; + D0A2454024EF563000B07068 /* StatusService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusService.swift; sourceTree = ""; }; D0A652AC24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PreferencesEndpoint+Stubbing.swift"; sourceTree = ""; }; D0B23F0C24D210E90066F411 /* NSError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSError+Extensions.swift"; sourceTree = ""; }; D0BEC93724C9632800E864C4 /* RootViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewModel.swift; sourceTree = ""; }; D0BEC93A24C96FD500E864C4 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; D0BEC94624CA22C400E864C4 /* StatusesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusesViewModel.swift; sourceTree = ""; }; - D0BEC94924CA231200E864C4 /* StatusesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusesView.swift; sourceTree = ""; }; D0C963FA24CC359D003BD330 /* AlertItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertItem.swift; sourceTree = ""; }; D0C963FD24CC3812003BD330 /* Publisher+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+Extensions.swift"; sourceTree = ""; }; D0CD847224DBDEC700CF380C /* MastodonPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPreferences.swift; sourceTree = ""; }; @@ -357,6 +384,7 @@ D0E5361D24E3EB4D00FB1CE1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D0E5362824E4A06B00FB1CE1 /* Notification Service Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Notification Service Extension.entitlements"; sourceTree = ""; }; D0E5362F24E5436C00FB1CE1 /* PushNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotification.swift; sourceTree = ""; }; + D0E900CD24F1F28A00B55F5A /* UIColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = ""; }; D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentityService.swift; sourceTree = ""; }; D0EC8DC424DF842700A08489 /* KeychainService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = ""; }; D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecretsService.swift; sourceTree = ""; }; @@ -425,7 +453,11 @@ D0159FA224DE955900E78478 /* CustomEmojiText.swift */, D0159F8C24DE743700E78478 /* IdentitiesView.swift */, D0159F8E24DE743700E78478 /* SecondaryNavigationView.swift */, + D0A2453824EF364100B07068 /* StatusListView.swift */, + D02D86D724EF61E4004583CC /* StatusTableViewCell.swift */, + D02D86D824EF61E4004583CC /* StatusTableViewCell.xib */, D0159F8D24DE743700E78478 /* TabNavigationView.swift */, + D02D86E324EF9848004583CC /* TouchFallthroughTextView.swift */, ); path = Views; sourceTree = ""; @@ -485,17 +517,27 @@ D019E6F224DF7C9E00697C7D /* Services */ = { isa = PBXGroup; children = ( - D054951024EB101F008B00A5 /* Status List Services */, D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */, D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */, D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */, D0EC8DC424DF842700A08489 /* KeychainService.swift */, D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */, + D054951024EB101F008B00A5 /* Status List Services */, + D0A2454024EF563000B07068 /* StatusService.swift */, D0EC8DD724E096C900A08489 /* UserNotificationService.swift */, ); path = Services; sourceTree = ""; }; + D02D870024EFBAD5004583CC /* Extensions */ = { + isa = PBXGroup; + children = ( + D02D870424EFBB79004583CC /* String+UIKitExtensions.swift */, + D0E900CD24F1F28A00B55F5A /* UIColor+Extensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; D047FA7F24C3E21000AF17C5 = { isa = PBXGroup; children = ( @@ -543,7 +585,9 @@ D047FA8E24C3E21200AF17C5 /* iOS */ = { isa = PBXGroup; children = ( + D02D870024EFBAD5004583CC /* Extensions */, D047FA8F24C3E21200AF17C5 /* Info.plist */, + D0A2453524EF344600B07068 /* View Controllers */, D0159F8024DE739500E78478 /* View Models */, D0159F7F24DE739000E78478 /* Views */, ); @@ -592,11 +636,12 @@ D0ED1BD624CF94B200B4899C /* Application.swift */, D05494E924EA3F54008B00A5 /* Attachment.swift */, D05494EF24EA3FE5008B00A5 /* Card.swift */, - D020F51024ECA309005AB084 /* MastodonContext.swift */, D0666A5324C6C3E500F3F04B /* Emoji.swift */, + D02D86E524EF998B004583CC /* HTML.swift */, D0666A4A24C6C37700F3F04B /* Identity.swift */, D0666A4D24C6C39600F3F04B /* Instance.swift */, D057426C24EA339300839EBA /* ListTimeline.swift */, + D020F51024ECA309005AB084 /* MastodonContext.swift */, D0ED1BE224CFA84400B4899C /* MastodonError.swift */, D0CD847224DBDEC700CF380C /* MastodonPreferences.swift */, D05494E624EA3F1A008B00A5 /* Mention.swift */, @@ -623,10 +668,19 @@ isa = PBXGroup; children = ( D06B491E24D3F7FE00642749 /* Localizable.strings */, + D042650724F058280096ED10 /* Localizable.stringsdict */, ); path = Localizations; sourceTree = ""; }; + D0A2453524EF344600B07068 /* View Controllers */ = { + isa = PBXGroup; + children = ( + D0A2453624EF346800B07068 /* StatusListViewController.swift */, + ); + path = "View Controllers"; + sourceTree = ""; + }; D0DB6EF024C5224F00D965FE /* Views */ = { isa = PBXGroup; children = ( @@ -636,7 +690,6 @@ D0091B6724DC10B30040E8D2 /* PostingReadingPreferencesView.swift */, D0091B6D24DD68090040E8D2 /* PreferencesView.swift */, D0BEC93A24C96FD500E864C4 /* RootView.swift */, - D0BEC94924CA231200E864C4 /* StatusesView.swift */, ); path = Views; sourceTree = ""; @@ -661,6 +714,7 @@ D0091B7024DD68220040E8D2 /* PreferencesViewModel.swift */, D0BEC93724C9632800E864C4 /* RootViewModel.swift */, D0BEC94624CA22C400E864C4 /* StatusesViewModel.swift */, + D0A2453E24EF55D000B07068 /* StatusViewModel.swift */, ); path = "View Models"; sourceTree = ""; @@ -668,13 +722,15 @@ D0DB6F1624C665B400D965FE /* Extensions */ = { isa = PBXGroup; children = ( + D02D86EB24EF9CA3004583CC /* CodingUserInfoKey+Extensions.swift */, + D0EC8DE724E21FEC00A08489 /* Data+Extensions.swift */, + D02D86EE24EFB13A004583CC /* Date+Extensions.swift */, D0A1CA7324DAC2F1003063E9 /* KingfisherOptionsInfo+Extensions.swift */, D0B23F0C24D210E90066F411 /* NSError+Extensions.swift */, D0159FA424DE989700E78478 /* NSMutableAttributedString+Extensions.swift */, D0C963FD24CC3812003BD330 /* Publisher+Extensions.swift */, D081A40424D0F1A8001B016E /* String+Extensions.swift */, D065F53A24D3B33A00741304 /* View+Extensions.swift */, - D0EC8DE724E21FEC00A08489 /* Data+Extensions.swift */, ); path = Extensions; sourceTree = ""; @@ -904,6 +960,8 @@ D054950124EA4FFE008B00A5 /* DevelopmentAssets.xcassets in Resources */, D047FAB224C3E21200AF17C5 /* Assets.xcassets in Resources */, D06B491F24D3F7FE00642749 /* Localizable.strings in Resources */, + D02D86DA24EF61E4004583CC /* StatusTableViewCell.xib in Resources */, + D042650824F058280096ED10 /* Localizable.stringsdict in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -912,6 +970,7 @@ buildActionMask = 2147483647; files = ( D054950224EA4FFE008B00A5 /* DevelopmentAssets.xcassets in Resources */, + D042650924F058280096ED10 /* Localizable.stringsdict in Resources */, D047FAB324C3E21200AF17C5 /* Assets.xcassets in Resources */, D06B492024D3FB8000642749 /* Localizable.strings in Resources */, ); @@ -975,6 +1034,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D02D86E624EF998B004583CC /* HTML.swift in Sources */, D04FD73924D4A7B4007D572D /* AccountEndpoint+Stubbing.swift in Sources */, D05494F724EA49F7008B00A5 /* TimelinesEndpoint.swift in Sources */, D054951B24EB2825008B00A5 /* TransientStatusCollection.swift in Sources */, @@ -987,6 +1047,7 @@ D0BEC94724CA22C400E864C4 /* StatusesViewModel.swift in Sources */, D0666A4E24C6C39600F3F04B /* Instance.swift in Sources */, D057426724E9FE1D00839EBA /* ContentDatabase.swift in Sources */, + D02D86E424EF9848004583CC /* TouchFallthroughTextView.swift in Sources */, D054951524EB1053008B00A5 /* TimelineService.swift in Sources */, D019E6E924DF72E700697C7D /* InstanceEndpoint.swift in Sources */, D0ED1BE324CFA84400B4899C /* MastodonError.swift in Sources */, @@ -995,6 +1056,7 @@ D0666A6324C6DC6C00F3F04B /* AppAuthorization.swift in Sources */, D05494F024EA3FE5008B00A5 /* Card.swift in Sources */, D019E6E524DF72E700697C7D /* AccountEndpoint.swift in Sources */, + D0A2453924EF364100B07068 /* StatusListView.swift in Sources */, D075817C24E6659A0081F6A3 /* NotificationTypesPreferencesView.swift in Sources */, D020F51424ECBA60005AB084 /* LazyView.swift in Sources */, D065F53B24D3B33A00741304 /* View+Extensions.swift in Sources */, @@ -1008,8 +1070,12 @@ D019E6ED24DF7BF300697C7D /* IdentityDatabase.swift in Sources */, D081A40524D0F1A8001B016E /* String+Extensions.swift in Sources */, D019E6E724DF72E700697C7D /* AccessTokenEndpoint.swift in Sources */, + D02D870524EFBB79004583CC /* String+UIKitExtensions.swift in Sources */, D0BEC93824C9632800E864C4 /* RootViewModel.swift in Sources */, + D02D86EC24EF9CA3004583CC /* CodingUserInfoKey+Extensions.swift in Sources */, + D0A2454124EF563000B07068 /* StatusService.swift in Sources */, D0ED1BC124CED48800B4899C /* HTTPClient.swift in Sources */, + D0E900CE24F1F28A00B55F5A /* UIColor+Extensions.swift in Sources */, D0EC8DEE24E2704D00A08489 /* PushSubscriptionEndpoint.swift in Sources */, D0159F9324DE743700E78478 /* SecondaryNavigationView.swift in Sources */, D019E6E324DF72E700697C7D /* PreferencesEndpoint.swift in Sources */, @@ -1017,13 +1083,15 @@ D0666A5424C6C3E500F3F04B /* Emoji.swift in Sources */, D05494FA24EA4E5E008B00A5 /* TimelinesEndpoint+Stubbing.swift in Sources */, D0A652AD24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift in Sources */, + D02D86D924EF61E4004583CC /* StatusTableViewCell.swift in Sources */, D0DC175524D00F0A00A75C65 /* AccessTokenEndpoint+Stubbing.swift in Sources */, D0B23F0D24D210E90066F411 /* NSError+Extensions.swift in Sources */, D05494ED24EA3FA9008B00A5 /* Poll.swift in Sources */, D019E6D924DF728400697C7D /* MastodonDecoder.swift in Sources */, D052BBCA24D74C9200A80A7A /* MockUserDefaults.swift in Sources */, + D0A2453F24EF55D000B07068 /* StatusViewModel.swift in Sources */, + D02D86EF24EFB13A004583CC /* Date+Extensions.swift in Sources */, D0DC175224D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */, - D0BEC94A24CA231200E864C4 /* StatusesView.swift in Sources */, D0BEC93B24C96FD500E864C4 /* RootView.swift in Sources */, D04FD74224D4AA34007D572D /* DevelopmentModels.swift in Sources */, D0EC8DC524DF842700A08489 /* KeychainService.swift in Sources */, @@ -1039,6 +1107,7 @@ D0EC8DE524E0B44500A08489 /* UserNotificationService.swift in Sources */, D0EC8DCB24DFA06700A08489 /* IdentitiesService.swift in Sources */, D0091B7124DD68220040E8D2 /* PreferencesViewModel.swift in Sources */, + D0A2453724EF346800B07068 /* StatusListViewController.swift in Sources */, D057426A24EA32AC00839EBA /* Timeline.swift in Sources */, D0DC174D24CFF1F100A75C65 /* Stubbing.swift in Sources */, D0091B6B24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */, @@ -1088,6 +1157,7 @@ D0ED1BD824CF94B200B4899C /* Application.swift in Sources */, D047FAAF24C3E21200AF17C5 /* MetatextApp.swift in Sources */, D0BEC94824CA22C400E864C4 /* StatusesViewModel.swift in Sources */, + D02D86ED24EF9CA3004583CC /* CodingUserInfoKey+Extensions.swift in Sources */, D0159FA624DE98F600E78478 /* NSMutableAttributedString+Extensions.swift in Sources */, D0EC8DC324DF7D9C00A08489 /* IdentityService.swift in Sources */, D0666A4F24C6C39600F3F04B /* Instance.swift in Sources */, @@ -1114,9 +1184,9 @@ D052BBCB24D74C9300A80A7A /* MockUserDefaults.swift in Sources */, D0DC175324D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */, D0EC8DE924E21FEC00A08489 /* Data+Extensions.swift in Sources */, - D0BEC94B24CA231200E864C4 /* StatusesView.swift in Sources */, D05494E524EA3EF7008B00A5 /* Tag.swift in Sources */, D0BEC93C24C96FD500E864C4 /* RootView.swift in Sources */, + D02D86E724EF998B004583CC /* HTML.swift in Sources */, D0159F9B24DE748900E78478 /* SidebarNavigationViewModel.swift in Sources */, D04FD74324D4AA34007D572D /* DevelopmentModels.swift in Sources */, D0DC175924D0130800A75C65 /* HTTPStubs.swift in Sources */, @@ -1138,6 +1208,7 @@ D0091B6C24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */, D0091B6F24DD68090040E8D2 /* PreferencesView.swift in Sources */, D0EC8DC924DF8B3C00A08489 /* SecretsService.swift in Sources */, + D02D86F024EFB13A004583CC /* Date+Extensions.swift in Sources */, D0EC8DE024E09D7000A08489 /* AppDelegate.swift in Sources */, D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */, D019E6EA24DF72E700697C7D /* InstanceEndpoint.swift in Sources */, @@ -1154,6 +1225,7 @@ D0EC8DEC24E26F1100A08489 /* PushSubscription.swift in Sources */, D03DF45C24E62A68007A8CD5 /* DeletionEndpoint.swift in Sources */, D0A1CA7524DAC2F1003063E9 /* KingfisherOptionsInfo+Extensions.swift in Sources */, + D0A2454224EF563000B07068 /* StatusService.swift in Sources */, D0ED1BC524CED54D00B4899C /* HTTPTarget.swift in Sources */, D0C963FF24CC3812003BD330 /* Publisher+Extensions.swift in Sources */, D075817D24E6659A0081F6A3 /* NotificationTypesPreferencesView.swift in Sources */, diff --git a/Shared/Databases/ContentDatabase.swift b/Shared/Databases/ContentDatabase.swift index 1f89795..f77cb2f 100644 --- a/Shared/Databases/ContentDatabase.swift +++ b/Shared/Databases/ContentDatabase.swift @@ -8,7 +8,7 @@ import GRDB struct ContentDatabase { private let databaseQueue: DatabaseQueue - init(identityID: UUID, inMemory: Bool = false) throws { + init(identityID: UUID, environment: AppEnvironment) throws { guard let documentsDirectory = NSSearchPathForDirectoriesInDomains( .documentDirectory, @@ -16,7 +16,7 @@ struct ContentDatabase { .first else { throw DatabaseError.documentsDirectoryNotFound } - if inMemory { + if environment.inMemoryContent { databaseQueue = DatabaseQueue() } else { databaseQueue = try DatabaseQueue(path: "\(documentsDirectory)/\(identityID.uuidString).sqlite3") @@ -24,6 +24,7 @@ struct ContentDatabase { try Self.migrate(databaseQueue) try Self.createTemporaryTables(databaseQueue) + Self.attributedStringCache = environment.attributedStringCache } } @@ -79,6 +80,8 @@ extension ContentDatabase { } private extension ContentDatabase { + static var attributedStringCache: AttributedStringCache? + // swiftlint:disable function_body_length static func migrate(_ writer: DatabaseWriter) throws { var migrator = DatabaseMigrator() @@ -181,6 +184,16 @@ private extension ContentDatabase { } extension Account: TableRecord, FetchableRecord, PersistableRecord { + static var databaseDecodingUserInfo: [CodingUserInfoKey: Any] { + var userInfo = [CodingUserInfoKey: Any]() + + if let attributedStringCache = ContentDatabase.attributedStringCache { + userInfo[.attributedStringCache] = attributedStringCache + } + + return userInfo + } + static func databaseJSONDecoder(for column: String) -> JSONDecoder { MastodonDecoder() } @@ -276,7 +289,7 @@ private struct StoredStatus: Codable, Hashable { let uri: String let createdAt: Date let accountId: String - let content: String + let content: HTML let visibility: Status.Visibility let sensitive: Bool let spoilerText: String @@ -354,6 +367,16 @@ private extension StoredStatus { } extension StoredStatus: TableRecord, FetchableRecord, PersistableRecord { + static var databaseDecodingUserInfo: [CodingUserInfoKey: Any] { + var userInfo = [CodingUserInfoKey: Any]() + + if let attributedStringCache = ContentDatabase.attributedStringCache { + userInfo[.attributedStringCache] = attributedStringCache + } + + return userInfo + } + static func databaseJSONDecoder(for column: String) -> JSONDecoder { MastodonDecoder() } diff --git a/Shared/Extensions/CodingUserInfoKey+Extensions.swift b/Shared/Extensions/CodingUserInfoKey+Extensions.swift new file mode 100644 index 0000000..ce3f707 --- /dev/null +++ b/Shared/Extensions/CodingUserInfoKey+Extensions.swift @@ -0,0 +1,9 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +typealias AttributedStringCache = NSCache + +extension CodingUserInfoKey { + static let attributedStringCache = CodingUserInfoKey(rawValue: "com.metabolist.metatext.attributed-string-cache")! +} diff --git a/Shared/Extensions/Date+Extensions.swift b/Shared/Extensions/Date+Extensions.swift new file mode 100644 index 0000000..95cf625 --- /dev/null +++ b/Shared/Extensions/Date+Extensions.swift @@ -0,0 +1,81 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +extension Date { + var timeAgo: String? { + let calendar = Calendar.autoupdatingCurrent + let now = Date() + + if + let oneMinuteAgo = calendar.date(byAdding: DateComponents(minute: -1), to: now), + oneMinuteAgo < self { + Self.abbreviatedDateComponentsFormatter.allowedUnits = [.second] + } else if + let oneHourAgo = calendar.date(byAdding: DateComponents(hour: -1), to: now), + oneHourAgo < self { + Self.abbreviatedDateComponentsFormatter.allowedUnits = [.minute] + } else if + let oneDayAgo = calendar.date(byAdding: DateComponents(day: -1), to: now), + oneDayAgo < self { + Self.abbreviatedDateComponentsFormatter.allowedUnits = [.hour] + } else if + let oneWeekAgo = calendar.date(byAdding: DateComponents(weekOfMonth: -1), to: now), + oneWeekAgo < self { + Self.abbreviatedDateComponentsFormatter.allowedUnits = [.day] + } else { + return Date.shortDateStyleRelativeDateFormatter.string(from: self) + } + + return Self.abbreviatedDateComponentsFormatter.string(from: self, to: now) + } + + var fullUnitTimeUntil: String? { + let calendar = Calendar.autoupdatingCurrent + let now = Date() + + if + let oneDayFromNow = calendar.date(byAdding: DateComponents(day: 1), to: now), + self > oneDayFromNow { + Self.fullDateComponentsFormatter.allowedUnits = [.day] + } else if + let oneHourFromNow = calendar.date(byAdding: DateComponents(hour: 1), to: now), + self > oneHourFromNow { + Self.fullDateComponentsFormatter.allowedUnits = [.hour] + } else if + let oneMinuteFromNow = calendar.date(byAdding: DateComponents(minute: 1), to: now), + self > oneMinuteFromNow { + Self.fullDateComponentsFormatter.allowedUnits = [.minute] + } else { + Self.fullDateComponentsFormatter.allowedUnits = [.second] + } + + return Self.fullDateComponentsFormatter.string(from: now, to: self) + } +} + +private extension Date { + private static let abbreviatedDateComponentsFormatter: DateComponentsFormatter = { + let dateComponentsFormatter = DateComponentsFormatter() + + dateComponentsFormatter.unitsStyle = .abbreviated + + return dateComponentsFormatter + }() + + private static let fullDateComponentsFormatter: DateComponentsFormatter = { + let dateComponentsFormatter = DateComponentsFormatter() + + dateComponentsFormatter.unitsStyle = .full + + return dateComponentsFormatter + }() + + private static let shortDateStyleRelativeDateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + + dateFormatter.dateStyle = .short + + return dateFormatter + }() +} diff --git a/Shared/Localizations/Localizable.strings b/Shared/Localizations/Localizable.strings index e2b27ed..da406ea 100644 --- a/Shared/Localizations/Localizable.strings +++ b/Shared/Localizations/Localizable.strings @@ -26,6 +26,15 @@ "preferences.notification-types.reblog" = "Reblog"; "preferences.notification-types.mention" = "Mention"; "preferences.notification-types.poll" = "Poll"; +"status.reblogged-by" = "%@ boosted"; +"status.pinned-post" = "Pinned post"; +"status.show-more" = "Show More"; +"status.show-less" = "Show Less"; +"status.poll.vote" = "Vote"; +"status.poll.participation-count" = "%ld people"; +"status.poll.time-left" = "%@ left"; +"status.poll.refresh" = "Refresh"; +"status.poll.closed" = "Closed"; "status.visibility.public" = "Public"; "status.visibility.unlisted" = "Unlisted"; "status.visibility.private" = "Private"; diff --git a/Shared/Localizations/Localizable.stringsdict b/Shared/Localizations/Localizable.stringsdict new file mode 100644 index 0000000..e876088 --- /dev/null +++ b/Shared/Localizations/Localizable.stringsdict @@ -0,0 +1,54 @@ + + + + + status.reblogs-count + + NSStringLocalizedFormatKey + %#@reblogs@ + reblogs + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + %ld Boost + other + %ld Boosts + + + status.favorites-count + + NSStringLocalizedFormatKey + %#@favorites@ + favorites + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + %ld Favorite + other + %ld Favorites + + + account.followers-count + + NSStringLocalizedFormatKey + %#@followers@ + followers + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + %ld Follower + other + %ld Followers + + + + diff --git a/Shared/Model/Account.swift b/Shared/Model/Account.swift index c0ff892..f9a18b2 100644 --- a/Shared/Model/Account.swift +++ b/Shared/Model/Account.swift @@ -5,7 +5,7 @@ import Foundation struct Account: Codable, Hashable { struct Field: Codable, Hashable { let name: String - let value: String + let value: HTML let verifiedAt: Date? } @@ -18,7 +18,7 @@ struct Account: Codable, Hashable { let followersCount: Int let followingCount: Int let statusesCount: Int - let note: String + let note: HTML let url: URL let avatar: URL let avatarStatic: URL diff --git a/Shared/Model/AppEnvironment.swift b/Shared/Model/AppEnvironment.swift index 07f88eb..be07c9e 100644 --- a/Shared/Model/AppEnvironment.swift +++ b/Shared/Model/AppEnvironment.swift @@ -8,6 +8,7 @@ struct AppEnvironment { let keychainServiceType: KeychainService.Type let userDefaults: UserDefaults let inMemoryContent: Bool + let attributedStringCache = AttributedStringCache() } extension AppEnvironment { diff --git a/Shared/Model/HTML.swift b/Shared/Model/HTML.swift new file mode 100644 index 0000000..5abbad6 --- /dev/null +++ b/Shared/Model/HTML.swift @@ -0,0 +1,117 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +struct HTML: Hashable { + let raw: String + let attributed: NSAttributedString +} + +extension HTML: Codable { + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let attributedStringCache = decoder.userInfo[.attributedStringCache] as? AttributedStringCache + + raw = try container.decode(String.self) + + if let attributed = attributedStringCache?.object(forKey: raw as NSString) { + self.attributed = attributed + + return + } + + attributed = HTMLParser(string: raw).parse() + attributedStringCache?.setObject(attributed, forKey: raw as NSString) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + try container.encode(raw) + } +} + +// https://docs.joinmastodon.org/spec/activitypub/#sanitization + +private class HTMLParser: NSObject { + private struct Link: Hashable { + let href: URL + let location: Int + var length = 0 + } + + private let rawString: String + private let parser: XMLParser + private let parseStopColumn: Int + private var constructedString = "" + private var attributesStack = [[String: String]]() + private var currentLink: Link? + private var links = Set() + private static let containerTag = "com.metabolist.metatext.container-tag" + private static let openingContainerTag = "<\(containerTag)>" + private static let closingContainerTag = "" + + init(string: String) { + rawString = Self.openingContainerTag + string + Self.closingContainerTag + parser = XMLParser(data: Data(rawString.utf8)) + parseStopColumn = rawString.count - Self.closingContainerTag.count + + super.init() + + parser.delegate = self + } + + func parse() -> NSAttributedString { + parser.parse() + + let attributedString = NSMutableAttributedString(string: constructedString) + + for link in links { + attributedString.addAttribute(.link, + value: link.href, + range: .init(location: link.location, length: link.length)) + } + + return attributedString + } +} + +extension HTMLParser: XMLParserDelegate { + func parser(_ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String: String] = [:]) { + attributesStack.append(attributeDict) + + if elementName == "a", let hrefString = attributeDict["href"], let href = URL(string: hrefString) { + currentLink = Link(href: href, location: constructedString.count) + } else if elementName == "br" { + constructedString.append("\n") + } + } + + func parser(_ parser: XMLParser, + didEndElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?) { + let attributes = attributesStack.removeLast() + + if attributes["class"] == "ellipsis" { + constructedString.append("…") + } + + if elementName == "a", var link = currentLink { + link.length = constructedString.count - link.location + links.insert(link) + } else if elementName == "p", parser.columnNumber < parseStopColumn { + constructedString.append("\n\n") + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + if attributesStack.last?["class"] != "invisible" { + constructedString.append(string) + } + } +} diff --git a/Shared/Model/Status.swift b/Shared/Model/Status.swift index 092af65..e08084d 100644 --- a/Shared/Model/Status.swift +++ b/Shared/Model/Status.swift @@ -17,7 +17,7 @@ class Status: Codable, Identifiable { let uri: String let createdAt: Date let account: Account - let content: String + let content: HTML let visibility: Visibility let sensitive: Bool let spoilerText: String @@ -49,7 +49,7 @@ class Status: Codable, Identifiable { uri: String, createdAt: Date, account: Account, - content: String, + content: HTML, visibility: Status.Visibility, sensitive: Bool, spoilerText: String, @@ -106,6 +106,12 @@ class Status: Codable, Identifiable { } } +extension Status { + var displayStatus: Status { + reblog ?? self + } +} + extension Status: Hashable { static func == (lhs: Status, rhs: Status) -> Bool { lhs.id == rhs.id diff --git a/Shared/Networking/HTTPClient.swift b/Shared/Networking/HTTPClient.swift index 4f13d8e..a1c85eb 100644 --- a/Shared/Networking/HTTPClient.swift +++ b/Shared/Networking/HTTPClient.swift @@ -10,7 +10,7 @@ class HTTPClient { private let session: Session private let decoder: DataDecoder - init(session: Session, decoder: DataDecoder = JSONDecoder()) { + init(session: Session, decoder: DataDecoder) { self.session = session self.decoder = decoder } diff --git a/Shared/Networking/Mastodon API/MastodonClient.swift b/Shared/Networking/Mastodon API/MastodonClient.swift index c61048d..14f3d05 100644 --- a/Shared/Networking/Mastodon API/MastodonClient.swift +++ b/Shared/Networking/Mastodon API/MastodonClient.swift @@ -7,8 +7,12 @@ class MastodonClient: HTTPClient { var instanceURL: URL? var accessToken: String? - init(session: Session) { - super.init(session: session, decoder: MastodonDecoder()) + required init(environment: AppEnvironment) { + let decoder = MastodonDecoder() + + decoder.userInfo[.attributedStringCache] = environment.attributedStringCache + + super.init(session: environment.session, decoder: decoder) } override func request(_ target: T) -> AnyPublisher { diff --git a/Shared/Services/AuthenticationService.swift b/Shared/Services/AuthenticationService.swift index b12f0e0..a102595 100644 --- a/Shared/Services/AuthenticationService.swift +++ b/Shared/Services/AuthenticationService.swift @@ -9,7 +9,7 @@ struct AuthenticationService { private let webAuthSessionContextProvider = WebAuthSessionContextProvider() init(environment: AppEnvironment) { - networkClient = MastodonClient(session: environment.session) + networkClient = MastodonClient(environment: environment) webAuthSessionType = environment.webAuthSessionType } } diff --git a/Shared/Services/IdentitiesService.swift b/Shared/Services/IdentitiesService.swift index a04b86c..4bb39b5 100644 --- a/Shared/Services/IdentitiesService.swift +++ b/Shared/Services/IdentitiesService.swift @@ -52,7 +52,7 @@ extension IdentitiesService { func deleteIdentity(_ identity: Identity) -> AnyPublisher { let secretsService = SecretsService(identityID: identity.id, keychainService: environment.keychainServiceType) - let networkClient = MastodonClient(session: environment.session) + let networkClient = MastodonClient(environment: environment) networkClient.instanceURL = identity.url diff --git a/Shared/Services/IdentityService.swift b/Shared/Services/IdentityService.swift index c2e3d59..8b4e095 100644 --- a/Shared/Services/IdentityService.swift +++ b/Shared/Services/IdentityService.swift @@ -34,11 +34,11 @@ class IdentityService { secretsService = SecretsService( identityID: identityID, keychainService: environment.keychainServiceType) - networkClient = MastodonClient(session: environment.session) + networkClient = MastodonClient(environment: environment) networkClient.instanceURL = identity.url networkClient.accessToken = try? secretsService.item(.accessToken) - contentDatabase = try ContentDatabase(identityID: identityID, inMemory: environment.inMemoryContent) + contentDatabase = try ContentDatabase(identityID: identityID, environment: environment) observation.catch { [weak self] error -> Empty in self?.observationErrorsInput.send(error) diff --git a/Shared/Services/Status List Services/ContextService.swift b/Shared/Services/Status List Services/ContextService.swift index e3c8290..2533de6 100644 --- a/Shared/Services/Status List Services/ContextService.swift +++ b/Shared/Services/Status List Services/ContextService.swift @@ -34,6 +34,32 @@ struct ContextService { extension ContextService: StatusListService { var contextParent: Status? { status } + func isReplyInContext(status: Status) -> Bool { + let flatContext = flattenedContext() + + guard + let index = flatContext.firstIndex(where: { $0.id == status.id }), + index > 0 + else { return false } + + let previousStatus = flatContext[index - 1] + + return previousStatus.id != contextParent?.id && status.inReplyToId == previousStatus.id + } + + func hasReplyFollowing(status: Status) -> Bool { + let flatContext = flattenedContext() + + guard + let index = flatContext.firstIndex(where: { $0.id == status.id }), + flatContext.count > index + 1 + else { return false } + + let nextStatus = flatContext[index + 1] + + return status.id != contextParent?.id && nextStatus.inReplyToId == status.id + } + func request(maxID: String?, minID: String?) -> AnyPublisher { networkClient.request(ContextEndpoint.context(id: status.id)) .handleEvents(receiveOutput: context.send) @@ -42,7 +68,17 @@ extension ContextService: StatusListService { .eraseToAnyPublisher() } + func statusService(status: Status) -> StatusService { + StatusService(status: status, networkClient: networkClient, contentDatabase: contentDatabase) + } + func contextService(status: Status) -> ContextService { - ContextService(status: status, networkClient: networkClient, contentDatabase: contentDatabase) + ContextService(status: status.displayStatus, networkClient: networkClient, contentDatabase: contentDatabase) + } +} + +private extension ContextService { + func flattenedContext() -> [Status] { + context.value.ancestors + [status] + context.value.descendants } } diff --git a/Shared/Services/Status List Services/StatusListService.swift b/Shared/Services/Status List Services/StatusListService.swift index 14d1a47..5d26a4b 100644 --- a/Shared/Services/Status List Services/StatusListService.swift +++ b/Shared/Services/Status List Services/StatusListService.swift @@ -6,10 +6,20 @@ import Combine protocol StatusListService { var statusSections: AnyPublisher<[[Status]], Error> { get } var contextParent: Status? { get } + func isPinned(status: Status) -> Bool + func isReplyInContext(status: Status) -> Bool + func hasReplyFollowing(status: Status) -> Bool func request(maxID: String?, minID: String?) -> AnyPublisher + func statusService(status: Status) -> StatusService func contextService(status: Status) -> ContextService } extension StatusListService { var contextParent: Status? { nil } + + func isPinned(status: Status) -> Bool { false } + + func isReplyInContext(status: Status) -> Bool { false } + + func hasReplyFollowing(status: Status) -> Bool { false } } diff --git a/Shared/Services/Status List Services/TimelineService.swift b/Shared/Services/Status List Services/TimelineService.swift index 9adfa61..99cb9f5 100644 --- a/Shared/Services/Status List Services/TimelineService.swift +++ b/Shared/Services/Status List Services/TimelineService.swift @@ -28,7 +28,11 @@ extension TimelineService: StatusListService { .eraseToAnyPublisher() } + func statusService(status: Status) -> StatusService { + StatusService(status: status, networkClient: networkClient, contentDatabase: contentDatabase) + } + func contextService(status: Status) -> ContextService { - ContextService(status: status, networkClient: networkClient, contentDatabase: contentDatabase) + ContextService(status: status.displayStatus, networkClient: networkClient, contentDatabase: contentDatabase) } } diff --git a/Shared/Services/StatusService.swift b/Shared/Services/StatusService.swift new file mode 100644 index 0000000..d7f37f1 --- /dev/null +++ b/Shared/Services/StatusService.swift @@ -0,0 +1,16 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Combine + +struct StatusService { + let status: Status + private let networkClient: MastodonClient + private let contentDatabase: ContentDatabase + + init(status: Status, networkClient: MastodonClient, contentDatabase: ContentDatabase) { + self.status = status + self.networkClient = networkClient + self.contentDatabase = contentDatabase + } +} diff --git a/Shared/View Models/StatusViewModel.swift b/Shared/View Models/StatusViewModel.swift new file mode 100644 index 0000000..2547df7 --- /dev/null +++ b/Shared/View Models/StatusViewModel.swift @@ -0,0 +1,112 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Combine + +struct StatusViewModel { + let content: NSAttributedString + let contentEmoji: [Emoji] + let displayName: String + let displayNameEmoji: [Emoji] + let spoilerText: String + let isReblog: Bool + let rebloggedByDisplayName: String + let rebloggedByDisplayNameEmoji: [Emoji] + let pollOptionTitles: [String] + let pollEmoji: [Emoji] + var isPinned = false + var isContextParent = false + var isReplyInContext = false + var hasReplyFollowing = false + var sensitiveContentToggled = false + + private let statusService: StatusService + + init(statusService: StatusService) { + self.statusService = statusService + content = statusService.status.displayStatus.content.attributed + contentEmoji = statusService.status.displayStatus.emojis + displayName = statusService.status.displayStatus.account.displayName == "" + ? statusService.status.displayStatus.account.username + : statusService.status.displayStatus.account.displayName + displayNameEmoji = statusService.status.displayStatus.account.emojis + spoilerText = statusService.status.displayStatus.spoilerText + isReblog = statusService.status.reblog != nil + rebloggedByDisplayName = statusService.status.account.displayName == "" + ? statusService.status.account.username + : statusService.status.account.displayName + rebloggedByDisplayNameEmoji = statusService.status.account.emojis + pollOptionTitles = statusService.status.displayStatus.poll?.options.map { $0.title } ?? [] + pollEmoji = statusService.status.displayStatus.poll?.emojis ?? [] + } +} + +extension StatusViewModel { + var shouldDisplaySensitiveContent: Bool { + if statusService.status.displayStatus.sensitive { + return sensitiveContentToggled + } else { + return true + } + } + + var accountName: String { "@" + statusService.status.displayStatus.account.acct } + + var avatarURL: URL { statusService.status.displayStatus.account.avatar } + + var time: String? { statusService.status.displayStatus.createdAt.timeAgo } + + var contextParentTime: String { + Self.contextParentDateFormatter.string(from: statusService.status.displayStatus.createdAt) + } + + var applicationName: String? { statusService.status.displayStatus.application?.name } + + var applicationURL: URL? { + guard let website = statusService.status.displayStatus.application?.website else { return nil } + + return URL(string: website) + } + + var repliesCount: Int { statusService.status.displayStatus.repliesCount } + + var reblogsCount: Int { statusService.status.displayStatus.reblogsCount } + + var favoritesCount: Int { statusService.status.displayStatus.favouritesCount } + + var reblogged: Bool { statusService.status.displayStatus.reblogged ?? false } + + var favorited: Bool { statusService.status.displayStatus.favourited ?? false } + + var sensitive: Bool { statusService.status.displayStatus.sensitive } + + var sharingURL: URL? { statusService.status.displayStatus.url } + + var cardURL: URL? { statusService.status.displayStatus.card?.url } + + var cardTitle: String? { statusService.status.displayStatus.card?.title } + + var cardDescription: String? { statusService.status.displayStatus.card?.description } + + var cardImageURL: URL? { statusService.status.displayStatus.card?.image } + + var canBeReblogged: Bool { + switch statusService.status.displayStatus.visibility { + case .direct, .private: + return false + default: + return true + } + } +} + +private extension StatusViewModel { + private static let contextParentDateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + + dateFormatter.dateStyle = .short + dateFormatter.timeStyle = .short + + return dateFormatter + }() +} diff --git a/Shared/View Models/StatusesViewModel.swift b/Shared/View Models/StatusesViewModel.swift index 4fe3f0c..c1550be 100644 --- a/Shared/View Models/StatusesViewModel.swift +++ b/Shared/View Models/StatusesViewModel.swift @@ -7,15 +7,15 @@ class StatusesViewModel: ObservableObject { @Published private(set) var statusSections = [[Status]]() @Published var alertItem: AlertItem? @Published private(set) var loading = false - let scrollToStatusID: AnyPublisher + let scrollToStatus: AnyPublisher private let statusListService: StatusListService - private let scrollToStatusIDInput = PassthroughSubject() + private let scrollToStatusInput = PassthroughSubject() private var hasScrolledToParentAfterContextLoad = false private var cancellables = Set() init(statusListService: StatusListService) { self.statusListService = statusListService - scrollToStatusID = scrollToStatusIDInput.eraseToAnyPublisher() + scrollToStatus = scrollToStatusInput.eraseToAnyPublisher() statusListService.statusSections .assignErrorsToAlertItem(to: \.alertItem, on: self) @@ -30,7 +30,7 @@ class StatusesViewModel: ObservableObject { !($0.first ?? []).isEmpty || !(($0.last ?? []).isEmpty), !self.hasScrolledToParentAfterContextLoad { self.hasScrolledToParentAfterContextLoad = true - self.scrollToStatusIDInput.send(contextParent.id) + self.scrollToStatusInput.send(contextParent) } } .store(in: &cancellables) @@ -50,7 +50,25 @@ extension StatusesViewModel { .store(in: &cancellables) } + func statusViewModel(status: Status) -> StatusViewModel { + var statusViewModel = Self.viewModelCache[status] + ?? StatusViewModel(statusService: statusListService.statusService(status: status)) + + statusViewModel.isContextParent = status == contextParent + statusViewModel.isPinned = statusListService.isPinned(status: status) + statusViewModel.isReplyInContext = statusListService.isReplyInContext(status: status) + statusViewModel.hasReplyFollowing = statusListService.hasReplyFollowing(status: status) + + Self.viewModelCache[status] = statusViewModel + + return statusViewModel + } + func contextViewModel(status: Status) -> StatusesViewModel { StatusesViewModel(statusListService: statusListService.contextService(status: status)) } } + +private extension StatusesViewModel { + static var viewModelCache = [Status: StatusViewModel]() +} diff --git a/Shared/Views/StatusesView.swift b/Shared/Views/StatusesView.swift deleted file mode 100644 index c799292..0000000 --- a/Shared/Views/StatusesView.swift +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import SwiftUI - -struct StatusesView: View { - @StateObject var viewModel: StatusesViewModel - - var body: some View { - ScrollViewReader { scrollViewProxy in - ScrollView { - LazyVStack { - ForEach(Array(zip(viewModel.statusSections.indices, viewModel.statusSections)), - id: \.0) { _, statuses in - ForEach(statuses) { status in - if status == viewModel.contextParent { - statusView(status: status) - } else { - NavigationLink(destination: - LazyView(StatusesView(viewModel: - viewModel.contextViewModel(status: status)))) { - statusView(status: status) - } - .buttonStyle(PlainButtonStyle()) - } - Divider() - } - } - if viewModel.loading { - ProgressView() - } - } - } - .onReceive(viewModel.scrollToStatusID.receive(on: DispatchQueue.main)) { id in - withAnimation { - scrollViewProxy.scrollTo(id) - } - } - } - .onAppear { viewModel.request() } - .alertItem($viewModel.alertItem) - } -} - -private extension StatusesView { - func statusView(status: Status) -> some View { - Text(status.content) - } -} - -#if DEBUG -struct StatusesView_Previews: PreviewProvider { - static var previews: some View { - StatusesView(viewModel: .development) - } -} -#endif diff --git a/iOS/Extensions/String+UIKitExtensions.swift b/iOS/Extensions/String+UIKitExtensions.swift new file mode 100644 index 0000000..1be4358 --- /dev/null +++ b/iOS/Extensions/String+UIKitExtensions.swift @@ -0,0 +1,24 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit + +extension String { + func countEmphasizedAttributedString(count: Int, highlighted: Bool = false) -> NSAttributedString { + let countRange = (self as NSString).range(of: String.localizedStringWithFormat("%ld", count)) + + let attributed = NSMutableAttributedString( + string: self, + attributes: [ + .font: UIFont.preferredFont(forTextStyle: .body), + .foregroundColor: highlighted ? UIColor.tertiaryLabel : UIColor.secondaryLabel + ]) + attributed.addAttributes( + [ + .font: UIFont.preferredFont(forTextStyle: .headline), + .foregroundColor: highlighted ? UIColor.secondaryLabel : UIColor.label + ], + range: countRange) + + return attributed + } +} diff --git a/iOS/Extensions/UIColor+Extensions.swift b/iOS/Extensions/UIColor+Extensions.swift new file mode 100644 index 0000000..b521952 --- /dev/null +++ b/iOS/Extensions/UIColor+Extensions.swift @@ -0,0 +1,12 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit + +extension UIColor { + func image(_ size: CGSize = CGSize(width: 1, height: 1)) -> UIImage { + UIGraphicsImageRenderer(size: size).image { context in + self.setFill() + context.fill(CGRect(origin: .zero, size: size)) + } + } +} diff --git a/iOS/View Controllers/StatusListViewController.swift b/iOS/View Controllers/StatusListViewController.swift new file mode 100644 index 0000000..55c0360 --- /dev/null +++ b/iOS/View Controllers/StatusListViewController.swift @@ -0,0 +1,136 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import SwiftUI +import Combine + +class StatusListViewController: UITableViewController { + private let viewModel: StatusesViewModel + private var cancellables = Set() + private var cellHeightCaches = [CGFloat: [Status: CGFloat]]() + + private lazy var dataSource: UITableViewDiffableDataSource = { + UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, status in + guard + let self = self, + let cell = tableView.dequeueReusableCell( + withIdentifier: String(describing: StatusTableViewCell.self), + for: indexPath) as? StatusTableViewCell + else { return nil } + + let statusViewModel = self.viewModel.statusViewModel(status: status) + + cell.viewModel = statusViewModel + cell.delegate = self + + return cell + } + }() + + init(viewModel: StatusesViewModel) { + self.viewModel = viewModel + + super.init(style: .plain) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + for cellClass in [StatusTableViewCell.self] { + let classString = String(describing: cellClass) + tableView.register( + UINib(nibName: classString, bundle: nil), + forCellReuseIdentifier: classString) + } + + tableView.dataSource = dataSource + tableView.cellLayoutMarginsFollowReadableWidth = true + tableView.separatorInset = .zero + + viewModel.$statusSections.map { $0.snapshot() } + .sink { [weak self] in self?.dataSource.apply($0, animatingDifferences: false) } + .store(in: &cancellables) + + viewModel.scrollToStatus + .receive(on: DispatchQueue.main) + .sink { [weak self] in + guard + let self = self, + let indexPath = self.dataSource.indexPath(for: $0) + else { return } + + self.tableView.scrollToRow(at: indexPath, at: .none, animated: true) + } + .store(in: &cancellables) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + viewModel.request() + } + + override func tableView(_ tableView: UITableView, + willDisplay cell: UITableViewCell, + forRowAt indexPath: IndexPath) { + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } + + var heightCache = cellHeightCaches[tableView.frame.width] ?? [Status: CGFloat]() + + heightCache[item] = cell.frame.height + cellHeightCaches[tableView.frame.width] = heightCache + } + + override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + guard let item = dataSource.itemIdentifier(for: indexPath) else { return UITableView.automaticDimension } + + return cellHeightCaches[tableView.frame.width]?[item] ?? UITableView.automaticDimension + } + + override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { + viewModel.statusSections[indexPath.section][indexPath.row] != viewModel.contextParent + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let status = viewModel.statusSections[indexPath.section][indexPath.row] + + navigationController?.pushViewController( + StatusListViewController(viewModel: viewModel.contextViewModel(status: status)), + animated: true) + } +} + +extension StatusListViewController: StatusTableViewCellDelegate { + func statusTableViewCellDidHaveShareButtonTapped(_ cell: StatusTableViewCell) { + guard let url = cell.viewModel.sharingURL else { return } + + share(url: url) + } +} + +private extension StatusListViewController { + func share(url: URL) { + let activityViewController = UIActivityViewController(activityItems: [url], applicationActivities: nil) + + present(activityViewController, animated: true, completion: nil) + } +} + +private extension Array where Element == [Status] { + func snapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + + let sections = [Int](0.. StatusListViewController { + StatusListViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: StatusListViewController, context: Context) { + + } +} diff --git a/iOS/Views/StatusTableViewCell.swift b/iOS/Views/StatusTableViewCell.swift new file mode 100644 index 0000000..942fede --- /dev/null +++ b/iOS/Views/StatusTableViewCell.swift @@ -0,0 +1,367 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import AVKit +import Kingfisher +import UIKit + +protocol StatusTableViewCellDelegate: class { + func statusTableViewCellDidHaveShareButtonTapped(_ cell: StatusTableViewCell) +} + +class StatusTableViewCell: UITableViewCell { + @IBOutlet weak var metaIcon: UIImageView! + @IBOutlet weak var metaLabel: UILabel! + @IBOutlet weak var contentTextView: TouchFallthroughTextView! + @IBOutlet weak var avatarButton: UIButton! + @IBOutlet weak var avatarImageView: AnimatedImageView! + @IBOutlet weak var displayNameLabel: UILabel! + @IBOutlet weak var accountLabel: UILabel! + @IBOutlet weak var timeLabel: UILabel! + @IBOutlet weak var spoilerTextLabel: UILabel! + @IBOutlet weak var toggleSensitiveContentButton: UIButton! + @IBOutlet weak var replyButton: UIButton! + @IBOutlet weak var reblogButton: UIButton! + @IBOutlet weak var favoriteButton: UIButton! + @IBOutlet weak var shareButton: UIButton! + @IBOutlet weak var cardView: UIView! + @IBOutlet weak var cardImageView: UIImageView! + @IBOutlet weak var cardTitleLabel: UILabel! + @IBOutlet weak var cardDescriptionLabel: UILabel! + @IBOutlet weak var cardURLLabel: UILabel! + @IBOutlet weak var cardButton: UIButton! + @IBOutlet weak var sensitiveContentView: UIStackView! + @IBOutlet weak var hasReplyFollowingView: UIView! + @IBOutlet weak var inReplyToView: UIView! + @IBOutlet weak var avatarReplyContextView: UIView! + @IBOutlet weak var nameDateView: UIStackView! + @IBOutlet weak var contextParentAvatarNameView: UIStackView! + @IBOutlet weak var contextParentAvatarImageView: AnimatedImageView! + @IBOutlet weak var contextParentAvatarButton: UIButton! + @IBOutlet weak var contextParentDisplayNameLabel: UILabel! + @IBOutlet weak var contextParentAccountLabel: UILabel! + @IBOutlet weak var actionButtonsView: UIStackView! + @IBOutlet weak var contextParentReplyButton: UIButton! + @IBOutlet weak var contextParentReblogButton: UIButton! + @IBOutlet weak var contextParentFavoriteButton: UIButton! + @IBOutlet weak var contextParentShareButton: UIButton! + @IBOutlet weak var contextParentActionsButton: UIButton! + @IBOutlet weak var contextParentTimeLabel: UILabel! + @IBOutlet weak var timeApplicationDividerView: UILabel! + @IBOutlet weak var applicationButton: UIButton! + @IBOutlet weak var contextParentRebloggedByButton: UIButton! + @IBOutlet weak var contextParentFavoritedByButton: UIButton! + @IBOutlet weak var contextParentItems: UIStackView! + @IBOutlet weak var contextParentRebloggedByFavoritedByView: UIStackView! + @IBOutlet weak var contextParentRebloggedByFavoritedBySeparator: UIView! + + weak var delegate: StatusTableViewCellDelegate? + + @IBOutlet private var separatorConstraints: [NSLayoutConstraint]! + + var viewModel: StatusViewModel! { + didSet { + let mutableContent = NSMutableAttributedString(attributedString: viewModel.content) + let mutableDisplayName = NSMutableAttributedString(string: viewModel.displayName) + let mutableSpoilerText = NSMutableAttributedString(string: viewModel.spoilerText) + let contentFont = UIFont.preferredFont(forTextStyle: viewModel.isContextParent ? .title3 : .callout) + + contentTextView.shouldFallthrough = !viewModel.isContextParent + avatarReplyContextView.isHidden = viewModel.isContextParent + nameDateView.isHidden = viewModel.isContextParent + contextParentAvatarNameView.isHidden = !viewModel.isContextParent + actionButtonsView.isHidden = viewModel.isContextParent + contextParentItems.isHidden = !viewModel.isContextParent + + let avatarImageView: UIImageView + let displayNameLabel: UILabel + let accountLabel: UILabel + + if viewModel.isContextParent { + avatarImageView = contextParentAvatarImageView + displayNameLabel = contextParentDisplayNameLabel + accountLabel = contextParentAccountLabel + } else { + avatarImageView = self.avatarImageView + displayNameLabel = self.displayNameLabel + accountLabel = self.accountLabel + } + + let contentRange = NSRange(location: 0, length: mutableContent.length) + mutableContent.removeAttribute(.font, range: contentRange) + mutableContent.addAttributes( + [.font: contentFont as Any, + .foregroundColor: UIColor.label], + range: contentRange) + mutableContent.insert(emojis: viewModel.contentEmoji) { [weak self] in + self?.contentTextView.setNeedsDisplay() + } + mutableContent.resizeAttachments(toLineHeight: contentFont.lineHeight) + contentTextView.attributedText = mutableContent + contentTextView.isHidden = contentTextView.text == "" + mutableDisplayName.insert(emojis: viewModel.displayNameEmoji) { displayNameLabel.setNeedsDisplay() } + mutableDisplayName.resizeAttachments(toLineHeight: displayNameLabel.font.lineHeight) + displayNameLabel.attributedText = mutableDisplayName + mutableSpoilerText.insert(emojis: viewModel.contentEmoji) { [weak self] in + self?.spoilerTextLabel.setNeedsDisplay() + } + mutableSpoilerText.resizeAttachments(toLineHeight: spoilerTextLabel.font.lineHeight) + spoilerTextLabel.attributedText = mutableSpoilerText + spoilerTextLabel.isHidden = !viewModel.sensitive || spoilerTextLabel.text == "" + toggleSensitiveContentButton.setTitle( + viewModel.shouldDisplaySensitiveContent + ? NSLocalizedString("status.show-less", comment: "") + : NSLocalizedString("status.show-more", comment: ""), + for: .normal) + accountLabel.text = viewModel.accountName + timeLabel.text = viewModel.time + contextParentTimeLabel.text = viewModel.contextParentTime + timeApplicationDividerView.isHidden = viewModel.applicationName == nil + applicationButton.isHidden = viewModel.applicationName == nil + applicationButton.setTitle(viewModel.applicationName, for: .normal) + applicationButton.isEnabled = viewModel.applicationURL != nil + avatarImageView.kf.setImage(with: viewModel.avatarURL) + toggleSensitiveContentButton.isHidden = !viewModel.sensitive + replyButton.setTitle(viewModel.repliesCount == 0 ? "" : String(viewModel.repliesCount), for: .normal) + reblogButton.setTitle(viewModel.reblogsCount == 0 ? "" : String(viewModel.reblogsCount), for: .normal) + setReblogButtonColor(reblogged: viewModel.reblogged) + favoriteButton.setTitle(viewModel.favoritesCount == 0 ? "" : String(viewModel.favoritesCount), for: .normal) + setFavoriteButtonColor(favorited: viewModel.favorited) + + reblogButton.isEnabled = viewModel.canBeReblogged + contextParentReblogButton.isEnabled = viewModel.canBeReblogged + + let noReblogs = viewModel.reblogsCount == 0 + let noFavorites = viewModel.favoritesCount == 0 + let noInteractions = noReblogs && noFavorites + + setAttributedLocalizedTitle( + button: contextParentRebloggedByButton, + localizationKey: "status.reblogs-count", + count: viewModel.reblogsCount) + contextParentRebloggedByButton.isHidden = noReblogs + setAttributedLocalizedTitle( + button: contextParentFavoritedByButton, + localizationKey: "status.favorites-count", + count: viewModel.favoritesCount) + contextParentFavoritedByButton.isHidden = noFavorites + + contextParentRebloggedByFavoritedByView.isHidden = noInteractions + contextParentRebloggedByFavoritedBySeparator.isHidden = noInteractions + + if + viewModel.isReblog { + let metaText = String.localizedStringWithFormat( + NSLocalizedString("status.reblogged-by", comment: ""), + viewModel.rebloggedByDisplayName) + let mutableMetaText = NSMutableAttributedString(string: metaText) + mutableMetaText.insert(emojis: viewModel.rebloggedByDisplayNameEmoji) { [weak self] in + self?.metaLabel.setNeedsDisplay() + } + mutableMetaText.resizeAttachments(toLineHeight: metaLabel.font.lineHeight) + metaLabel.attributedText = mutableMetaText + metaIcon.image = UIImage( + systemName: "arrow.2.squarepath", + withConfiguration: UIImage.SymbolConfiguration(scale: .small)) + metaLabel.isHidden = false + metaIcon.isHidden = false + } else if viewModel.isPinned { + metaLabel.text = NSLocalizedString("status.pinned-post", comment: "") + metaIcon.image = UIImage( + systemName: "pin", + withConfiguration: UIImage.SymbolConfiguration(scale: .small)) + metaLabel.isHidden = false + metaIcon.isHidden = false + } else { + metaLabel.isHidden = true + metaIcon.isHidden = true + } + + if let cardURL = viewModel.cardURL { + cardTitleLabel.text = viewModel.cardTitle + cardDescriptionLabel.text = viewModel.cardDescription + cardDescriptionLabel.isHidden = cardDescriptionLabel.text == "" + || cardDescriptionLabel.text == cardTitleLabel.text + if + let host = cardURL.host, host.hasPrefix("www."), + let withoutWww = cardURL.host?.components(separatedBy: "www.").last { + cardURLLabel.text = withoutWww + } else { + cardURLLabel.text = cardURL.host + } + + if let cardImageURL = viewModel.cardImageURL { + cardImageView.isHidden = false + cardImageView.kf.setImage(with: cardImageURL) + } else { + cardImageView.isHidden = true + } + cardView.isHidden = false + } else { + cardView.isHidden = true + } + + sensitiveContentView.isHidden = !viewModel.shouldDisplaySensitiveContent + + inReplyToView.isHidden = !viewModel.isReplyInContext + + hasReplyFollowingView.isHidden = !viewModel.hasReplyFollowing + } + } + + override func awakeFromNib() { + super.awakeFromNib() + + for constraint in separatorConstraints { + constraint.constant = 1 / UIScreen.main.scale + } + + avatarImageView.kf.indicatorType = .activity + contextParentAvatarImageView.kf.indicatorType = .activity + cardImageView.kf.indicatorType = .activity + + contentTextView.delegate = self + + let highlightedButtonBackgroundImage = UIColor(white: 0, alpha: 0.5).image() + + cardButton.setBackgroundImage(highlightedButtonBackgroundImage, for: .highlighted) + avatarButton.setBackgroundImage(highlightedButtonBackgroundImage, for: .highlighted) + contextParentAvatarButton.setBackgroundImage(highlightedButtonBackgroundImage, for: .highlighted) + } + + override func prepareForReuse() { + super.prepareForReuse() + + avatarImageView.kf.cancelDownloadTask() + cardImageView.kf.cancelDownloadTask() + } + + override func layoutSubviews() { + super.layoutSubviews() + + for button: UIButton in [toggleSensitiveContentButton] where button.frame.height != 0 { + button.layer.cornerRadius = button.frame.height / 2 + } + + if hasReplyFollowingView.isHidden { + separatorInset.right = UIDevice.current.userInterfaceIdiom == .phone ? 0 : layoutMargins.right + } else { + separatorInset.right = .greatestFiniteMagnitude + } + + separatorInset.left = UIDevice.current.userInterfaceIdiom == .phone ? 0 : layoutMargins.left + } +} + +extension StatusTableViewCell { + @IBAction func avatarButtonTapped(_ sender: Any) { + + } + + @IBAction func toggleSensitiveContentButtonTapped(_ sender: Any) { + + } + + @IBAction func cardButtonTapped(_ sender: UIButton) { + + } + + @IBAction func replyButtonTapped(_ sender: UIButton) { + + } + + @IBAction func reblogButtonTapped(_ sender: UIButton) { + + } + + @IBAction func favoriteButtonTapped(_ sender: UIButton) { + + } + + @IBAction func actionsButtonTapped(_ sender: Any) { + + } + + @IBAction func shareButtonTapped(_ sender: Any) { + delegate?.statusTableViewCellDidHaveShareButtonTapped(self) + } + + @IBAction func applicationButtonTapped(_ sender: Any) { + + } + + @IBAction func contextParentRebloggedByButtonTapped(_ sender: Any) { + + } + + @IBAction func contextParentFavoritedByButtonTapped(_ sender: Any) { + + } +} + +extension StatusTableViewCell: UITextViewDelegate { + func textView( + _ textView: UITextView, + shouldInteractWith URL: URL, + in characterRange: NSRange, + interaction: UITextItemInteraction) -> Bool { + switch interaction { + case .invokeDefaultAction: print(URL); return false + case .preview: return false + case .presentActions: return false + @unknown default: return false + } + } +} + +private extension StatusTableViewCell { + private static let defaultAspectRatioConstraintMultiplier: CGFloat = 4.0 / 3.0 + private static let hasReplyFollowingSeparatorInsets = UIEdgeInsets( + top: 0, + left: 0, + bottom: 0, + right: .greatestFiniteMagnitude) + + private func setReblogButtonColor(reblogged: Bool) { + let reblogColor: UIColor = reblogged ? .systemGreen : .secondaryLabel + let reblogButton: UIButton + + if viewModel.isContextParent { + reblogButton = contextParentReblogButton + } else { + reblogButton = self.reblogButton + } + + reblogButton.tintColor = reblogColor + reblogButton.setTitleColor(reblogColor, for: .normal) + } + + private func setFavoriteButtonColor(favorited: Bool) { + let favoriteColor: UIColor = favorited ? .systemYellow : .secondaryLabel + let favoriteButton: UIButton + let scale: UIImage.SymbolScale + + if viewModel.isContextParent { + favoriteButton = contextParentFavoriteButton + scale = .medium + } else { + favoriteButton = self.favoriteButton + scale = .small + } + + favoriteButton.tintColor = favoriteColor + favoriteButton.setTitleColor(favoriteColor, for: .normal) + favoriteButton.setImage(UIImage( + systemName: favorited ? "star.fill" : "star", + withConfiguration: UIImage.SymbolConfiguration(scale: scale)), + for: .normal) + } + + private func setAttributedLocalizedTitle(button: UIButton, localizationKey: String, count: Int) { + let localizedTitle = String.localizedStringWithFormat(NSLocalizedString(localizationKey, comment: ""), count) + + button.setAttributedTitle(localizedTitle.countEmphasizedAttributedString(count: count), for: .normal) + button.setAttributedTitle( + localizedTitle.countEmphasizedAttributedString(count: count, highlighted: true), + for: .highlighted) + } +} diff --git a/iOS/Views/StatusTableViewCell.xib b/iOS/Views/StatusTableViewCell.xib new file mode 100644 index 0000000..277e102 --- /dev/null +++ b/iOS/Views/StatusTableViewCell.xib @@ -0,0 +1,556 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/Views/TabNavigationView.swift b/iOS/Views/TabNavigationView.swift index 65c22bd..df09bac 100644 --- a/iOS/Views/TabNavigationView.swift +++ b/iOS/Views/TabNavigationView.swift @@ -40,7 +40,7 @@ private extension TabNavigationView { func view(tab: TabNavigationViewModel.Tab) -> some View { switch tab { case .timelines: - StatusesView(viewModel: viewModel.timelineViewModel) + StatusListView(viewModel: viewModel.timelineViewModel) .navigationBarTitle(viewModel.identity.handle, displayMode: .inline) .navigationBarItems( leading: Button { diff --git a/iOS/Views/TouchFallthroughTextView.swift b/iOS/Views/TouchFallthroughTextView.swift new file mode 100644 index 0000000..37a34fa --- /dev/null +++ b/iOS/Views/TouchFallthroughTextView.swift @@ -0,0 +1,100 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit + +class TouchFallthroughTextView: UITextView { + var shouldFallthrough: Bool = true + + override init(frame: CGRect, textContainer: NSTextContainer?) { + super.init(frame: frame, textContainer: textContainer) + initializationActions() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + initializationActions() + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + shouldFallthrough ? urlAndRect(at: point) != nil : super.point(inside: point, with: event) + } + + override var selectedTextRange: UITextRange? { + get { shouldFallthrough ? nil : super.selectedTextRange } + set { + if !shouldFallthrough { + super.selectedTextRange = newValue + } + } + } + + override var intrinsicContentSize: CGSize { + return text == "" ? .zero : super.intrinsicContentSize + } + + func urlAndRect(at point: CGPoint) -> (URL, CGRect)? { + guard + let pos = closestPosition(to: point), + let range = tokenizer.rangeEnclosingPosition( + pos, with: .character, + inDirection: UITextDirection.layout(.left)) + else { return nil } + + let urlAtPointIndex = offset(from: beginningOfDocument, to: range.start) + + guard let url = attributedText.attribute( + .link, at: offset(from: beginningOfDocument, to: range.start), + effectiveRange: nil) as? URL + else { return nil } + + let maxLength = attributedText.length + var min = urlAtPointIndex + var max = urlAtPointIndex + + attributedText.enumerateAttribute( + .link, + in: NSRange(location: 0, length: urlAtPointIndex), + options: .reverse) { attribute, range, stop in + if let attributeURL = attribute as? URL, attributeURL == url, min > 0 { + min = range.location + } else { + stop.pointee = true + } + } + + attributedText.enumerateAttribute( + .link, + in: NSRange(location: urlAtPointIndex, length: maxLength - urlAtPointIndex), + options: []) { attribute, range, stop in + if let attributeURL = attribute as? URL, attributeURL == url, max < maxLength { + max = range.location + range.length + } else { + stop.pointee = true + } + } + + var urlRect = CGRect.zero + + layoutManager.enumerateEnclosingRects( + forGlyphRange: NSRange(location: min, length: max - min), + withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), + in: textContainer) { rect, _ in + if urlRect.origin == .zero { + urlRect.origin = rect.origin + } + + urlRect = urlRect.union(rect) + } + + return (url, urlRect) + } +} + +private extension TouchFallthroughTextView { + private func initializationActions() { + textDragInteraction?.isEnabled = false + textContainerInset = .zero + textContainer.lineFragmentPadding = 0 + linkTextAttributes = [.foregroundColor: tintColor as Any, .underlineColor: UIColor.clear] + } +}