From 5beb3ac594bf1f5c1902e3a2717ff661c023639e Mon Sep 17 00:00:00 2001 From: Martin Dutra Date: Tue, 14 Feb 2023 12:37:03 -0300 Subject: [PATCH 01/16] add MessageView --- Planetary.xcodeproj/project.pbxproj | 53 +++-- .../xcshareddata/xcschemes/Planetary.xcscheme | 9 +- Source/App/AppController+URL.swift | 10 +- Source/Bot/Bot.swift | 5 +- Source/FakeBot/FakeBot.swift | 6 +- .../GoBot/FeedStrategy/RepliesStrategy.swift | 198 ++++++++++++++++++ Source/GoBot/GoBot.swift | 15 +- Source/GoBot/ViewDatabase.swift | 26 ++- Source/Localization/Localized.swift | 1 + .../Localization/en.lproj/Generated.strings | 1 + Source/Model/SearchResults.swift | 17 ++ Source/UI/Message/CompactPostView.swift | 7 +- Source/UI/Message/CompactVoteView.swift | 71 +++++++ Source/UI/Message/MessageButton.swift | 9 +- Source/UI/Message/MessageCard.swift | 137 +++++------- Source/UI/Message/MessageHeaderView.swift | 14 +- Source/UI/Message/MessageStack.swift | 5 +- Source/UI/Message/MessageView.swift | 138 ++++++++++++ 18 files changed, 580 insertions(+), 142 deletions(-) create mode 100644 Source/GoBot/FeedStrategy/RepliesStrategy.swift create mode 100644 Source/UI/Message/CompactVoteView.swift create mode 100644 Source/UI/Message/MessageView.swift diff --git a/Planetary.xcodeproj/project.pbxproj b/Planetary.xcodeproj/project.pbxproj index 83da5d401f..79ff02cdb4 100644 --- a/Planetary.xcodeproj/project.pbxproj +++ b/Planetary.xcodeproj/project.pbxproj @@ -193,7 +193,7 @@ 23CA4B93232291E1005B35E8 /* UITextView+NSMutableAttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 539FD4B022C19F66005A4DF2 /* UITextView+NSMutableAttributedString.swift */; }; 23CF345722099E5D00F46A54 /* InvalidVoteMissingIdentifier.json in Resources */ = {isa = PBXBuildFile; fileRef = 23CF345622099E5D00F46A54 /* InvalidVoteMissingIdentifier.json */; }; 23D8CE272253DB29001EB7D5 /* Address.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23D8CE262253DB29001EB7D5 /* Address.swift */; }; - 23D8E2C022033D31004776EA /* BuildFile in Resources */ = {isa = PBXBuildFile; }; + 23D8E2C022033D31004776EA /* (null) in Resources */ = {isa = PBXBuildFile; }; 23E76A5623F2F0F70074F424 /* ValueTimestamp.json in Resources */ = {isa = PBXBuildFile; fileRef = 23E76A5523F2F0F70074F424 /* ValueTimestamp.json */; }; 23E8805E22F894650074D399 /* Collection+RandomSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23E8805D22F894650074D399 /* Collection+RandomSample.swift */; }; 23E8805F22F894650074D399 /* Collection+RandomSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23E8805D22F894650074D399 /* Collection+RandomSample.swift */; }; @@ -253,7 +253,7 @@ 530F01B222DFCEED007EBAE2 /* String+IsValid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 530F01B122DFCEED007EBAE2 /* String+IsValid.swift */; }; 530F01B422E0D3E7007EBAE2 /* TitledToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 530F01B322E0D3E7007EBAE2 /* TitledToggle.swift */; }; 530F01B622E0D930007EBAE2 /* JoinOnboardingStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 530F01B522E0D930007EBAE2 /* JoinOnboardingStep.swift */; }; - 5314EF3A22EA276A0065D02A /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; + 5314EF3A22EA276A0065D02A /* (null) in Frameworks */ = {isa = PBXBuildFile; }; 5314EF3B22EA27840065D02A /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5396A62A22445BE900C57A4B /* AppConfiguration.swift */; }; 5314EF3C22EA27920065D02A /* SSBNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53362F692209108F007264CB /* SSBNetwork.swift */; }; 5314EF3D22EA27980065D02A /* Secret.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE01F32204F66200DFDF16 /* Secret.swift */; }; @@ -760,6 +760,12 @@ 5BC2975D2806024B00C0CD81 /* PostsAndContactsAlgorithm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC2975C2806024B00C0CD81 /* PostsAndContactsAlgorithm.swift */; }; 5BC2975E2806024B00C0CD81 /* PostsAndContactsAlgorithm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC2975C2806024B00C0CD81 /* PostsAndContactsAlgorithm.swift */; }; 5BC3DE6128299CB900F6A363 /* SocialStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC3DE6028299CB900F6A363 /* SocialStats.swift */; }; + 5BC7F4FD29997900007D5566 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC7F4FC29997900007D5566 /* MessageView.swift */; }; + 5BC7F4FE29997900007D5566 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC7F4FC29997900007D5566 /* MessageView.swift */; }; + 5BC7F5042999ADAC007D5566 /* RepliesStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC7F5032999ADAC007D5566 /* RepliesStrategy.swift */; }; + 5BC7F5052999ADAC007D5566 /* RepliesStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC7F5032999ADAC007D5566 /* RepliesStrategy.swift */; }; + 5BC7F5082999BCC9007D5566 /* CompactVoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC7F5072999BCC9007D5566 /* CompactVoteView.swift */; }; + 5BC7F5092999BCC9007D5566 /* CompactVoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC7F5072999BCC9007D5566 /* CompactVoteView.swift */; }; 5BE28DFD27CD7BA1004D7D27 /* Analytics in Frameworks */ = {isa = PBXBuildFile; productRef = 5BE28DFC27CD7BA1004D7D27 /* Analytics */; }; 5BE69B0D27CEA1B70013D51D /* Analytics in Frameworks */ = {isa = PBXBuildFile; productRef = 5BE69B0C27CEA1B70013D51D /* Analytics */; }; 5BE8A30929193ECE00C4A38E /* BlobGalleryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BE8A30829193ECE00C4A38E /* BlobGalleryView.swift */; }; @@ -813,9 +819,9 @@ 8F6DDE5DB47CDEED56B74CB3 /* Pods_UnitTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5EA32E74C0DFF1EF8924773F /* Pods_UnitTests.framework */; }; C90EBE7327E0D5C900EAE560 /* PreloadedPubService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90EBE7227E0D5C900EAE560 /* PreloadedPubService.swift */; }; C90EBE7427E0D5C900EAE560 /* PreloadedPubService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90EBE7227E0D5C900EAE560 /* PreloadedPubService.swift */; }; - C9134DDB28184AF700595D49 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9134DDB28184AF700595D49 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9134DE228184C2700595D49 /* Support in Frameworks */ = {isa = PBXBuildFile; productRef = C9134DE128184C2700595D49 /* Support */; }; - C9134DE328184D4200595D49 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9134DE328184D4200595D49 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9134DE6281854E700595D49 /* Support in Frameworks */ = {isa = PBXBuildFile; productRef = C9134DE5281854E700595D49 /* Support */; }; C920A3A3289AFB88009C83F3 /* PlaceholderModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C920A3A2289AFB88009C83F3 /* PlaceholderModifier.swift */; }; C920A3A4289AFB88009C83F3 /* PlaceholderModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C920A3A2289AFB88009C83F3 /* PlaceholderModifier.swift */; }; @@ -1046,7 +1052,7 @@ C9724BB42809EC4F000EBCCD /* DebugOnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531587B1229F33F00004E2B1 /* DebugOnboardingViewController.swift */; }; C9724BB52809EC57000EBCCD /* DebugUIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 533F4050225E9E010060B4B9 /* DebugUIViewController.swift */; }; C9724BB62809EC61000EBCCD /* DebugPostsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531EC40B230F6B9E001A25AD /* DebugPostsViewController.swift */; }; - C9724BB72809EC68000EBCCD /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9724BB72809EC68000EBCCD /* (null) in Sources */ = {isa = PBXBuildFile; }; C9724BB82809EC77000EBCCD /* SecretViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53FCAFDD22051C88009E9E82 /* SecretViewController.swift */; }; C9724BB92809EC7B000EBCCD /* BotViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5396A62622444A1500C57A4B /* BotViewController.swift */; }; C9724BBA2809ECA2000EBCCD /* StatisticsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B5DB7CB24AC01D9008DCB81 /* StatisticsOperation.swift */; }; @@ -1719,6 +1725,9 @@ 5BC297582806022C00C0CD81 /* PostsAlgorithm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsAlgorithm.swift; sourceTree = ""; }; 5BC2975C2806024B00C0CD81 /* PostsAndContactsAlgorithm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsAndContactsAlgorithm.swift; sourceTree = ""; }; 5BC3DE6028299CB900F6A363 /* SocialStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialStats.swift; sourceTree = ""; }; + 5BC7F4FC29997900007D5566 /* MessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageView.swift; sourceTree = ""; }; + 5BC7F5032999ADAC007D5566 /* RepliesStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepliesStrategy.swift; sourceTree = ""; }; + 5BC7F5072999BCC9007D5566 /* CompactVoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactVoteView.swift; sourceTree = ""; }; 5BE8A30829193ECE00C4A38E /* BlobGalleryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlobGalleryView.swift; sourceTree = ""; }; 5BE8A30C2919C9E800C4A38E /* IdentityListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityListView.swift; sourceTree = ""; }; 5BE8A30F291A96FE00C4A38E /* IdentityOptionsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityOptionsButton.swift; sourceTree = ""; }; @@ -1801,7 +1810,6 @@ C969F438282C7A3700FF6FE3 /* bouncing_ellipse.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = bouncing_ellipse.json; sourceTree = ""; }; C969F43A282C7AB000FF6FE3 /* LottieAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieAnimation.swift; sourceTree = ""; }; C96D84092976E72A0026C23C /* BotMigrationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BotMigrationView.swift; sourceTree = ""; }; - C9724B0B28089499000EBCCD /* Beta1MigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Beta1MigrationTests.swift; sourceTree = ""; }; C978D35027BF018600842458 /* OperationQueue+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperationQueue+Async.swift"; sourceTree = ""; }; C97A826428D256D00064848A /* Task+CancellableSleep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Task+CancellableSleep.swift"; sourceTree = ""; }; C982CBD02833ED1C00D8963F /* FeedStrategySelectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStrategySelectionViewController.swift; sourceTree = ""; }; @@ -1923,7 +1931,7 @@ buildActionMask = 2147483647; files = ( 5BC2974C2804B9DE00C0CD81 /* SQLite in Frameworks */, - 5314EF3A22EA276A0065D02A /* BuildFile in Frameworks */, + 5314EF3A22EA276A0065D02A /* (null) in Frameworks */, 664DA6493E3CC0506BD71355 /* Pods_APITests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2771,6 +2779,8 @@ 5B533E52295B5F8200F5EED1 /* MessageGrid.swift */, 5BAB1840290C2634009F8F90 /* CompactPostView.swift */, 5B747F6F295E1AA8003D014A /* GoldenPostView.swift */, + 5BC7F4FC29997900007D5566 /* MessageView.swift */, + 5BC7F5072999BCC9007D5566 /* CompactVoteView.swift */, ); path = Message; sourceTree = ""; @@ -2857,6 +2867,7 @@ 5B2CF37D2950CE3100630CB6 /* HomeStrategy.swift */, 5B2CF3902952343B00630CB6 /* ProfileStrategy.swift */, 5B533E55295B85F400F5EED1 /* DiscoverStrategy.swift */, + 5BC7F5032999ADAC007D5566 /* RepliesStrategy.swift */, ); path = FeedStrategy; sourceTree = ""; @@ -3289,7 +3300,7 @@ mainGroup = 5344D82221C4649700704A34; packageReferences = ( C969F434282C79CC00FF6FE3 /* XCRemoteSwiftPackageReference "lottie-ios" */, - 5BC2974428048AD800C0CD81 /* XCRemoteSwiftPackageReference "SQLite.swift" */, + 5BC2974428048AD800C0CD81 /* XCRemoteSwiftPackageReference "SQLite" */, 5B5BB8DB283E8BFC00D99AB8 /* XCRemoteSwiftPackageReference "SkeletonView" */, ); productRefGroup = 5344D82C21C4649700704A34 /* Products */; @@ -3347,7 +3358,7 @@ 5316E95B21FFD4980053832E /* InvalidChannel.json in Resources */, C924412F278DDECE00592E5E /* Preload.bundle in Resources */, 5316E95721FFCDFF0053832E /* UnsupportedType.json in Resources */, - 23D8E2C022033D31004776EA /* BuildFile in Resources */, + 23D8E2C022033D31004776EA /* (null) in Resources */, 5316E95921FFD19F0053832E /* Invalid.json in Resources */, C98A014B2790C5A900E29A97 /* Feed_big.sqlite in Resources */, E041A24C293D45DB00629A8E /* RoomAliasAnnouncement_revoked.json in Resources */, @@ -3860,6 +3871,7 @@ C9724B7C2809E8D4000EBCCD /* PillButton.swift in Sources */, C982CBDE28353DBB00D8963F /* JoinPlanetarySystemOperation.swift in Sources */, C9724B382809D47D000EBCCD /* SplashOnboardingStep.swift in Sources */, + 5BC7F5092999BCC9007D5566 /* CompactVoteView.swift in Sources */, C9724B392809D47D000EBCCD /* DirectoryOnboardingStep.swift in Sources */, 53E29CE122D80427008A2CB1 /* Hashtag+NSAttributedString.swift in Sources */, 0A64A83924735392009A5EBF /* PushAPIService.swift in Sources */, @@ -3927,6 +3939,7 @@ C9724BC82809ED75000EBCCD /* UINavigationBar+Verse.swift in Sources */, C9724BDA2809EE32000EBCCD /* SendMissionOperation.swift in Sources */, 0ACE91A8243D748700EFB4E9 /* GoBotError+LocalizedError.swift in Sources */, + 5BC7F4FE29997900007D5566 /* MessageView.swift in Sources */, C9724B842809E938000EBCCD /* PostCellView.swift in Sources */, C9724BD62809EE12000EBCCD /* ConnectedPeerListView.swift in Sources */, 2358696224A0FB9100F4FC1D /* URLRequest+APIHeaders.swift in Sources */, @@ -3952,7 +3965,7 @@ C9724B642809D6DC000EBCCD /* NotificationCellView.swift in Sources */, C9724BBE2809ECCC000EBCCD /* UITextView+Verse.swift in Sources */, 53E29CE022D80424008A2CB1 /* Hashtag+String.swift in Sources */, - C9724BB72809EC68000EBCCD /* BuildFile in Sources */, + C9724BB72809EC68000EBCCD /* (null) in Sources */, 0A64A84024735520009A5EBF /* NullPushAPI.swift in Sources */, 23EF5AAA249D177F00469977 /* BanListAPI.swift in Sources */, 53E26C5E23045D34009240B2 /* Data+Person.swift in Sources */, @@ -3969,7 +3982,8 @@ 53B4F5FA22B8602F00027C6A /* Mention+NSAttributedString.swift in Sources */, 0A64A84824735717009A5EBF /* PhoneVerificationAPIService.swift in Sources */, C96D840C2976E72A0026C23C /* BotMigrationView.swift in Sources */, - C9134DE328184D4200595D49 /* BuildFile in Sources */, + C9134DE328184D4200595D49 /* (null) in Sources */, + C9134DE328184D4200595D49 /* (null) in Sources */, C9724BE72809EEA4000EBCCD /* SimplePublishView.swift in Sources */, C9724B432809D4F1000EBCCD /* UIButton+Text.swift in Sources */, 535754F722692B59002A6989 /* BotError.swift in Sources */, @@ -3997,13 +4011,14 @@ C9C3788C27C6CC6900238B58 /* Publisher+collectNext.swift in Sources */, C9724B5F2809D664000EBCCD /* UIScreen+Sizes.swift in Sources */, 53631EF623A9A93F009C6999 /* Blob+String.swift in Sources */, + 5BC7F5052999ADAC007D5566 /* RepliesStrategy.swift in Sources */, C9724BC42809ED5B000EBCCD /* Blob+UIColor.swift in Sources */, C9412AEB28AEE8EB00F791A7 /* RoomAliasRegistrationController.swift in Sources */, 5B4AEA7E294D14320059E039 /* CompactHashtagView.swift in Sources */, C9724B782809E7C1000EBCCD /* AppController+Push.swift in Sources */, 5B97D1F02964AFFB000AA5D1 /* IdentityButton.swift in Sources */, 5BA32EDD291597DE00744984 /* BotRepository.swift in Sources */, - C9134DDB28184AF700595D49 /* BuildFile in Sources */, + C9134DDB28184AF700595D49 /* (null) in Sources */, C9724BE02809EE5A000EBCCD /* Tappable.swift in Sources */, 5B2CF3922952343B00630CB6 /* ProfileStrategy.swift in Sources */, C9F1C84927C92842005A3228 /* Localizable.swift in Sources */, @@ -4412,6 +4427,7 @@ 5BEE741E2880515800897ACC /* Post+ViewDatabase.swift in Sources */, C95E7C73297898FD00E921F4 /* BotMigrationController.swift in Sources */, 535B6A10237362AE008C248E /* BlockedUsersViewController.swift in Sources */, + 5BC7F5042999ADAC007D5566 /* RepliesStrategy.swift in Sources */, 8D74DE012335601F003C284B /* UIViewController+NavigationItems.swift in Sources */, 0A64A82824734F00009A5EBF /* VersePubAPI.swift in Sources */, 539FD4AF22C19B9F005A4DF2 /* AboutsMenu.swift in Sources */, @@ -4583,6 +4599,7 @@ 533EDBF023861169008B3565 /* Caches.swift in Sources */, 5BC3DE6128299CB900F6A363 /* SocialStats.swift in Sources */, 5336611122D965B300100707 /* UIColor+Random.swift in Sources */, + 5BC7F5082999BCC9007D5566 /* CompactVoteView.swift in Sources */, C9F1C85527C9875A005A3228 /* Color+Hex.swift in Sources */, 8DA9567D230DA46C00A334EB /* UIButton+Text.swift in Sources */, 53B4F5F122B7123900027C6A /* NotificationsViewController.swift in Sources */, @@ -4844,6 +4861,7 @@ 530F01A222DEADD9007EBAE2 /* PhoneOnboardingStep.swift in Sources */, 53EAB398239AD0D700DF5530 /* AttributedStringCache.swift in Sources */, C9C3787F27C691EA00238B58 /* PeerConnectionInfo.swift in Sources */, + 5BC7F4FD29997900007D5566 /* MessageView.swift in Sources */, 53E26C4E23022482009240B2 /* AppDelegate+Push.swift in Sources */, C923DBB1288057EB00569AAB /* HelpDrawerView.swift in Sources */, 531B92BD22CD65ED005D5255 /* UIFont+Verse.swift in Sources */, @@ -5139,7 +5157,6 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_COMPILATION_MODE = singlefile; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; }; name = Debug; @@ -5198,7 +5215,6 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = RELEASE; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; VALIDATE_PRODUCT = YES; }; @@ -5233,7 +5249,6 @@ STRIP_SWIFT_SYMBOLS = NO; SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; SUPPORTS_MACCATALYST = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; @@ -5378,7 +5393,7 @@ minimumVersion = 1.0.0; }; }; - 5BC2974428048AD800C0CD81 /* XCRemoteSwiftPackageReference "SQLite.swift" */ = { + 5BC2974428048AD800C0CD81 /* XCRemoteSwiftPackageReference "SQLite" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/stephencelis/SQLite.swift"; requirement = { @@ -5429,17 +5444,17 @@ }; 5BC2974528048AD800C0CD81 /* SQLite */ = { isa = XCSwiftPackageProductDependency; - package = 5BC2974428048AD800C0CD81 /* XCRemoteSwiftPackageReference "SQLite.swift" */; + package = 5BC2974428048AD800C0CD81 /* XCRemoteSwiftPackageReference "SQLite" */; productName = SQLite; }; 5BC297492804B98C00C0CD81 /* SQLite */ = { isa = XCSwiftPackageProductDependency; - package = 5BC2974428048AD800C0CD81 /* XCRemoteSwiftPackageReference "SQLite.swift" */; + package = 5BC2974428048AD800C0CD81 /* XCRemoteSwiftPackageReference "SQLite" */; productName = SQLite; }; 5BC2974B2804B9DE00C0CD81 /* SQLite */ = { isa = XCSwiftPackageProductDependency; - package = 5BC2974428048AD800C0CD81 /* XCRemoteSwiftPackageReference "SQLite.swift" */; + package = 5BC2974428048AD800C0CD81 /* XCRemoteSwiftPackageReference "SQLite" */; productName = SQLite; }; 5BE28DFC27CD7BA1004D7D27 /* Analytics */ = { diff --git a/Planetary.xcodeproj/xcshareddata/xcschemes/Planetary.xcscheme b/Planetary.xcodeproj/xcshareddata/xcschemes/Planetary.xcscheme index 948adc5a50..ac6759c242 100644 --- a/Planetary.xcodeproj/xcshareddata/xcschemes/Planetary.xcscheme +++ b/Planetary.xcodeproj/xcshareddata/xcschemes/Planetary.xcscheme @@ -1,7 +1,7 @@ + version = "2.0"> @@ -66,12 +66,17 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + disableMainThreadChecker = "YES" + disablePerformanceAntipatternChecker = "YES" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" + debugXPCServices = "NO" debugServiceExtension = "internal" - allowLocationSimulation = "YES"> + allowLocationSimulation = "YES" + viewDebuggingEnabled = "No" + queueDebuggingEnabled = "No"> Message? + /// Fetches the post with the given ID from the database. func post(from key: MessageIdentifier) throws -> Message diff --git a/Source/FakeBot/FakeBot.swift b/Source/FakeBot/FakeBot.swift index 7c4ee59e47..cad367053f 100644 --- a/Source/FakeBot/FakeBot.swift +++ b/Source/FakeBot/FakeBot.swift @@ -318,7 +318,11 @@ class FakeBot: Bot, @unchecked Sendable { func feed(identity: Identity, completion: PaginatedCompletion) { completion(StaticDataProxy(), nil) } - + + func message(identifier: MessageIdentifier) async throws -> Message? { + return nil + } + func post(from key: MessageIdentifier) throws -> Message { throw FakeBotError.runtimeError("not implemented") } diff --git a/Source/GoBot/FeedStrategy/RepliesStrategy.swift b/Source/GoBot/FeedStrategy/RepliesStrategy.swift new file mode 100644 index 0000000000..bc7fa3ed6e --- /dev/null +++ b/Source/GoBot/FeedStrategy/RepliesStrategy.swift @@ -0,0 +1,198 @@ +// +// RepliesStrategy.swift +// Planetary +// +// Created by Martin Dutra on 12/2/23. +// Copyright © 2023 Verse Communications Inc. All rights reserved. +// + +import Foundation +import SQLite +import Logger + +/// This algorithm returns a feed with replies to a message +final class RepliesStrategy: NSObject, FeedStrategy { + + // swiftlint:disable indentation_width + /// SQL query to count the total number of items in the feed + /// + /// The WHERE clauses are as follows: + /// - Only posts and follows (contacts) + /// - Discard private messages + /// - Discard hidden messages + /// - Only follows (contacts) to people we know something about + /// - Only posts and follows from user itsef + /// - Discard posts and follows from the future + private let countNumberOfKeysQuery = """ + SELECT + COUNT(*) + FROM + tangles t + JOIN messagekeys rmk ON rmk.id = t.root + JOIN messagekeys ON messagekeys.id = t.msg_ref + JOIN messages messages ON messages.msg_id = t.msg_ref + JOIN authors a ON a.id = messages.author_id + WHERE + messages.type IN ('post', 'vote') + AND rmk.key = :message_identifier + AND messages.hidden = FALSE; + """ + // swiftlint:enable indentation_width + + // swiftlint:disable indentation_width + /// SQL query to return the feed's keyvalues + /// + /// The SELECT clauses are as follows: + /// - All data from message, post, contact, tangle, messagekey, author and about of the author + /// - The identified of the followed author if the message is a follow (contact) + /// - A bool column indicating if the message has blobs + /// - A bool column indicating if the message has feed mentions + /// - A bool column indicating if the message has message mentions + /// - The number of replies to the message + /// + /// The WHERE clauses are as follows: + /// - Only posts and follows (contacts) + /// - Discard private messages + /// - Discard hidden messages + /// - Only follows (contacts) to people we know something about + /// - Only posts and follows from user itsef + /// - Discard posts and follows from the future or before the message + /// + /// The result is sorted by date + private let fetchMessagesQuery = """ + SELECT + messages.*, + posts.*, + tangles.*, + messagekeys.*, + authors.*, + abouts.*, + votes.*, + EXISTS ( + SELECT + 1 + FROM + post_blobs + WHERE + post_blobs.msg_ref = messages.msg_id + ) as has_blobs, + EXISTS ( + SELECT + 1 + FROM + mention_feed + WHERE + mention_feed.msg_ref = messages.msg_id + ) as has_feed_mentions, + EXISTS ( + SELECT + 1 + FROM + mention_message + WHERE + mention_message.msg_ref = messages.msg_id + ) as has_message_mentions, + ( + SELECT + COUNT(*) + FROM + tangles + WHERE + root = messages.msg_id + ) as replies_count, + ( + SELECT + GROUP_CONCAT(abouts.image, ';') + FROM + tangles + JOIN messages AS tangled_message ON tangled_message.msg_id = tangles.msg_ref + JOIN abouts ON abouts.about_id = tangled_message.author_id + WHERE + tangles.root = messages.msg_id + AND abouts.image IS NOT NULL + LIMIT + 2 + ) as replies + FROM + tangles t + JOIN messagekeys rmk ON rmk.id = t.root + JOIN messagekeys ON messagekeys.id = t.msg_ref + JOIN messages messages ON messages.msg_id = t.msg_ref + JOIN authors ON authors.id = messages.author_id + LEFT JOIN tangles ON tangles.msg_ref = messages.msg_id + LEFT JOIN abouts ON abouts.about_id = messages.author_id + LEFT JOIN posts ON posts.msg_ref = t.msg_ref + LEFT JOIN votes ON votes.msg_ref = t.msg_ref + WHERE + messages.type IN ('post', 'vote') + AND rmk.key = :message_identifier + AND messages.hidden = FALSE + AND messages.is_decrypted = FALSE + ORDER BY + messages.claimed_at ASC + LIMIT + :limit + OFFSET + :offset; + """ + // swiftlint:enable indentation_width + + let identifier: MessageIdentifier + + override init() { + self.identifier = .null + super.init() + } + + init(identifier: MessageIdentifier) { + self.identifier = identifier + super.init() + } + + required init?(coder: NSCoder) { + self.identifier = .null + super.init() + } + + func encode(with coder: NSCoder) {} + + func countNumberOfKeys(connection: Connection, userId: Int64) throws -> Int { + let query = try connection.prepare(countNumberOfKeysQuery) + + let bindings: [String: Binding?] = [ + ":message_identifier": identifier + ] + + if let count = try query.scalar(bindings) as? Int64 { + return Int(truncatingIfNeeded: count) + } + return 0 + } + + func countNumberOfKeys(connection: Connection, userId: Int64, since message: MessageIdentifier) throws -> Int { + return 0 + } + + func fetchMessages(database: ViewDatabase, userId: Int64, limit: Int, offset: Int?) throws -> [Message] { + guard let connection = try? database.checkoutConnection() else { + Log.error("db is closed") + return [] + } + + let query = try connection.prepare(fetchMessagesQuery) + let bindings: [String: Binding?] = [ + ":message_identifier": identifier, + ":limit": limit, + ":offset": offset ?? 0 + ] + let messages = try query.bind(bindings).prepareRowIterator().map { messageRow -> Message? in + try buildMessage(messageRow: messageRow, database: database) + } + let compactMessages = messages.compactMap { $0 } + return compactMessages + } + + private func buildMessage(messageRow: Row, database: ViewDatabase) throws -> Message? { + try Message(row: messageRow, database: database) + } +} diff --git a/Source/GoBot/GoBot.swift b/Source/GoBot/GoBot.swift index c3235d25b5..18c836b2a1 100644 --- a/Source/GoBot/GoBot.swift +++ b/Source/GoBot/GoBot.swift @@ -1561,7 +1561,20 @@ class GoBot: Bot, @unchecked Sendable { func feed(identity: Identity, completion: @escaping PaginatedCompletion) { feed(strategy: NoHopFeedAlgorithm(identity: identity), completion: completion) } - + + func message(identifier: MessageIdentifier) async throws -> Message? { + try await withCheckedThrowingContinuation { continuation in + userInitiatedQueue.async { [identifier] in + do { + let message = try self.database.message(with: identifier) + continuation.resume(returning: message) + } catch { + continuation.resume(throwing: error) + } + } + } + } + func post(from key: MessageIdentifier) throws -> Message { try self.database.post(with: key) } diff --git a/Source/GoBot/ViewDatabase.swift b/Source/GoBot/ViewDatabase.swift index b99759e7f8..4cbb22fc94 100644 --- a/Source/GoBot/ViewDatabase.swift +++ b/Source/GoBot/ViewDatabase.swift @@ -685,9 +685,29 @@ class ViewDatabase { } } - func message(with id: MessageIdentifier) throws -> Message { - let msgId = try self.msgID(of: id, make: false) - return try post(with: msgId) + func message(with identifier: MessageIdentifier) throws -> Message? { + let db = try checkoutConnection() + + let qry = msgs + .join(msgKeys, on: msgKeys[colID] == msgs[colMessageID]) + .join(authors, on: authors[colID] == msgs[colAuthorID]) + .join(.leftOuter, tangles, on: tangles[colMessageRef] == msgs[colMessageID]) + .join(.leftOuter, posts, on: posts[colMessageRef] == msgs[colMessageID]) + .join(.leftOuter, votes, on: votes[colMessageRef] == msgs[colMessageID]) + .join(.leftOuter, abouts, on: abouts[colAboutID] == msgs[colAuthorID]) + .filter(msgKeys[colKey] == identifier) + .limit(1) + if let row = try db.prepare(qry).makeIterator().next() { + return try Message( + row: row, + database: self, + useNamespacedTables: true, + hasMentionColumns: false, + hasReplies: false + ) + } else { + return nil + } } // MARK: pubs & rooms diff --git a/Source/Localization/Localized.swift b/Source/Localization/Localized.swift index 8e0d19bf28..549180d251 100644 --- a/Source/Localization/Localized.swift +++ b/Source/Localization/Localized.swift @@ -80,6 +80,7 @@ enum Localized: String, Localizable, CaseIterable { case posted = "{{somebody}} posted" case replied = "{{somebody}} replied" case liked = "{{somebody}} liked" + case reacted = "{{somebody}} reacted" case startedFollowing = "{{somebody}} started following" case stoppedFollowing = "{{somebody}} stopped following" case startedBlocking = "{{somebody}} blocked" diff --git a/Source/Localization/en.lproj/Generated.strings b/Source/Localization/en.lproj/Generated.strings index 984d06fbbc..7dbb778743 100644 --- a/Source/Localization/en.lproj/Generated.strings +++ b/Source/Localization/en.lproj/Generated.strings @@ -34,6 +34,7 @@ "Localized.posted" = "{{somebody}} posted"; "Localized.replied" = "{{somebody}} replied"; "Localized.liked" = "{{somebody}} liked"; +"Localized.reacted" = "{{somebody}} reacted"; "Localized.startedFollowing" = "{{somebody}} started following"; "Localized.stoppedFollowing" = "{{somebody}} stopped following"; "Localized.startedBlocking" = "{{somebody}} blocked"; diff --git a/Source/Model/SearchResults.swift b/Source/Model/SearchResults.swift index 9f3d56f332..cfb32f7046 100644 --- a/Source/Model/SearchResults.swift +++ b/Source/Model/SearchResults.swift @@ -113,3 +113,20 @@ extension Either: Identifiable, Equatable, Hashable where Left == FeedIdentifier hasher.combine(id) } } + +extension Either where Left == MessageIdentifier, Right == Message { + var id: MessageIdentifier { + switch self { + case .left(let identifier): + return identifier + case .right(let message): + return message.id + } + } + static func == (lhs: Either, rhs: Either) -> Bool { + lhs.id == rhs.id + } + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/Source/UI/Message/CompactPostView.swift b/Source/UI/Message/CompactPostView.swift index 11b26e026a..2d29784fc1 100644 --- a/Source/UI/Message/CompactPostView.swift +++ b/Source/UI/Message/CompactPostView.swift @@ -12,16 +12,19 @@ struct CompactPostView: View { let identifier: MessageIdentifier + var lineLimit: Int? + var post: Post { didSet { blobs = post.anyBlobs } } - init(identifier: MessageIdentifier, post: Post) { + init(identifier: MessageIdentifier, post: Post, lineLimit: Int? = 5) { self.identifier = identifier self.post = post self.blobs = post.anyBlobs + self.lineLimit = lineLimit self.markdown = post.text.parseMarkdown() } @@ -48,7 +51,7 @@ struct CompactPostView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { Text(markdown) - .lineLimit(5) + .lineLimit(lineLimit) .font(.body) .foregroundColor(.primaryTxt) .accentColor(.accent) diff --git a/Source/UI/Message/CompactVoteView.swift b/Source/UI/Message/CompactVoteView.swift new file mode 100644 index 0000000000..0fcccf9f91 --- /dev/null +++ b/Source/UI/Message/CompactVoteView.swift @@ -0,0 +1,71 @@ +// +// CompactVoteView.swift +// Planetary +// +// Created by Martin Dutra on 12/2/23. +// Copyright © 2023 Verse Communications Inc. All rights reserved. +// + +import SwiftUI + +struct CompactVoteView: View { + + var identifier: MessageIdentifier + + var vote: Vote + + init(identifier: MessageIdentifier, vote: Vote) { + self.identifier = identifier + self.vote = vote + } + + @EnvironmentObject + private var appController: AppController + + var body: some View { + Text(expression) + .lineLimit(1) + .font(.body) + .foregroundColor(.secondaryTxt) + .padding(15) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var expression: AttributedString { + var expression: String + if let explicitExpression = vote.expression, + explicitExpression.isSingleEmoji { + expression = explicitExpression + } else if vote.value > 0 { + expression = "*\(Localized.likesThis.text)*" + } else { + expression = "*\(Localized.dislikesThis.text)*" + } + do { + return try AttributedString(markdown: expression) + } catch { + return AttributedString(expression) + } + } +} + +struct CompactVoteView_Previews: PreviewProvider { + static var like: Vote { + Vote(link: .null, value: 1, expression: nil) + } + + static var previews: some View { + Group { + VStack { + CompactVoteView(identifier: .null, vote: like) + } + VStack { + CompactVoteView(identifier: .null, vote: like) + } + .preferredColorScheme(.dark) + } + .padding() + .background(Color.cardBackground) + .environmentObject(BotRepository.fake) + } +} diff --git a/Source/UI/Message/MessageButton.swift b/Source/UI/Message/MessageButton.swift index 31ef7f3538..7a2d7ff626 100644 --- a/Source/UI/Message/MessageButton.swift +++ b/Source/UI/Message/MessageButton.swift @@ -15,19 +15,16 @@ import SwiftUI struct MessageButton: View { var message: Message var style = CardStyle.compact + var shouldDisplayChain = false @EnvironmentObject private var appController: AppController var body: some View { Button { - if let contact = message.content.contact { - appController.open(identity: contact.contact) - } else { - appController.open(identifier: message.id) - } + appController.open(identifier: message.id) } label: { - MessageCard(message: message, style: style) + MessageCard(message: message, style: style, shouldDisplayChain: shouldDisplayChain) } .buttonStyle(CardButtonStyle()) } diff --git a/Source/UI/Message/MessageCard.swift b/Source/UI/Message/MessageCard.swift index 2826359dc9..ed5cfac7b0 100644 --- a/Source/UI/Message/MessageCard.swift +++ b/Source/UI/Message/MessageCard.swift @@ -15,6 +15,7 @@ struct MessageCard: View { var message: Message var style = CardStyle.compact + var shouldDisplayChain = false @EnvironmentObject private var appController: AppController @@ -30,102 +31,58 @@ struct MessageCard: View { } var body: some View { - VStack(alignment: .leading, spacing: 0) { - switch style { - case .compact: - HStack(alignment: .center) { - Button { - appController.open(identity: author.identity) - } label: { - HStack(alignment: .center) { - AvatarView(metadata: author.image, size: 24) - if let header = attributedHeader { - Text(header) - .lineLimit(1) - .font(.subheadline) - .foregroundColor(Color.secondaryTxt) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - MessageOptionsButton(message: message) + ZStack { + if shouldDisplayChain { + Path { path in + path.move(to: CGPoint(x: 35, y: -4)) + path.addLine(to: CGPoint(x: 35, y: 15)) } - .padding(10) - Divider().overlay(Color.cardDivider).shadow(color: .cardDividerShadow, radius: 0, x: 0, y: 1) - if let contact = message.content.contact { - IdentityCard(identity: contact.contact, style: .compact) - } else if let post = message.content.post { - Group { + .stroke(style: StrokeStyle(lineWidth: 4, lineCap: .round)) + .fill(Color.secondaryTxt) + } + VStack(alignment: .leading, spacing: 0) { + switch style { + case .compact: + MessageHeaderView(message: message) + Divider().overlay(Color.cardDivider).shadow(color: .cardDividerShadow, radius: 0, x: 0, y: 1) + if let contact = message.content.contact { + IdentityCard(identity: contact.contact, style: .compact) + } else if let post = message.content.post { CompactPostView(identifier: message.id, post: post) - Divider().overlay(Color.cardDivider).shadow(color: .cardDividerShadow, radius: 0, x: 0, y: 1) - HStack { - StackedAvatarsView(avatars: replies, size: 20, border: 0) - if let replies = attributedReplies { - Text(replies) - .font(.subheadline) - .foregroundColor(Color.secondaryTxt) - } - Spacer() - Image.buttonReply + } else if let vote = message.content.vote { + CompactVoteView(identifier: message.id, vote: vote.vote) + } + Divider() + .overlay(Color.cardDivider) + .shadow(color: .cardDividerShadow, radius: 0, x: 0, y: 1) + HStack { + StackedAvatarsView(avatars: replies, size: 20, border: 0) + if let replies = attributedReplies { + Text(replies) + .font(.subheadline) + .foregroundColor(Color.secondaryTxt) } - .padding(15) + Spacer() + Image.buttonReply + } + .padding(15) + case .golden: + if let contact = message.content.contact { + GoldenIdentityView(identity: contact.contact) + } else if let post = message.content.post { + GoldenPostView(identifier: message.id, post: post, author: author) } - } - case .golden: - if let contact = message.content.contact { - GoldenIdentityView(identity: contact.contact) - } else if let post = message.content.post { - GoldenPostView(identifier: message.id, post: post, author: author) } } - } - .background( - LinearGradient( - colors: [Color.cardBgTop, Color.cardBgBottom], - startPoint: .top, - endPoint: .bottom + .background( + LinearGradient( + colors: [Color.cardBgTop, Color.cardBgBottom], + startPoint: .top, + endPoint: .bottom + ) ) - ) - .cornerRadius(cornerRadius) - .padding(padding) - } - - private var attributedHeader: AttributedString? { - var localized: Localized - switch message.contentType { - case .post: - guard let post = message.content.post else { - return nil - } - if post.isRoot { - localized = .posted - } else { - localized = .replied - } - case .contact: - guard let contact = message.content.contact else { - return nil - } - if contact.isBlocking { - localized = .startedBlocking - } else if contact.isFollowing { - localized = .startedFollowing - } else { - localized = .stoppedFollowing - } - default: - return nil - } - let string = localized.text(["somebody": "**\(author.displayName)**"]) - do { - var attributed = try AttributedString(markdown: string) - if let range = attributed.range(of: author.displayName) { - attributed[range].foregroundColor = .primaryTxt - } - return attributed - } catch { - return nil + .cornerRadius(cornerRadius) + .padding(padding) } } @@ -264,7 +221,7 @@ struct MessageCard_Previews: PreviewProvider { static var previews: some View { Group { ScrollView { - VStack { + VStack(spacing: 0) { MessageCard(message: message) MessageCard(message: messageWithOneReply) MessageCard(message: messageWithReplies) diff --git a/Source/UI/Message/MessageHeaderView.swift b/Source/UI/Message/MessageHeaderView.swift index 3b067be4f5..66f41e77e3 100644 --- a/Source/UI/Message/MessageHeaderView.swift +++ b/Source/UI/Message/MessageHeaderView.swift @@ -10,8 +10,6 @@ import SwiftUI struct MessageHeaderView: View { var message: Message - var attributedTitle: AttributedString? - var shouldDisplayOptions = true @EnvironmentObject private var appController: AppController @@ -22,9 +20,9 @@ struct MessageHeaderView: View { appController.open(identity: message.author) } label: { HStack(alignment: .center) { - AvatarView(metadata: message.metadata.author.about?.image, size: 24) - if let title = attributedTitle { - Text(title) + AvatarView(metadata: author.image, size: 24) + if let header = attributedHeader { + Text(header) .lineLimit(1) .font(.subheadline) .foregroundColor(Color.secondaryTxt) @@ -33,9 +31,7 @@ struct MessageHeaderView: View { } } } - if shouldDisplayOptions { - MessageOptionsButton(message: message) - } + MessageOptionsButton(message: message) } .padding(10) } @@ -73,6 +69,8 @@ struct MessageHeaderView: View { } else { localized = .stoppedFollowing } + case .vote: + localized = .reacted default: return nil } diff --git a/Source/UI/Message/MessageStack.swift b/Source/UI/Message/MessageStack.swift index 62e25aad0f..0ba6d492d7 100644 --- a/Source/UI/Message/MessageStack.swift +++ b/Source/UI/Message/MessageStack.swift @@ -15,10 +15,13 @@ struct MessageStack: View where DataSource: MessageDataSource { @ObservedObject var dataSource: DataSource + // If true, it will chain all messages + var chained = false + var body: some View { InfiniteStack(dataSource: dataSource) { message in if let message = message as? Message { - MessageButton(message: message) + MessageButton(message: message, shouldDisplayChain: chained) } } } diff --git a/Source/UI/Message/MessageView.swift b/Source/UI/Message/MessageView.swift new file mode 100644 index 0000000000..ea3fc97e43 --- /dev/null +++ b/Source/UI/Message/MessageView.swift @@ -0,0 +1,138 @@ +// +// MessageView.swift +// Planetary +// +// Created by Martin Dutra on 12/2/23. +// Copyright © 2023 Verse Communications Inc. All rights reserved. +// + +import CrashReporting +import Logger +import SwiftUI + +enum MessageViewBuilder { + static func build( + identifier: MessageIdentifier, + botRepository: BotRepository = BotRepository.shared, + appController: AppController = AppController.shared + ) -> UIHostingController { + UIHostingController( + rootView: MessageView(identifier: identifier, bot: botRepository.current) + .injectAppEnvironment(botRepository: botRepository, appController: appController) + ) + } +} + +struct MessageView: View { + /// The Identifier or the Message it will show information for. + /// + /// This view will load the Identifier if not present, or use the Message (and save a database call) if it is. + var identifierOrMessage: Either + + @State + private var message: Message? + + @EnvironmentObject + private var botRepository: BotRepository + + @ObservedObject + private var dataSource: FeedStrategyMessageDataSource + + init(identifier: MessageIdentifier, bot: Bot) { + self.init(identifierOrMessage: .left(identifier), bot: bot) + } + + init(message: Message, bot: Bot) { + self.init(identifierOrMessage: .right(message), bot: bot) + } + + init(identifierOrMessage: Either, bot: Bot) { + self.identifierOrMessage = identifierOrMessage + self.dataSource = FeedStrategyMessageDataSource( + strategy: RepliesStrategy(identifier: identifierOrMessage.id), + bot: bot + ) + } + + var body: some View { + Group { + if let message = message { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 0) { + MessageHeaderView(message: message) + Divider().overlay(Color.cardDivider).shadow(color: .cardDividerShadow, radius: 0, x: 0, y: 1) + if let contact = message.content.contact { + IdentityCard(identity: contact.contact, style: .compact) + } else if let post = message.content.post { + CompactPostView(identifier: message.id, post: post, lineLimit: nil) + } else if let vote = message.content.vote { + CompactVoteView(identifier: message.id, vote: vote.vote) + } + } + .background( + LinearGradient( + colors: [Color.cardBgTop, Color.cardBgBottom], + startPoint: .top, + endPoint: .bottom + ) + ) + .cornerRadius(20) + .padding(EdgeInsets(top: 15, leading: 15, bottom: 0, trailing: 15)) + .compositingGroup() + .shadow(color: .cardBorderBottom, radius: 0, x: 0, y: 4) + .shadow( + color: .cardShadowBottom, + radius: 10, + x: 0, + y: 4 + ) + MessageStack(dataSource: dataSource, chained: true) + .placeholder(when: dataSource.isEmpty, alignment: .top) { + EmptyView() + } + Spacer(minLength: 15) + } + } else { + LoadingView() + } + } + .background(Color.appBg) + .navigationTitle(Localized.Post.one.text) + .task { + loadMessageIfNeeded() + } + } + + private func loadMessageIfNeeded() { + guard message == nil else { + return + } + switch identifierOrMessage { + case .left(let messageIdentifier): + Task.detached { + let bot = await botRepository.current + do { + let result = try await bot.message(identifier: messageIdentifier) + await MainActor.run { + message = result + } + } catch { + Log.optional(error) + CrashReporting.shared.reportIfNeeded(error: error) + await MainActor.run { + message = nil + } + } + } + case .right(let message): + self.message = message + } + } +} + +struct MessageView_Previews: PreviewProvider { + static var previews: some View { + MessageView(identifier: .null, bot: FakeBot()) + .injectAppEnvironment(botRepository: .fake) + } +} From 3548138e9116a08d6192200a9938139d29126f13 Mon Sep 17 00:00:00 2001 From: Martin Dutra Date: Wed, 15 Feb 2023 14:43:46 -0300 Subject: [PATCH 02/16] Show follows in MessageView --- Source/GoBot/ViewDatabase.swift | 96 +++++++++++++++++++++----- Source/UI/Message/MessageView.swift | 101 +++++++++++++++++++--------- 2 files changed, 147 insertions(+), 50 deletions(-) diff --git a/Source/GoBot/ViewDatabase.swift b/Source/GoBot/ViewDatabase.swift index 4cbb22fc94..db15270d1f 100644 --- a/Source/GoBot/ViewDatabase.swift +++ b/Source/GoBot/ViewDatabase.swift @@ -688,23 +688,85 @@ class ViewDatabase { func message(with identifier: MessageIdentifier) throws -> Message? { let db = try checkoutConnection() - let qry = msgs - .join(msgKeys, on: msgKeys[colID] == msgs[colMessageID]) - .join(authors, on: authors[colID] == msgs[colAuthorID]) - .join(.leftOuter, tangles, on: tangles[colMessageRef] == msgs[colMessageID]) - .join(.leftOuter, posts, on: posts[colMessageRef] == msgs[colMessageID]) - .join(.leftOuter, votes, on: votes[colMessageRef] == msgs[colMessageID]) - .join(.leftOuter, abouts, on: abouts[colAboutID] == msgs[colAuthorID]) - .filter(msgKeys[colKey] == identifier) - .limit(1) - if let row = try db.prepare(qry).makeIterator().next() { - return try Message( - row: row, - database: self, - useNamespacedTables: true, - hasMentionColumns: false, - hasReplies: false - ) + // swiftlint:disable indentation_width + let query = """ + SELECT + messages.*, + posts.*, + contacts.*, + contact_about.about_id, + tangles.*, + messagekeys.*, + votes.*, + authors.*, + author_about.*, + contact_author.author AS contact_identifier, + EXISTS ( + SELECT + 1 + FROM + post_blobs + WHERE + post_blobs.msg_ref = messages.msg_id + ) as has_blobs, + EXISTS ( + SELECT + 1 + FROM + mention_feed + WHERE + mention_feed.msg_ref = messages.msg_id + ) as has_feed_mentions, + EXISTS ( + SELECT + 1 + FROM + mention_message + WHERE + mention_message.msg_ref = messages.msg_id + ) as has_message_mentions, + ( + SELECT + COUNT(*) + FROM + tangles + WHERE + root = messages.msg_id + ) as replies_count, + ( + SELECT + GROUP_CONCAT(abouts.image, ';') + FROM + tangles + JOIN messages AS tangled_message ON tangled_message.msg_id = tangles.msg_ref + JOIN abouts ON abouts.about_id = tangled_message.author_id + WHERE + tangles.root = messages.msg_id + AND abouts.image IS NOT NULL + LIMIT + 2 + ) as replies + FROM + messages + LEFT JOIN posts ON messages.msg_id = posts.msg_ref + LEFT JOIN contacts ON messages.msg_id = contacts.msg_ref + LEFT JOIN tangles ON tangles.msg_ref = messages.msg_id + LEFT JOIN votes ON votes.msg_ref = messages.msg_id + JOIN messagekeys ON messagekeys.id = messages.msg_id + JOIN authors ON authors.id = messages.author_id + LEFT JOIN abouts AS author_about ON author_about.about_id = messages.author_id + LEFT JOIN authors AS contact_author ON contact_author.id = contacts.contact_id + LEFT JOIN abouts AS contact_about ON contact_about.about_id = contacts.contact_id + WHERE + messagekeys.key = :message_identifier + LIMIT + 1 + """ + // swiftlint:enable indentation_width + + let bindings: [String: Binding?] = [":message_identifier": identifier] + if let row = try db.prepare(query).bind(bindings).prepareRowIterator().next() { + return try Message(row: row, database: self) } else { return nil } diff --git a/Source/UI/Message/MessageView.swift b/Source/UI/Message/MessageView.swift index ea3fc97e43..cdd2201ad6 100644 --- a/Source/UI/Message/MessageView.swift +++ b/Source/UI/Message/MessageView.swift @@ -57,40 +57,42 @@ struct MessageView: View { var body: some View { Group { if let message = message { - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 0) { - MessageHeaderView(message: message) - Divider().overlay(Color.cardDivider).shadow(color: .cardDividerShadow, radius: 0, x: 0, y: 1) - if let contact = message.content.contact { - IdentityCard(identity: contact.contact, style: .compact) - } else if let post = message.content.post { - CompactPostView(identifier: message.id, post: post, lineLimit: nil) - } else if let vote = message.content.vote { - CompactVoteView(identifier: message.id, vote: vote.vote) + VStack(spacing: 0) { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 0) { + MessageHeaderView(message: message) + Divider().overlay(Color.cardDivider).shadow(color: .cardDividerShadow, radius: 0, x: 0, y: 1) + if let contact = message.content.contact { + IdentityCard(identity: contact.contact, style: .compact) + } else if let post = message.content.post { + CompactPostView(identifier: message.id, post: post, lineLimit: nil) + } else if let vote = message.content.vote { + CompactVoteView(identifier: message.id, vote: vote.vote) + } } - } - .background( - LinearGradient( - colors: [Color.cardBgTop, Color.cardBgBottom], - startPoint: .top, - endPoint: .bottom + .background( + LinearGradient( + colors: [Color.cardBgTop, Color.cardBgBottom], + startPoint: .top, + endPoint: .bottom + ) ) - ) - .cornerRadius(20) - .padding(EdgeInsets(top: 15, leading: 15, bottom: 0, trailing: 15)) - .compositingGroup() - .shadow(color: .cardBorderBottom, radius: 0, x: 0, y: 4) - .shadow( - color: .cardShadowBottom, - radius: 10, - x: 0, - y: 4 - ) - MessageStack(dataSource: dataSource, chained: true) - .placeholder(when: dataSource.isEmpty, alignment: .top) { - EmptyView() - } - Spacer(minLength: 15) + .cornerRadius(20) + .padding(EdgeInsets(top: 15, leading: 15, bottom: 0, trailing: 15)) + .compositingGroup() + .shadow(color: .cardBorderBottom, radius: 0, x: 0, y: 4) + .shadow( + color: .cardShadowBottom, + radius: 10, + x: 0, + y: 4 + ) + MessageStack(dataSource: dataSource, chained: true) + .placeholder(when: dataSource.isEmpty, alignment: .top) { + EmptyView() + } + Spacer(minLength: 15) + } } } else { LoadingView() @@ -131,8 +133,41 @@ struct MessageView: View { } struct MessageView_Previews: PreviewProvider { + static var messageValue: MessageValue { + MessageValue( + author: "@QW5uYVZlcnNlWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFg=.ed25519", + content: Content( + from: Post( + blobs: nil, + branches: nil, + hashtags: nil, + mentions: nil, + root: nil, + text: .loremIpsum(words: 10) + ) + ), + hash: "", + previous: nil, + sequence: 0, + signature: .null, + claimedTimestamp: 0 + ) + } + static var message: Message { + var message = Message( + key: "@unset", + value: messageValue, + timestamp: 0 + ) + message.metadata = Message.Metadata( + author: Message.Metadata.Author(about: About(about: .null, name: "Mario")), + replies: Message.Metadata.Replies(count: 0, abouts: Set()), + isPrivate: false + ) + return message + } static var previews: some View { - MessageView(identifier: .null, bot: FakeBot()) + MessageView(message: message, bot: FakeBot()) .injectAppEnvironment(botRepository: .fake) } } From 75922b213e7419c4b3ca8cf526381e9f1d121eff Mon Sep 17 00:00:00 2001 From: Filip Borkiewicz Date: Wed, 8 Mar 2023 16:10:26 +0100 Subject: [PATCH 03/16] Bump version number to v2.0.1 (#1253) --- CHANGELOG.md | 4 ++++ Planetary.xcodeproj/project.pbxproj | 8 ++++---- Resources/Info.plist | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d98c1d039..88747e35ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- ... + +## [2.0.0] _waiting for review_ + - Updated the localization strategy to have a better support of foreign languages. #1065 - Added the option to join the Planetary room to the Manage Rooms screen. #1137 - Add a button to delete the SQL database in the debug settings. #738 diff --git a/Planetary.xcodeproj/project.pbxproj b/Planetary.xcodeproj/project.pbxproj index 27ca3cb94e..3f3c056599 100644 --- a/Planetary.xcodeproj/project.pbxproj +++ b/Planetary.xcodeproj/project.pbxproj @@ -5206,7 +5206,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Resources/FBTT.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 415; + CURRENT_PROJECT_VERSION = 416; DEVELOPMENT_TEAM = GZCZBKH7MY; EAGER_LINKING = YES; ENABLE_BITCODE = NO; @@ -5221,7 +5221,7 @@ "@executable_path/", ); MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.0.1; OTHER_LDFLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS"; SDKROOT = iphoneos; @@ -5241,7 +5241,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Resources/FBTT.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 415; + CURRENT_PROJECT_VERSION = 416; DEVELOPMENT_TEAM = GZCZBKH7MY; EAGER_LINKING = YES; ENABLE_BITCODE = NO; @@ -5256,7 +5256,7 @@ "@executable_path/", ); MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.0.1; OTHER_LDFLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS"; SDKROOT = iphoneos; diff --git a/Resources/Info.plist b/Resources/Info.plist index 37d7ca7e9d..4d23d8ebff 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -48,7 +48,7 @@ CFBundleVersion - 415 + 416 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS From 793061310c7adc6f124ba6e9d8792564178e46e0 Mon Sep 17 00:00:00 2001 From: Filip Borkiewicz Date: Mon, 13 Mar 2023 16:05:13 +0100 Subject: [PATCH 04/16] Bump github.com/planetary-social/scuttlego (#1256) v0.0.3 -> v0.0.4 Commits: - 1cb6a70c37259ccf2244053289fde94460b30d82 Use jsoniter for all marshaling and unmarshaling - feecc3693f9377a89cb1cad7e6f3ee5c75318c81 Update go-ssb to d6db27d - 9ae9c6665a60b285d15d742947c253945c903f3b Release v0.0.4 --- CHANGELOG.md | 2 +- Frameworks/GoSSB.xcframework/ios-arm64/libssb-go.a | 4 ++-- .../ios-arm64_x86_64-simulator/libssb-go.a | 4 ++-- GoSSB/Sources/go.mod | 4 ++-- GoSSB/Sources/go.sum | 8 ++++---- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88747e35ce..c7783e3397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- ... +- Improved message replication performance. ## [2.0.0] _waiting for review_ diff --git a/Frameworks/GoSSB.xcframework/ios-arm64/libssb-go.a b/Frameworks/GoSSB.xcframework/ios-arm64/libssb-go.a index 7366df4bfb..fb536af537 100644 --- a/Frameworks/GoSSB.xcframework/ios-arm64/libssb-go.a +++ b/Frameworks/GoSSB.xcframework/ios-arm64/libssb-go.a @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6dbd6a2b2441ba55bd1bd0224fe7396bc04c3e605a2d3d76d0d59af4fa15ca4a -size 18246424 +oid sha256:88a235130aa514f3687de0bb9941956683d747a1d7bfe733dcb8f18726dc90bc +size 18148128 diff --git a/Frameworks/GoSSB.xcframework/ios-arm64_x86_64-simulator/libssb-go.a b/Frameworks/GoSSB.xcframework/ios-arm64_x86_64-simulator/libssb-go.a index cdeff9cbb6..20fe37a533 100644 --- a/Frameworks/GoSSB.xcframework/ios-arm64_x86_64-simulator/libssb-go.a +++ b/Frameworks/GoSSB.xcframework/ios-arm64_x86_64-simulator/libssb-go.a @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a81136932440c4d3a5d1a0cb414061b2d6b33b6bae3503a1479d63048c827a6 -size 35229520 +oid sha256:5c251ee1529724bc4116f15be1b31c5eea53a4ccef3159a0115231425d9fcbdb +size 35031776 diff --git a/GoSSB/Sources/go.mod b/GoSSB/Sources/go.mod index 80bbf384a7..0564b6bf1d 100644 --- a/GoSSB/Sources/go.mod +++ b/GoSSB/Sources/go.mod @@ -4,9 +4,9 @@ require ( github.com/boreq/errors v0.1.0 github.com/dgraph-io/badger/v3 v3.2103.5 github.com/pkg/errors v0.9.1 - github.com/planetary-social/scuttlego v0.0.3 + github.com/planetary-social/scuttlego v0.0.4 github.com/sirupsen/logrus v1.8.1 - github.com/ssbc/go-ssb v0.2.2-0.20230212123438-2cdd828cd8c8 + github.com/ssbc/go-ssb v0.2.2-0.20230308230318-d6db27d1852d github.com/ssbc/go-ssb-multiserver v0.1.5-0.20221019203850-917ae0e23d57 github.com/ssbc/go-ssb-refs v0.5.2 github.com/stretchr/testify v1.8.1 diff --git a/GoSSB/Sources/go.sum b/GoSSB/Sources/go.sum index d68931f759..5b3da52858 100644 --- a/GoSSB/Sources/go.sum +++ b/GoSSB/Sources/go.sum @@ -175,8 +175,8 @@ github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/planetary-social/scuttlego v0.0.3 h1:Z3Qn+DDUem5WYBARuf6atCMUn+smo5MqFUM818MZb0k= -github.com/planetary-social/scuttlego v0.0.3/go.mod h1:6317yJFAMsnkTvfGkvKvOfq05hCU+2SMc78j3sgVjiU= +github.com/planetary-social/scuttlego v0.0.4 h1:54rsM2Aj/5KA7QixRPsca0pYDQVoQp56SjQq4Spg5dI= +github.com/planetary-social/scuttlego v0.0.4/go.mod h1:4JQ1EHILc5UmSD86fEhNuy3pyFs4HmMhpce/YCSuX8g= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -215,8 +215,8 @@ github.com/ssbc/go-netwrap v0.1.5-0.20221019160355-cd323bb2e29d/go.mod h1:tsE1qe github.com/ssbc/go-secretstream v1.2.11-0.20221019175226-fa042d4912fe/go.mod h1:imXhXNa5OfEL+qrGtOs6NZ9zJe6L3P+ZwFVC2mIgH0E= github.com/ssbc/go-secretstream v1.2.11-0.20221111164233-4b41f899f844 h1:r1uKQOpTliDf9BCMbRfCeynZ87Y+XMs/DBZqZzB596Y= github.com/ssbc/go-secretstream v1.2.11-0.20221111164233-4b41f899f844/go.mod h1:imXhXNa5OfEL+qrGtOs6NZ9zJe6L3P+ZwFVC2mIgH0E= -github.com/ssbc/go-ssb v0.2.2-0.20230212123438-2cdd828cd8c8 h1:p1Nwim4mUOrsY/iXlu2m/Ctk0p6GjHJNg4WUqIygEjY= -github.com/ssbc/go-ssb v0.2.2-0.20230212123438-2cdd828cd8c8/go.mod h1:kBucAtyavzNPi81r37zkYSa569sOvvO2nwvzMUexG3w= +github.com/ssbc/go-ssb v0.2.2-0.20230308230318-d6db27d1852d h1:3jzEBxJe5nqHhaIlM984T52YPsRwbNrwuXpkQsllAtQ= +github.com/ssbc/go-ssb v0.2.2-0.20230308230318-d6db27d1852d/go.mod h1:tKx4OFBqFpNCxYUZagBnfqVfLhYtzUM1vJVR3tUkLnw= github.com/ssbc/go-ssb-multiserver v0.1.5-0.20221019203850-917ae0e23d57 h1:wfIu3HcI8HGLSbJFo35eKMjFBMhHhXJsYGTAOVhpNkQ= github.com/ssbc/go-ssb-multiserver v0.1.5-0.20221019203850-917ae0e23d57/go.mod h1:LMJaMYVJxtYKTj8A0t8+iM+K2/SuYSvp14urj9sHLa8= github.com/ssbc/go-ssb-refs v0.5.2-0.20221017100922-8e95413c6580/go.mod h1:rUj40X7iiUWFt62aF4NVm3uNnKiPZ9Uo/ds4D4ZE980= From 99a85199aea88c390e14a569bcf06b00f19bead8 Mon Sep 17 00:00:00 2001 From: Martin Dutra Date: Wed, 15 Mar 2023 10:26:44 -0300 Subject: [PATCH 05/16] add a compose and a preview screen in SwiftUI --- Planetary.xcodeproj/project.pbxproj | 30 +- .../Extensions/LinearGradient+Planetary.swift | 6 + Source/Bot/Bot+Publish.swift | 22 +- Source/Controller/NewPostViewController.swift | 262 --------------- .../NotificationsViewController.swift | 24 +- Source/Controller/StoreReviewController.swift | 2 +- Source/Controller/ThreadViewController.swift | 33 -- Source/Localization/Localized.swift | 1 + .../Localization/en.lproj/Generated.strings | 1 + Source/Model/Draft.swift | 22 +- Source/Model/Post.swift | 128 +++---- Source/UI/Buttons/PillButtonStyle.swift | 4 +- Source/UI/Compose/AttachedImageButton.swift | 62 ++++ Source/UI/Compose/ComposeView.swift | 314 ++++++++++++++++++ Source/UI/Compose/ImagePickerButton.swift | 133 ++++++++ Source/UI/Compose/PreviewView.swift | 248 ++++++++++++++ Source/UI/Discover/DiscoverView.swift | 17 +- Source/UI/Home/HomeView.swift | 17 +- .../UI/Identity/ExtendedSocialStatsView.swift | 1 - Source/UI/Identity/GoldenIdentityView.swift | 6 +- Source/UI/Identity/IdentityListView.swift | 63 +++- Source/UI/Identity/IdentityView.swift | 1 + Source/UI/Identity/ImagePicker.swift | 3 +- Source/UI/LoadingView.swift | 31 +- Source/UI/Message/MessageCard.swift | 6 +- Source/UI/PostTextEditorView.swift | 4 + 26 files changed, 992 insertions(+), 449 deletions(-) delete mode 100644 Source/Controller/NewPostViewController.swift create mode 100644 Source/UI/Compose/AttachedImageButton.swift create mode 100644 Source/UI/Compose/ComposeView.swift create mode 100644 Source/UI/Compose/ImagePickerButton.swift create mode 100644 Source/UI/Compose/PreviewView.swift diff --git a/Planetary.xcodeproj/project.pbxproj b/Planetary.xcodeproj/project.pbxproj index 3f3c056599..29d502600d 100644 --- a/Planetary.xcodeproj/project.pbxproj +++ b/Planetary.xcodeproj/project.pbxproj @@ -560,7 +560,6 @@ 53E341F4224D9E3B002BB5F4 /* URL+Identifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E341F3224D9E3B002BB5F4 /* URL+Identifier.swift */; }; 53E341F6224D9FC7002BB5F4 /* AppController+URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E341F5224D9FC7002BB5F4 /* AppController+URL.swift */; }; 53E341F8224DB1D8002BB5F4 /* BlobViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E341F7224DB1D8002BB5F4 /* BlobViewController.swift */; }; - 53E341FC224FF87D002BB5F4 /* NewPostViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E341FB224FF87D002BB5F4 /* NewPostViewController.swift */; }; 53E341FE224FF9EE002BB5F4 /* UIImage+Verse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E341FD224FF9EE002BB5F4 /* UIImage+Verse.swift */; }; 53E34200225000D4002BB5F4 /* UIViewController+Keyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E341FF225000D4002BB5F4 /* UIViewController+Keyboard.swift */; }; 53E34202225008FE002BB5F4 /* Notification+Keyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E34201225008FE002BB5F4 /* Notification+Keyboard.swift */; }; @@ -668,6 +667,10 @@ 5B533E56295B85F400F5EED1 /* DiscoverStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B533E55295B85F400F5EED1 /* DiscoverStrategy.swift */; }; 5B533E57295B85F400F5EED1 /* DiscoverStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B533E55295B85F400F5EED1 /* DiscoverStrategy.swift */; }; 5B5BB8DD283E8BFC00D99AB8 /* SkeletonView in Frameworks */ = {isa = PBXBuildFile; productRef = 5B5BB8DC283E8BFC00D99AB8 /* SkeletonView */; }; + 5B5BF56829BA5746003C09A4 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5BF56729BA5746003C09A4 /* ComposeView.swift */; }; + 5B5BF56A29BB7619003C09A4 /* PreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5BF56929BB7619003C09A4 /* PreviewView.swift */; }; + 5B5BF56C29BFE065003C09A4 /* ImagePickerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5BF56B29BFE065003C09A4 /* ImagePickerButton.swift */; }; + 5B5BF56E29C01E5A003C09A4 /* AttachedImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5BF56D29C01E5A003C09A4 /* AttachedImageButton.swift */; }; 5B5EF4F7294FDA460052237A /* InfiniteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5EF4F6294FDA460052237A /* InfiniteDataSource.swift */; }; 5B5EF4F8294FDA460052237A /* InfiniteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5EF4F6294FDA460052237A /* InfiniteDataSource.swift */; }; 5B5EF4FB294FE1230052237A /* MessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5EF4FA294FE1230052237A /* MessageList.swift */; }; @@ -956,7 +959,6 @@ C9724B582809D5F8000EBCCD /* PostCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A4871002498030800BCD063 /* PostCollectionViewCell.swift */; }; C9724B592809D5FE000EBCCD /* UIEdgeInsets+Layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5396A6202244312400C57A4B /* UIEdgeInsets+Layout.swift */; }; C9724B5A2809D60B000EBCCD /* UIViewController+Sync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536255DD23AC4C46001007D0 /* UIViewController+Sync.swift */; }; - C9724B5B2809D614000EBCCD /* NewPostViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E341FB224FF87D002BB5F4 /* NewPostViewController.swift */; }; C9724B5D2809D62A000EBCCD /* MessagePaginatedCollectionViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A4870FE249801CD00BCD063 /* MessagePaginatedCollectionViewDataSource.swift */; }; C9724B5E2809D651000EBCCD /* NSMutableAttributedString+Attributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531B92BA22CD1383005D5255 /* NSMutableAttributedString+Attributes.swift */; }; C9724B5F2809D664000EBCCD /* UIScreen+Sizes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D768CEB2321A38800B6EC29 /* UIScreen+Sizes.swift */; }; @@ -1610,7 +1612,6 @@ 53E341F3224D9E3B002BB5F4 /* URL+Identifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Identifier.swift"; sourceTree = ""; }; 53E341F5224D9FC7002BB5F4 /* AppController+URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppController+URL.swift"; sourceTree = ""; }; 53E341F7224DB1D8002BB5F4 /* BlobViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlobViewController.swift; sourceTree = ""; }; - 53E341FB224FF87D002BB5F4 /* NewPostViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewPostViewController.swift; sourceTree = ""; }; 53E341FD224FF9EE002BB5F4 /* UIImage+Verse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Verse.swift"; sourceTree = ""; }; 53E341FF225000D4002BB5F4 /* UIViewController+Keyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Keyboard.swift"; sourceTree = ""; }; 53E34201225008FE002BB5F4 /* Notification+Keyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Keyboard.swift"; sourceTree = ""; }; @@ -1669,6 +1670,10 @@ 5B533E4F295B5D0500F5EED1 /* InfiniteGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfiniteGrid.swift; sourceTree = ""; }; 5B533E52295B5F8200F5EED1 /* MessageGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageGrid.swift; sourceTree = ""; }; 5B533E55295B85F400F5EED1 /* DiscoverStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoverStrategy.swift; sourceTree = ""; }; + 5B5BF56729BA5746003C09A4 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; + 5B5BF56929BB7619003C09A4 /* PreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewView.swift; sourceTree = ""; }; + 5B5BF56B29BFE065003C09A4 /* ImagePickerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePickerButton.swift; sourceTree = ""; }; + 5B5BF56D29C01E5A003C09A4 /* AttachedImageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachedImageButton.swift; sourceTree = ""; }; 5B5EF4F6294FDA460052237A /* InfiniteDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfiniteDataSource.swift; sourceTree = ""; }; 5B5EF4FA294FE1230052237A /* MessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageList.swift; sourceTree = ""; }; 5B67FFDC2863B4F40028ABE4 /* NumberOfRecentItemsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberOfRecentItemsOperation.swift; sourceTree = ""; }; @@ -2172,7 +2177,6 @@ 53B634B322150A4100400403 /* MainViewController.swift */, 0AD8D630240EC38600D87A95 /* ManagePubsViewController.swift */, 5319EBD622F22BA700EC7583 /* MenuViewController.swift */, - 53E341FB224FF87D002BB5F4 /* NewPostViewController.swift */, 53B4F5F022B7123900027C6A /* NotificationsViewController.swift */, 5B1FF5E428D8AA25008F3A85 /* RawMessageController.swift */, 0AD8D634240EFF8800D87A95 /* RedeemInviteViewController.swift */, @@ -2486,6 +2490,7 @@ 5396A63622483B7900C57A4B /* UI */ = { isa = PBXGroup; children = ( + 5B5BF56529BA573B003C09A4 /* Compose */, 5B18DB772968B536001F3B70 /* Search */, 5B533E4B295B5B9B00F5EED1 /* Discover */, 5B2CF3982953A01F00630CB6 /* Message */, @@ -2800,6 +2805,17 @@ path = Discover; sourceTree = ""; }; + 5B5BF56529BA573B003C09A4 /* Compose */ = { + isa = PBXGroup; + children = ( + 5B5BF56729BA5746003C09A4 /* ComposeView.swift */, + 5B5BF56929BB7619003C09A4 /* PreviewView.swift */, + 5B5BF56B29BFE065003C09A4 /* ImagePickerButton.swift */, + 5B5BF56D29C01E5A003C09A4 /* AttachedImageButton.swift */, + ); + path = Compose; + sourceTree = ""; + }; 5B7AB67D28F46E90007DCCF1 /* Identity */ = { isa = PBXGroup; children = ( @@ -4152,7 +4168,6 @@ 0ABCA9082437C16200D7F39C /* NSAttributedString+Markdown.swift in Sources */, C9724B702809E77A000EBCCD /* AvatarStackView.swift in Sources */, C9724BA02809EAB8000EBCCD /* UnfollowOperation.swift in Sources */, - C9724B5B2809D614000EBCCD /* NewPostViewController.swift in Sources */, C9724B422809D4E9000EBCCD /* Layout+FillSuperview.swift in Sources */, 53EE01FB2205035800DFDF16 /* XCTestCase+JSON.swift in Sources */, C9724BEF2809EF45000EBCCD /* UIViewController+TopViewController.swift in Sources */, @@ -4408,6 +4423,7 @@ 5BEE741E2880515800897ACC /* Post+ViewDatabase.swift in Sources */, C95E7C73297898FD00E921F4 /* BotMigrationController.swift in Sources */, 535B6A10237362AE008C248E /* BlockedUsersViewController.swift in Sources */, + 5B5BF56829BA5746003C09A4 /* ComposeView.swift in Sources */, 8D74DE012335601F003C284B /* UIViewController+NavigationItems.swift in Sources */, 0A64A82824734F00009A5EBF /* VersePubAPI.swift in Sources */, 539FD4AF22C19B9F005A4DF2 /* AboutsMenu.swift in Sources */, @@ -4420,7 +4436,6 @@ 5BAE9C86281E0EA9008AEA84 /* Support+GoBot.swift in Sources */, 5396A633224836E800C57A4B /* UIColor+Hex.swift in Sources */, 1B5DB7CC24AC01DA008DCB81 /* StatisticsOperation.swift in Sources */, - 53E341FC224FF87D002BB5F4 /* NewPostViewController.swift in Sources */, C9A1632628AE88DC00ACDCC5 /* AddAliasView.swift in Sources */, 5319257E22F286FC00B44FA3 /* URL+Verse.swift in Sources */, 0ACE91A2243D740C00EFB4E9 /* GoBotError.swift in Sources */, @@ -4597,6 +4612,7 @@ 53375F812321757C00610932 /* GalleryView.swift in Sources */, C9421EB228889F2400A4C86D /* FancySectionTitle.swift in Sources */, 531EC4102310C274001A25AD /* Blob+NSAttributedString.swift in Sources */, + 5B5BF56A29BB7619003C09A4 /* PreviewView.swift in Sources */, 0A143A7B242E6714008745C6 /* AppDelegate+UniversalLink.swift in Sources */, 5B7AB67A28F0926B007DCCF1 /* String+LoremIpsum.swift in Sources */, 0AE5EDBB25826FD7008BDA0C /* String+Emoji.swift in Sources */, @@ -4641,6 +4657,7 @@ 53AD3FE522735C7B005228F9 /* MessageValue.swift in Sources */, 5376FAE2228D005600411F5B /* UIBarButtonItem+Saveable.swift in Sources */, 53631EF323A99CF0009C6999 /* NSAttributedString+Flatten.swift in Sources */, + 5B5BF56E29C01E5A003C09A4 /* AttachedImageButton.swift in Sources */, 0A64A8552473585B009A5EBF /* AuthyPhoneVerificationAPI.swift in Sources */, 2394335222708B8400D56B94 /* Date+millisecs.swift in Sources */, 5BA6E3C02937907E000393AC /* Notification+Post.swift in Sources */, @@ -4651,6 +4668,7 @@ 5B9C69562888813000229469 /* CountUnreadNotificationsOperation.swift in Sources */, 53AD3FD7226FC4B8005228F9 /* UITableView+Verse.swift in Sources */, 53AD3FDD22712CE0005228F9 /* MessageTableViewDelegate.swift in Sources */, + 5B5BF56C29BFE065003C09A4 /* ImagePickerButton.swift in Sources */, 5B2CF3882950FD1D00630CB6 /* FeedStrategyMessageDataSource.swift in Sources */, 536255DE23AC4C46001007D0 /* UIViewController+Sync.swift in Sources */, 23C7443B2200B12000FB554A /* Content.swift in Sources */, diff --git a/Shared/Extensions/LinearGradient+Planetary.swift b/Shared/Extensions/LinearGradient+Planetary.swift index c480c61819..d59ac84736 100644 --- a/Shared/Extensions/LinearGradient+Planetary.swift +++ b/Shared/Extensions/LinearGradient+Planetary.swift @@ -27,4 +27,10 @@ extension LinearGradient { startPoint: .topLeading, endPoint: .bottomTrailing ) + + public static let cardGradient = LinearGradient( + colors: [Color.cardBgTop, Color.cardBgBottom], + startPoint: .top, + endPoint: .bottom + ) } diff --git a/Source/Bot/Bot+Publish.swift b/Source/Bot/Bot+Publish.swift index 5d33b37e47..41c8ae872c 100644 --- a/Source/Bot/Bot+Publish.swift +++ b/Source/Bot/Bot+Publish.swift @@ -18,23 +18,23 @@ extension Bot { Thread.assertIsMainThread() // publish all images first - self.prepare(images) { - blobs, error in + self.prepare(images) { blobs, error in if Log.optional(error) { completion(Identifier.null, error); return } // mutate post to include blobs let postWithBlobs = post.copy(with: blobs) // publish post - self.publish(content: postWithBlobs) { - postIdentifier, error in + self.publish(content: postWithBlobs) { postIdentifier, error in if Log.optional(error) { completion(.null, error); return } completion(postIdentifier, nil) } } } - - @MainActor func publish(_ post: Post, with images: [UIImage] = []) async throws -> MessageIdentifier { + + @discardableResult + @MainActor + func publish(_ post: Post, with images: [UIImage] = []) async throws -> MessageIdentifier { try await withCheckedThrowingContinuation { continuation in publish(post, with: images) { result, error in if let error = error { @@ -50,23 +50,19 @@ extension Bot { // will quit after first failure and return an error without models // if some of the images were published and later ones fail well // then doing this again will duplicate already published images - func prepare(_ images: [UIImage], - completion: @escaping PublishBlobsCompletion) { + func prepare(_ images: [UIImage], completion: @escaping PublishBlobsCompletion) { Thread.assertIsMainThread() if images.isEmpty { completion([], nil); return } var blobs = [Int: Blob]() - // TODO need to add Bot.publish(blobs) - // TODO check all blobs before publish let datas = images.compactMap { $0.blobData() } - // TODO need Bot error + guard datas.count == images.count else { completion([], nil); return } for (index, data) in datas.enumerated() { let image = images[index] - self.addBlob(data: data) { - identifier, error in + self.addBlob(data: data) { identifier, error in if let error = error { completion([], error); return } let metadata = Blob.Metadata.describing(image, mimeType: .jpeg, data: data) let blob = Blob(identifier: identifier, metadata: metadata) diff --git a/Source/Controller/NewPostViewController.swift b/Source/Controller/NewPostViewController.swift deleted file mode 100644 index 72582cf9bb..0000000000 --- a/Source/Controller/NewPostViewController.swift +++ /dev/null @@ -1,262 +0,0 @@ -// -// NewPostViewController.swift -// FBTT -// -// Created by Christoph on 3/30/19. -// Copyright © 2019 Verse Communications Inc. All rights reserved. -// - -import Foundation -import UIKit -import Logger -import Analytics -import CrashReporting -import Combine - -class NewPostViewController: ContentViewController { - - var didPublish: ((Post) -> Void)? - - private lazy var textView = PostTextEditorView() - - // this view manages it's own height constraints - // checkout ImageGallery.open() and close() - private lazy var galleryView: ImageGalleryView = { - let view = ImageGalleryView(height: 75) - view.delegate = self - view.backgroundColor = .cardBackground - return view - }() - - var images: [UIImage] { - galleryView.images - } - - private lazy var buttonsView: PostButtonsView = { - let view = PostButtonsView() - Layout.addSeparator(toTopOf: view) - view.backgroundColor = .cardBackground - return view - }() - - private let imagePicker = UIImagePicker() - - private let draftStore: DraftStore - private let draftKey: String - private let queue = DispatchQueue.global(qos: .userInitiated) - private var cancellables = [AnyCancellable]() - - // MARK: Lifecycle - - init(images: [UIImage] = []) { - self.draftKey = "com.planetary.ios.draft." + (Bots.current.identity ?? "") - self.draftStore = DraftStore(draftKey: draftKey) - super.init(scrollable: false, title: .newPost) - isKeyboardHandlingEnabled = true - view.backgroundColor = .cardBackground - addActions() - setupDrafts() - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - let item = UIBarButtonItem( - image: UIImage.verse.dismiss, - style: .plain, - target: self, - action: #selector(dismissWithoutPost) - ) - item.tintColor = .secondaryAction - item.accessibilityLabel = Localized.done.text - self.navigationItem.leftBarButtonItem = item - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - } - - override func willMove(toParent parent: UIViewController?) { - super.willMove(toParent: parent) - self.parent?.presentationController?.delegate = self - } - - override func constrainSubviews() { - super.constrainSubviews() - - Layout.fillTop(of: self.contentView, with: self.textView) - - Layout.fillSouth(of: self.textView, with: self.galleryView) - - Layout.fillBottom(of: self.contentView, with: self.buttonsView, respectSafeArea: false) - self.buttonsView.pinTop(toBottomOf: self.galleryView) - self.buttonsView.constrainHeight(to: PostButtonsView.viewHeight) - } - - // MARK: - Drafts - - /// Configures the view to save and load draft posts from NSUserDefaults. - private func setupDrafts() { - Task { - if let draft = await draftStore.loadDraft() { - if let text = draft.attributedText { - textView.attributedText = text - } - galleryView.add(draft.images) - Log.info("Restored draft") - } - - textView - .textPublisher - .throttle(for: 3, scheduler: queue, latest: true) - .sink { [weak self] newText in - let newTextValue = newText.map { AttributedString($0) } - Task(priority: .userInitiated) { - await self?.draftStore.save(text: newTextValue, images: self?.images ?? []) - } - } - .store(in: &cancellables) - } - } - - // MARK: Actions - - private func addActions() { - self.buttonsView.photoButton.addTarget(self, action: #selector(photoButtonTouchUpInside), for: .touchUpInside) - self.buttonsView.previewToggle.addTarget(self, action: #selector(previewToggled), for: .valueChanged) - self.buttonsView.postButton.action = didPressPostButton - } - - @objc - private func photoButtonTouchUpInside(sender: AnyObject) { - - Analytics.shared.trackDidTapButton(buttonName: "attach_photo") - self.imagePicker.present(from: sender, controller: self) { [weak self] image in - if let image = image { self?.galleryView.add(image) } - self?.imagePicker.dismiss() - } - } - - @objc - private func previewToggled() { - Analytics.shared.trackDidTapButton(buttonName: "preview") - self.textView.previewActive = self.buttonsView.previewToggle.isOn - } - - func didPressPostButton(sender: AnyObject) { - self.lookBusy(message: Localized.NewPost.publishing) - Analytics.shared.trackDidTapButton(buttonName: "post") - self.buttonsView.postButton.isHidden = true - - let hasText = self.textView.attributedText.length > 0 - let hasImages = !self.galleryView.images.isEmpty - - guard hasText || hasImages else { - self.lookReady() - return - } - - let text = self.textView.attributedText - let post = Post(attributedText: text) - let images = self.galleryView.images - - let draftStore = draftStore - let textValue = AttributedString(text) - Task.detached(priority: .userInitiated) { - await draftStore.save(text: textValue, images: self.galleryView.images) - do { - _ = try await Bots.current.publish(post, with: images) - Analytics.shared.trackDidPost(characterCount: post.text.count) - await self.dismiss(didPublish: post) - try StoreReviewController.promptIfConditionsMet() - } catch { - Log.optional(error) - CrashReporting.shared.reportIfNeeded(error: error) - await self.alert(error: error) - } - await self.lookReady() - } - } - - private func dismiss(didPublish post: Post) async { - // Clear UI buffers so they don't get saved as a draft on dismiss - textView.clear() - galleryView.removeAll() - await self.draftStore.clearDraft() - self.didPublish?(post) - self.dismiss(animated: true) - } - - @objc - private func dismissWithoutPost() { - let textValue = AttributedString(self.textView.attributedText) - Task.detached(priority: .userInitiated) { - await self.draftStore.save(text: textValue, images: self.images) - } - self.dismiss(animated: true) - } - - // MARK: Animations - - private func lookBusy(message: Localizable? = nil) { - AppController.shared.showProgress(after: 0.2, statusText: message?.text) - self.buttonsView.photoButton.isEnabled = false - self.buttonsView.postButton.isEnabled = false - self.buttonsView.previewToggle.isEnabled = false - self.buttonsView.postButton.isHidden = true - } - - private func lookReady() { - AppController.shared.hideProgress() - self.buttonsView.photoButton.isEnabled = true - self.buttonsView.postButton.isEnabled = true - self.buttonsView.postButton.isHidden = false - self.buttonsView.previewToggle.isEnabled = true - } -} - -extension NewPostViewController: ImageGalleryViewDelegate { - - // Limits the max number of images to 8 - func imageGalleryViewDidChange(_ view: ImageGalleryView) { - self.buttonsView.photoButton.isEnabled = view.images.count < 8 - view.images.isEmpty ? view.close() : view.open() - let textValue = AttributedString(textView.attributedText) - Task.detached(priority: .userInitiated) { - await self.draftStore.save(text: textValue, images: view.images) - } - } - - func imageGalleryView( - _ view: ImageGalleryView, - didSelect image: UIImage, - at indexPath: IndexPath - ) { - self.confirm( - message: Localized.NewPost.confirmRemove.text, - isDestructive: true, - confirmTitle: Localized.NewPost.remove.text, - confirmClosure: { view.remove(at: indexPath) } - ) - } -} - -extension NewPostViewController: UIAdaptivePresentationControllerDelegate { - - func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { - let hasText = self.textView.attributedText.length > 0 - let hasImages = !self.galleryView.images.isEmpty - - if hasText || hasImages { - let textValue = AttributedString(textView.attributedText) - Task.detached(priority: .userInitiated) { - await self.draftStore.save(text: textValue, images: self.images) - } - } - } -} diff --git a/Source/Controller/NotificationsViewController.swift b/Source/Controller/NotificationsViewController.swift index 1b990e65de..7441a89b5d 100644 --- a/Source/Controller/NotificationsViewController.swift +++ b/Source/Controller/NotificationsViewController.swift @@ -20,17 +20,6 @@ class NotificationsViewController: ContentViewController, HelpDrawerViewControll /// The last time we loaded the reports from the database or we checked if there are new reports to show private var lastTimeNewReportsUpdatesWasChecked = Date() - - private lazy var newPostBarButtonItem: UIBarButtonItem = { - let image = UIImage.navIconWrite - let item = UIBarButtonItem( - image: image, - style: .plain, - target: self, - action: #selector(newPostButtonTouchUpInside) - ) - return item - }() lazy var helpButton: UIBarButtonItem = { HelpDrawerCoordinator.helpBarButton(for: self) }() var helpDrawerType: HelpDrawer { .notifications } @@ -68,7 +57,7 @@ class NotificationsViewController: ContentViewController, HelpDrawerViewControll init() { super.init(scrollable: false, title: .notifications) - navigationItem.rightBarButtonItems = [newPostBarButtonItem, helpButton] + navigationItem.rightBarButtonItems = [helpButton] } required init?(coder aDecoder: NSCoder) { @@ -194,17 +183,6 @@ class NotificationsViewController: ContentViewController, HelpDrawerViewControll func didCreateReport(notification: Notification) { checkForNotificationUpdates(force: true) } - - @objc - func newPostButtonTouchUpInside() { - Analytics.shared.trackDidTapButton(buttonName: "compose") - let controller = NewPostViewController() - controller.didPublish = { [weak self] _ in - self?.load() - } - let navController = UINavigationController(rootViewController: controller) - self.present(navController, animated: true, completion: nil) - } } private class NotificationsTableViewDataSource: MessageTableViewDataSource { diff --git a/Source/Controller/StoreReviewController.swift b/Source/Controller/StoreReviewController.swift index 25e7c65071..2a7af06971 100644 --- a/Source/Controller/StoreReviewController.swift +++ b/Source/Controller/StoreReviewController.swift @@ -65,7 +65,7 @@ enum StoreReviewController { guard let identity = Bots.current.identity else { throw PromptError.cannotReviewWhileLoggedOut } - + guard !isYearlyPromptCountExceeded else { return } diff --git a/Source/Controller/ThreadViewController.swift b/Source/Controller/ThreadViewController.swift index 1edcfc086a..d98a9c014c 100644 --- a/Source/Controller/ThreadViewController.swift +++ b/Source/Controller/ThreadViewController.swift @@ -191,10 +191,6 @@ class ThreadViewController: ContentViewController { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) headerView.removeFromSuperview() - let textValue = AttributedString(replyTextView.attributedText) - Task.detached(priority: .userInitiated) { - await self.draftStore.save(text: textValue, images: self.images) - } } private func load(animated: Bool = true, completion: (() -> Void)? = nil) { @@ -212,29 +208,6 @@ class ThreadViewController: ContentViewController { } } } - - private func setUpDrafts() { - Task { - if let draft = await self.draftStore.loadDraft() { - if let text = draft.attributedText { - replyTextView.attributedText = text - } - galleryView.add(draft.images) - Log.info("Restored draft") - } - - replyTextView - .textPublisher - .throttle(for: 3, scheduler: queue, latest: true) - .sink { [weak self] newText in - let newTextValue = newText.map { AttributedString($0) } - Task(priority: .userInitiated) { - await self?.draftStore.save(text: newTextValue, images: self?.images ?? []) - } - } - .store(in: &cancellables) - } - } private func refresh() { self.load() @@ -270,7 +243,6 @@ class ThreadViewController: ContentViewController { self.interactionView.replies = replies as? StaticDataProxy self.interactionView.update() replyTextView.isHidden = root.offChain == true - setUpDrafts() } override func viewDidLayoutSubviews() { @@ -381,7 +353,6 @@ class ThreadViewController: ContentViewController { let draftStore = draftStore let textValue = AttributedString(text) Task.detached(priority: .userInitiated) { - await draftStore.save(text: textValue, images: images) do { let messageID = try await Bots.current.publish(post, with: images) Analytics.shared.trackDidReply(characterCount: post.text.count) @@ -512,10 +483,6 @@ extension ThreadViewController: ImageGalleryViewDelegate { func imageGalleryViewDidChange(_ view: ImageGalleryView) { self.buttonsView.photoButton.isEnabled = view.images.count < 8 view.images.isEmpty ? view.close() : view.open() - let textValue = AttributedString(replyTextView.attributedText) - Task.detached(priority: .userInitiated) { - await self.draftStore.save(text: textValue, images: view.images) - } } func imageGalleryView(_ view: ImageGalleryView, didSelect image: UIImage, at indexPath: IndexPath) { diff --git a/Source/Localization/Localized.swift b/Source/Localization/Localized.swift index a8347c2c5b..9303bda728 100644 --- a/Source/Localization/Localized.swift +++ b/Source/Localization/Localized.swift @@ -271,6 +271,7 @@ extension Localized { case remove = "Remove" case publishing = "Publishing..." case restoring = "Restoring..." + case mention = "Mention" } } diff --git a/Source/Localization/en.lproj/Generated.strings b/Source/Localization/en.lproj/Generated.strings index 377769197f..854eeae1d7 100644 --- a/Source/Localization/en.lproj/Generated.strings +++ b/Source/Localization/en.lproj/Generated.strings @@ -173,6 +173,7 @@ "NewPost.remove" = "Remove"; "NewPost.publishing" = "Publishing..."; "NewPost.restoring" = "Restoring..."; +"NewPost.mention" = "Mention"; "Offboarding.reset" = "Delete"; "Offboarding.resetIdentity" = "Delete My Identity"; diff --git a/Source/Model/Draft.swift b/Source/Model/Draft.swift index 2f48c93c2b..f1db8e864b 100644 --- a/Source/Model/Draft.swift +++ b/Source/Model/Draft.swift @@ -16,24 +16,24 @@ extension AttributedString: @unchecked Sendable {} #endif /// A class representing a drafted post. Supports NSCoding so it can be saved to disk. -class Draft: NSObject, NSCoding { +final class Draft: NSObject, NSCoding, Sendable { - var attributedText: NSAttributedString? - var images: [UIImage] = [] + let text: String + let images: [UIImage] - init(attributedText: NSAttributedString?, images: [UIImage]) { - self.attributedText = attributedText + init(text: String, images: [UIImage]) { + self.text = text self.images = images } // swiftlint:disable legacy_objc_type required init?(coder: NSCoder) { - self.attributedText = coder.decodeObject(of: NSAttributedString.self, forKey: "attributedText") + self.text = coder.decodeObject(forKey: "text") as? String ?? "" self.images = (coder.decodeObject(of: NSArray.self, forKey: "images") as? [UIImage]) ?? [] } func encode(with coder: NSCoder) { - coder.encode(attributedText, forKey: "attributedText") + coder.encode(text, forKey: "text") coder.encode(images, forKey: "images") } // swiftlint:enable legacy_objc_type @@ -43,7 +43,7 @@ class Draft: NSObject, NSCoding { return false } - return otherDraft.attributedText == attributedText && otherDraft.images == images + return otherDraft.text == text && otherDraft.images == images } } @@ -67,10 +67,10 @@ actor DraftStore { return nil } - - func save(text string: AttributedString?, images: [UIImage]) { + + func save(text string: String, images: [UIImage]) { let draft = Draft( - attributedText: string.map { NSAttributedString($0) }, + text: string, images: images ) diff --git a/Source/Model/Post.swift b/Source/Model/Post.swift index eb50c4df44..6da1883446 100644 --- a/Source/Model/Post.swift +++ b/Source/Model/Post.swift @@ -7,7 +7,7 @@ import Foundation -class Post: ContentCodable { +final class Post: ContentCodable, Sendable { enum CodingKeys: String, CodingKey { case branch @@ -37,18 +37,19 @@ class Post: ContentCodable { /// Intended to be used when publishing a new Post from a UI. /// Check out NewPostViewController for an example. - init(attributedText: NSAttributedString, - root: MessageIdentifier? = nil, - branches: [MessageIdentifier]? = nil) { + init( + attributedText: NSAttributedString, + root: MessageIdentifier? = nil, + branches: [MessageIdentifier]? = nil + ) { // required self.branch = branches self.root = root self.text = attributedText.markdown self.type = .post - var mentionsFromHashtags = attributedText.string.hashtags().map { - tag in - Mention(link: tag.string) + var mentionsFromHashtags = attributedText.string.hashtags().map { hashtag in + Mention(link: hashtag.string) } mentionsFromHashtags.append(contentsOf: attributedText.mentions()) @@ -59,35 +60,65 @@ class Post: ContentCodable { self.reply = nil } + /// Intended to be used when publishing a new Post from a UI. + /// Check out PreviewView for an example. + init(text: String, root: MessageIdentifier? = nil, branches: [MessageIdentifier]? = nil) { + self.branch = branches + self.root = root + self.text = text + self.type = .post + + let attributed = text.parseMarkdown() + var mentions = [Mention]() + for attributedRun in attributed.runs { + if let link = attributedRun.attributes.link ?? attributedRun.attributes.imageURL { + let name = String(attributed[attributedRun.range].characters) + if link.scheme == URL.planetaryScheme { + let path = String(link.path.dropFirst()) + if path.isValidIdentifier || path.isHashtag { + mentions.append(Mention(link: path, name: name)) + } + } + } + } + self.mentions = mentions + + // unused + self.recps = nil + self.reply = nil + } + /// Intended to be used to create models in the view database or unit tests. - init(blobs: Blobs? = nil, - branches: [MessageIdentifier]? = nil, - hashtags: Hashtags? = nil, - mentions: [Mention]? = nil, - root: MessageIdentifier? = nil, - text: String) { + init( + blobs: Blobs? = nil, + branches: [MessageIdentifier]? = nil, + hashtags: Hashtags? = nil, + mentions: [Mention]? = nil, + root: MessageIdentifier? = nil, + text: String + ) { // required self.branch = branches self.root = root self.text = text self.type = .post - var m: Mentions = [] + var mention: Mentions = [] if let mentions = mentions { - m = mentions + mention = mentions } if let blobs = blobs { - for b in blobs { - m.append(b.asMention()) + for blob in blobs { + mention.append(blob.asMention()) } } if let tags = hashtags { - for h in tags { - m.append(Mention(link: h.string)) + for hashtag in tags { + mention.append(Mention(link: hashtag.string)) } } // keep it optional - self.mentions = m.count > 0 ? m : nil + self.mentions = mention.count > 0 ? mention : nil // unused self.recps = nil @@ -137,38 +168,36 @@ extension Post { } } -/* code to handle both kinds of recpients: - patchcore publishes this object instead of just the key as a string - { link: @pubkey, name: somenick} - - - handling from https://stackoverflow.com/a/49023027 -*/ - enum RecipientElement: Codable { case namedKey(RecipientNamedKey) case string(Identity) init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() - if let x = try? container.decode(Identity.self) { - self = .string(x) + if let identity = try? container.decode(Identity.self) { + self = .string(identity) return } - if let x = try? container.decode(RecipientNamedKey.self) { - self = .namedKey(x) + if let namedKey = try? container.decode(RecipientNamedKey.self) { + self = .namedKey(namedKey) return } - throw DecodingError.typeMismatch(RecipientElement.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for RecipientElement")) + throw DecodingError.typeMismatch( + RecipientElement.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Wrong type for RecipientElement" + ) + ) } func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { - case .namedKey(let x): - try container.encode(x.link) - case .string(let x): - try container.encode(x) + case .namedKey(let namedKey): + try container.encode(namedKey.link) + case .string(let identity): + try container.encode(identity) } } } @@ -183,27 +212,14 @@ struct RecipientNamedKey: Codable { } } -/* TODO: there is a cleaner solution here - tried this to get [Identity] but got the following error so I added getRecipientIdentities as a workaround - Constrained extension must be declared on the unspecialized generic type 'Array' with constraints specified by a 'where' clause - - - typealias Recipients = [RecipientElement] - - extension Recipients { - func recipients() -> [Identity] { - return getRecipientIdentities(self) - } - } -*/ func getRecipientIdentities(recps: [RecipientElement]) -> [Identity] { var identities: [Identity] = [] - for r in recps { - switch r { - case .string(let str): - identities.append(str) - case .namedKey(let nk): - identities.append(nk.link) + for recipient in recps { + switch recipient { + case .string(let identity): + identities.append(identity) + case .namedKey(let namedKey): + identities.append(namedKey.link) } } return identities diff --git a/Source/UI/Buttons/PillButtonStyle.swift b/Source/UI/Buttons/PillButtonStyle.swift index 81a3d6274a..c24ff50680 100644 --- a/Source/UI/Buttons/PillButtonStyle.swift +++ b/Source/UI/Buttons/PillButtonStyle.swift @@ -9,11 +9,13 @@ import SwiftUI struct PillButtonStyle: ButtonStyle { + var padding: CGFloat = 15 + @SwiftUI.Environment(\.isEnabled) private var isEnabled func makeBody(configuration: Configuration) -> some View { configuration.label - .padding() + .padding(padding) .background( !isEnabled ? Color.pillButtonBackgroundDisabled : configuration.isPressed ? Color.pillButtonBackgroundPressed : Color.pillButtonBackground diff --git a/Source/UI/Compose/AttachedImageButton.swift b/Source/UI/Compose/AttachedImageButton.swift new file mode 100644 index 0000000000..7c276f2f51 --- /dev/null +++ b/Source/UI/Compose/AttachedImageButton.swift @@ -0,0 +1,62 @@ +// +// AttachedImageButton.swift +// Planetary +// +// Created by Martin Dutra on 14/3/23. +// Copyright © 2023 Verse Communications Inc. All rights reserved. +// + +import SwiftUI + +struct AttachedImageButton: View { + + var image: UIImage + + var onCompletion: ((UIImage) -> Void) + + @State + private var showDeleteAttachmentConfirmation = false + + var body: some View { + Button { + showDeleteAttachmentConfirmation = true + } label: { + ZStack { + Image(uiImage: image) + .resizable() + .frame(width: 65, height: 65) + Image.navIconDismiss.padding(3).background(Circle().foregroundColor(.primaryTxt.opacity(0.4))) + } + } + .confirmationDialog( + Localized.NewPost.remove.text, + isPresented: $showDeleteAttachmentConfirmation, + actions: { + Button(Localized.ok.text, role: .destructive) { + showDeleteAttachmentConfirmation = false + onCompletion(image) + } + Button(Localized.cancel.text, role: .cancel) { + showDeleteAttachmentConfirmation = false + } + }, + message: { + Localized.NewPost.confirmRemove.view + } + ) + } +} + +struct AttachedImageButton_Previews: PreviewProvider { + static var previews: some View { + Group { + AttachedImageButton(image: UIImage(named: "avatar1") ?? .gobotIcon) { image in + print(image) + } + AttachedImageButton(image: UIImage(named: "avatar1") ?? .gobotIcon) { image in + print(image) + } + .preferredColorScheme(.dark) + } + } +} diff --git a/Source/UI/Compose/ComposeView.swift b/Source/UI/Compose/ComposeView.swift new file mode 100644 index 0000000000..3ff2284202 --- /dev/null +++ b/Source/UI/Compose/ComposeView.swift @@ -0,0 +1,314 @@ +// +// ComposeView.swift +// Planetary +// +// Created by Martin Dutra on 9/3/23. +// Copyright © 2023 Verse Communications Inc. All rights reserved. +// + +import Analytics +import Combine +import CrashReporting +import Logger +import PhotosUI +import SwiftUI + +struct ComposeView: View { + + /// Binding used to dimiss this view + @Binding + var isPresenting: Bool + + /// State holding the text the user is typing + @StateObject + private var textEditorObserver = TextEditorObserver() + + /// State containing the very last state before `text` changes + /// + /// We need this so that we can compare and decide what has changed. + @State + private var oldText: String = "" + + /// State containing the photos the user is attaching + @State + private var photos: [UIImage] = [] + + /// State containing the offset (index) of text when the user is mentioning someone + /// + /// When we detect the user typed a '@', we save the position of that character here and open a screen + /// that lets the user select someone to mention, then we can replace this character with the full mention. + @State + private var mentionOffset: Int? + + /// State used to present or hide a confirmation dialog that lets the user remove an attached photo. + @State + private var showDeleteAttachmentConfirmation = false + + private var showAvailableMentions: Binding { + Binding { + mentionOffset != nil + } set: { _ in + mentionOffset = nil + } + } + + /// List containing possible identities the user can mention. + @State + private var followings: [Identity]? + + /// If true, we already attempted to load a draft from disk + /// + /// We need this because the `task` modifier can be invoked multiple times if the user goes back from Preview. + @State + private var draftLoaded = false + + @EnvironmentObject + private var botRepository: BotRepository + + /// Used to maintain focus on the text editor when the keyboard is dismissed or the screen appears + @FocusState + private var textEditorIsFocused: Bool + + var body: some View { + NavigationView { + VStack { + TextEditor(text: $textEditorObserver.text) + .focused($textEditorIsFocused) + .padding() + .scrollContentBackground(.hidden) + .foregroundColor(.primaryTxt) + .onChange(of: textEditorObserver.text) { newValue in + let difference = newValue.difference(from: oldText) + guard difference.count == 1, let change = difference.first else { + oldText = newValue + return + } + switch change { + case .insert(let offset, let element, _): + if element == "@", followings != nil { + mentionOffset = offset + } + default: + break + } + oldText = newValue + } + .sheet(isPresented: showAvailableMentions) { + NavigationStack { + IdentityListView(identities: followings ?? []) { identity in + Task.detached(priority: .userInitiated) { + guard let offset = await mentionOffset else { + return + } + let textToModify = await textEditorObserver.text + let link: String + do { + let about = try await botRepository.current.about(identity: identity) + if let name = about?.name { + link = "[\(name)](\(identity))" + } else { + link = "[\(identity)](\(identity))" + } + } catch { + link = "[\(identity)](\(identity))" + } + var modifiedString = String() + for (i, char) in textToModify.enumerated() { + modifiedString += (i == offset) ? "\(link) " : String(char) + } + await MainActor.run { [modifiedString] in + textEditorObserver.text = modifiedString + oldText = modifiedString + mentionOffset = nil + } + } + } + .navigationTitle(Localized.NewPost.mention.text) + } + .presentationDetents([.medium, .large]) + } + VStack(spacing: 0) { + if !photos.isEmpty { + ScrollView(.horizontal) { + HStack(spacing: 5) { + ForEach(photos, id: \.self) { photo in + AttachedImageButton(image: photo) { image in + guard let index = photos.firstIndex(where: { $0 === image }) else { + return + } + photos.remove(at: index) + saveDraft() + } + } + } + .padding(5) + } + } + Divider().overlay(Color.cardDivider).shadow(color: .cardDividerShadow, radius: 0, x: 0, y: 1) + HStack { + ImagePickerButton { image in + photos.append(image) + saveDraft() + } label: { + Image.iconLibrary + } + .padding(10) + Spacer() + NavigationLink { + PreviewView( + text: textEditorObserver.text, + photos: photos, + isPresenting: $isPresenting + ) + .task { + saveDraft() + } + } label: { + Text(Localized.preview.text) + .font(.subheadline) + .foregroundColor(.white) + .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .background( + Rectangle() + .fill(LinearGradient.horizontalAccent) + .cornerRadius(17) + ) + } + .padding(10) + } + Color.cardBgBottom.ignoresSafeArea(.all, edges: .bottom).frame(height: 0) + } + .background { + LinearGradient.cardGradient + } + } + .background { + Color.appBg + } + .navigationTitle(Localized.newPost.text) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + saveDraft() + isPresenting = false + } label: { + Image.navIconDismiss + } + } + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification)) { _ in + textEditorIsFocused = true + } + .onAppear { + textEditorIsFocused = true + } + .onReceive(textEditorObserver.$throttledText) { _ in + saveDraft() + } + .onReceive(NotificationCenter.default.publisher(for: .didPublishPost)) { _ in + clearDraft() + } + .task { + if !draftLoaded { + loadDraft() + draftLoaded = true + } + if followings == nil { + loadFollowings() + } + } + } + .background(Color.navigationbarBg) + } + + private func loadDraft() { + Task.detached(priority: .userInitiated) { + let bot = await botRepository.current + let draftStore = await buildDraftStore(from: bot) + if let draft = await draftStore.loadDraft() { + await MainActor.run { + textEditorObserver.text = draft.text + photos = draft.images + } + Log.info("Restored draft") + } + } + } + + private func saveDraft() { + Task.detached(priority: .userInitiated) { + let bot = await botRepository.current + let draftStore = await buildDraftStore(from: bot) + let currentText = await textEditorObserver.text + let currentPhotos = await photos + await draftStore.save(text: currentText, images: currentPhotos) + Log.debug("Draft saved with \(currentText.count) characters and \(currentPhotos.count) photos.") + } + } + + private func clearDraft() { + Task.detached(priority: .userInitiated) { + let bot = await botRepository.current + let draftStore = await buildDraftStore(from: bot) + await draftStore.clearDraft() + Log.debug("Draft cleared") + } + } + + private func buildDraftStore(from bot: Bot) -> DraftStore { + let currentIdentity = bot.identity ?? "" + let draftKey = "com.planetary.ios.draft." + currentIdentity + return DraftStore(draftKey: draftKey) + } + + private func loadFollowings() { + Task.detached { + let bot = await botRepository.current + guard let currentIdentity = bot.identity else { + return + } + do { + let result: [Identity] = try await bot.followings(identity: currentIdentity) + await MainActor.run { + followings = result + } + } catch { + Log.optional(error) + CrashReporting.shared.reportIfNeeded(error: error) + } + } + } +} + +@MainActor +fileprivate class TextEditorObserver: ObservableObject { + @Published + var throttledText = "" + + @Published + var text = "" + + private var subscriptions = Set() + + init() { + $text + .removeDuplicates() + .throttle(for: .seconds(2), scheduler: RunLoop.main, latest: true) + .sink { [weak self] value in + self?.throttledText = value + } + .store(in: &subscriptions) + } +} + +struct ComposeView_Previews: PreviewProvider { + @State + static var isPresenting = false + + static var previews: some View { + ComposeView(isPresenting: $isPresenting) + .preferredColorScheme(.dark) + .injectAppEnvironment(botRepository: .fake) + } +} diff --git a/Source/UI/Compose/ImagePickerButton.swift b/Source/UI/Compose/ImagePickerButton.swift new file mode 100644 index 0000000000..1cc8e3ed45 --- /dev/null +++ b/Source/UI/Compose/ImagePickerButton.swift @@ -0,0 +1,133 @@ +// +// ImagePickerButton.swift +// Planetary +// +// Created by Martin Dutra on 13/3/23. +// Copyright © 2023 Verse Communications Inc. All rights reserved. +// + +import Analytics +import AVKit +import SwiftUI + +struct ImagePickerButton