diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf4e79e0f..a7ca77cb60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Redesigned screen that shows the detail of a message. +- Sub-threads: you can now reply to a reply. +- Add the option to reply to a reaction or a contact message (follow, unfollow, block). +- The Preview screen shows the message you are replying to (if so). + +## [2.0.1] 2023-03-28 + - Improved message replication performance. - Redesigned screen for composing posts with better support for previewing. - Filter list of followers/follows by name, bio or identity. -## [2.0.0] _waiting for review_ +## [2.0.0] 2023-03-08 - 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 diff --git a/Planetary.xcodeproj/project.pbxproj b/Planetary.xcodeproj/project.pbxproj index 6be27380d6..727ef3b08b 100644 --- a/Planetary.xcodeproj/project.pbxproj +++ b/Planetary.xcodeproj/project.pbxproj @@ -636,6 +636,12 @@ 5B3FBE3B292BB9E5004F34CC /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B3FBE39292BB9E5004F34CC /* ImagePicker.swift */; }; 5B3FBE3D292BE5E0004F34CC /* EditAvatarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B3FBE3C292BE5E0004F34CC /* EditAvatarButton.swift */; }; 5B3FBE3E292BE5E0004F34CC /* EditAvatarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B3FBE3C292BE5E0004F34CC /* EditAvatarButton.swift */; }; + 5B46611229C9CC60008B8E8C /* CompactMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B46611129C9CC60008B8E8C /* CompactMessageView.swift */; }; + 5B46611329C9CC60008B8E8C /* CompactMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B46611129C9CC60008B8E8C /* CompactMessageView.swift */; }; + 5B46611629C9D289008B8E8C /* GoldenMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B46611529C9D289008B8E8C /* GoldenMessageView.swift */; }; + 5B46611729C9D289008B8E8C /* GoldenMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B46611529C9D289008B8E8C /* GoldenMessageView.swift */; }; + 5B46612429CFB537008B8E8C /* LoadingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B46612329CFB537008B8E8C /* LoadingCard.swift */; }; + 5B46612529CFB537008B8E8C /* LoadingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B46612329CFB537008B8E8C /* LoadingCard.swift */; }; 5B4AEA70294D14240059E039 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B4AEA6D294D14240059E039 /* HomeView.swift */; }; 5B4AEA71294D14240059E039 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B4AEA6D294D14240059E039 /* HomeView.swift */; }; 5B4AEA72294D14240059E039 /* EmptyHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B4AEA6E294D14240059E039 /* EmptyHomeView.swift */; }; @@ -675,6 +681,8 @@ 5B5BF57729C2239C003C09A4 /* PreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5BF56929BB7619003C09A4 /* PreviewView.swift */; }; 5B5BF57829C2239F003C09A4 /* ImagePickerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5BF56B29BFE065003C09A4 /* ImagePickerButton.swift */; }; 5B5BF57929C2239F003C09A4 /* AttachedImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5BF56D29C01E5A003C09A4 /* AttachedImageButton.swift */; }; + 5B5BF57F29C4D1C5003C09A4 /* LikeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5BF57E29C4D1C5003C09A4 /* LikeButton.swift */; }; + 5B5BF58029C4D1C5003C09A4 /* LikeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5BF57E29C4D1C5003C09A4 /* LikeButton.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 */; }; @@ -765,6 +773,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 */; }; @@ -1660,6 +1674,9 @@ 5B3FBE3029295CEC004F34CC /* BioView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BioView.swift; sourceTree = ""; }; 5B3FBE39292BB9E5004F34CC /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; 5B3FBE3C292BE5E0004F34CC /* EditAvatarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAvatarButton.swift; sourceTree = ""; }; + 5B46611129C9CC60008B8E8C /* CompactMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactMessageView.swift; sourceTree = ""; }; + 5B46611529C9D289008B8E8C /* GoldenMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoldenMessageView.swift; sourceTree = ""; }; + 5B46612329CFB537008B8E8C /* LoadingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCard.swift; sourceTree = ""; }; 5B4AEA6D294D14240059E039 /* HomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; 5B4AEA6E294D14240059E039 /* EmptyHomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmptyHomeView.swift; sourceTree = ""; }; 5B4AEA75294D14320059E039 /* EmptyPostsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmptyPostsView.swift; sourceTree = ""; }; @@ -1678,6 +1695,7 @@ 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 = ""; }; + 5B5BF57E29C4D1C5003C09A4 /* LikeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LikeButton.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 = ""; }; @@ -1725,6 +1743,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 = ""; }; @@ -2776,6 +2797,11 @@ 5B533E52295B5F8200F5EED1 /* MessageGrid.swift */, 5BAB1840290C2634009F8F90 /* CompactPostView.swift */, 5B747F6F295E1AA8003D014A /* GoldenPostView.swift */, + 5BC7F4FC29997900007D5566 /* MessageView.swift */, + 5BC7F5072999BCC9007D5566 /* CompactVoteView.swift */, + 5B5BF57E29C4D1C5003C09A4 /* LikeButton.swift */, + 5B46611129C9CC60008B8E8C /* CompactMessageView.swift */, + 5B46611529C9D289008B8E8C /* GoldenMessageView.swift */, ); path = Message; sourceTree = ""; @@ -2873,6 +2899,7 @@ 5B2CF37D2950CE3100630CB6 /* HomeStrategy.swift */, 5B2CF3902952343B00630CB6 /* ProfileStrategy.swift */, 5B533E55295B85F400F5EED1 /* DiscoverStrategy.swift */, + 5BC7F5032999ADAC007D5566 /* RepliesStrategy.swift */, ); path = FeedStrategy; sourceTree = ""; @@ -3122,6 +3149,7 @@ E0A4AB20294A52C300A410CB /* RoomCard.swift */, 5B88E5FA29707A54005F865D /* CardStyle.swift */, 5B88E5FD29707D8F005F865D /* CardButtonStyle.swift */, + 5B46612329CFB537008B8E8C /* LoadingCard.swift */, ); path = Cards; sourceTree = ""; @@ -3853,6 +3881,7 @@ C9EBE0482880C2C900A2CF51 /* HelpDrawerCoordinator.swift in Sources */, C982CBE228353E2600D8963F /* ContactHeaderView.swift in Sources */, C9724B332809D47D000EBCCD /* NameOnboardingStep.swift in Sources */, + 5B46611729C9D289008B8E8C /* GoldenMessageView.swift in Sources */, 5BA6E3CD2939A96C000393AC /* FloatingButton.swift in Sources */, C9724B4E2809D594000EBCCD /* NotificationsViewController.swift in Sources */, C9724B8A2809E971000EBCCD /* AttributedStringCache.swift in Sources */, @@ -3870,6 +3899,7 @@ C9724BB92809EC7B000EBCCD /* BotViewController.swift in Sources */, C9421EB72888A43600A4C86D /* URL+Planetary.swift in Sources */, C9724B362809D47D000EBCCD /* PhotoConfirmOnboardingStep.swift in Sources */, + 5B5BF58029C4D1C5003C09A4 /* LikeButton.swift in Sources */, C9724BBB2809ECA8000EBCCD /* Saveable.swift in Sources */, 5BFC1C2528FF0859008A4B87 /* ExtendedSocialStats.swift in Sources */, C9724B372809D47D000EBCCD /* PhotoOnboardingStep.swift in Sources */, @@ -3877,6 +3907,7 @@ C9724B7C2809E8D4000EBCCD /* PillButton.swift in Sources */, C982CBDE28353DBB00D8963F /* JoinPlanetarySystemOperation.swift in Sources */, C9724B382809D47D000EBCCD /* SplashOnboardingStep.swift in Sources */, + 5BC7F5092999BCC9007D5566 /* CompactVoteView.swift in Sources */, 53E29CE122D80427008A2CB1 /* Hashtag+NSAttributedString.swift in Sources */, 0A64A83924735392009A5EBF /* PushAPIService.swift in Sources */, 53C2B13C2295BC940018D0A8 /* Keychain.swift in Sources */, @@ -3892,6 +3923,7 @@ 5B97D1E629635FFF000AA5D1 /* SearchResultsView.swift in Sources */, C9724B742809E7A6000EBCCD /* UIFont+Verse.swift in Sources */, C969F0E12899CFEE00081615 /* Room.swift in Sources */, + 5B46612529CFB537008B8E8C /* LoadingCard.swift in Sources */, C9724BE22809EE7D000EBCCD /* SyncOperation.swift in Sources */, C9724B102809D1D6000EBCCD /* AppController.swift in Sources */, 533EDBF52389BDD4008B3565 /* UIColor+Hex.swift in Sources */, @@ -3944,6 +3976,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 */, @@ -4015,6 +4048,7 @@ 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 */, @@ -4152,6 +4186,7 @@ 238ED6B2232282D600E054A3 /* Blob.swift in Sources */, C9724B8E2809E9B2000EBCCD /* UIViewController+ApplicationWillEnterForeground.swift in Sources */, C9724B762809E7B2000EBCCD /* BlockButton.swift in Sources */, + 5B46611329C9CC60008B8E8C /* CompactMessageView.swift in Sources */, 535754F522692B29002A6989 /* ContentType.swift in Sources */, C969F0E52899D1FD00081615 /* AppDelegate+URLScheme.swift in Sources */, 5BA6E3CA29397CA9000393AC /* LoadingView.swift in Sources */, @@ -4431,6 +4466,7 @@ 5BEE741E2880515800897ACC /* Post+ViewDatabase.swift in Sources */, C95E7C73297898FD00E921F4 /* BotMigrationController.swift in Sources */, 535B6A10237362AE008C248E /* BlockedUsersViewController.swift in Sources */, + 5BC7F5042999ADAC007D5566 /* RepliesStrategy.swift in Sources */, 5B5BF56829BA5746003C09A4 /* ComposeView.swift in Sources */, 8D74DE012335601F003C284B /* UIViewController+NavigationItems.swift in Sources */, 0A64A82824734F00009A5EBF /* VersePubAPI.swift in Sources */, @@ -4601,6 +4637,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 */, @@ -4642,6 +4679,7 @@ 5B7AB66F28E74692007DCCF1 /* ExtendedSocialStats.swift in Sources */, 5BE8A30D2919C9E800C4A38E /* IdentityListView.swift in Sources */, 5B7786D828F876190081B1C6 /* ImageMetadataView.swift in Sources */, + 5B5BF57F29C4D1C5003C09A4 /* LikeButton.swift in Sources */, 8D6D2E64236387C800E7B0EC /* FollowCountView.swift in Sources */, 5BAB1845290C2767009F8F90 /* CompactIdentityView.swift in Sources */, 5B533E53295B5F8200F5EED1 /* MessageGrid.swift in Sources */, @@ -4695,6 +4733,7 @@ 0A64A84B24735766009A5EBF /* PhoneVerificationResponse.swift in Sources */, 530F018F22DCEC08007EBAE2 /* OnboardingStepView.swift in Sources */, 5B4AEA7D294D14320059E039 /* CompactHashtagView.swift in Sources */, + 5B46611229C9CC60008B8E8C /* CompactMessageView.swift in Sources */, 236761592450681800A97140 /* ViewDatabase+Pagination.swift in Sources */, 5BA32ED82915930000744984 /* ActivityView.swift in Sources */, 53375F83232178DB00610932 /* UIPageControl+Verse.swift in Sources */, @@ -4795,6 +4834,7 @@ 5B18DB7A2968B542001F3B70 /* SearchResultsGrid.swift in Sources */, 0A4870FD2498014F00BCD063 /* UICollectionView+Verse.swift in Sources */, 8D317ECD2357A37C0009E073 /* UIImageView+Fade.swift in Sources */, + 5B46612429CFB537008B8E8C /* LoadingCard.swift in Sources */, C9313A7C289AD9D40093AC47 /* RoomInvitationRedeemer.swift in Sources */, 535754F22268DAB0002A6989 /* BotError.swift in Sources */, 0AB736BC2457753F000190F8 /* AsynchronousOperation.swift in Sources */, @@ -4841,6 +4881,7 @@ 8DE093DC234651E1009E505D /* FollowButton.swift in Sources */, 539246E022A9CCBF00D01EEC /* Person.swift in Sources */, 53C422B721C476B000A314AD /* DebugTableViewController.swift in Sources */, + 5B46611629C9D289008B8E8C /* GoldenMessageView.swift in Sources */, 0A64A82324734EC2009A5EBF /* PubAPIService.swift in Sources */, 53211CE42283717C007FB785 /* Image+UIImage.swift in Sources */, 5B5EF4FB294FE1230052237A /* MessageList.swift in Sources */, @@ -4865,6 +4906,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 */, @@ -5160,7 +5202,6 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_COMPILATION_MODE = singlefile; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; }; name = Debug; @@ -5219,7 +5260,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; }; @@ -5232,7 +5272,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Resources/FBTT.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 417; + CURRENT_PROJECT_VERSION = 418; DEVELOPMENT_TEAM = GZCZBKH7MY; EAGER_LINKING = YES; ENABLE_BITCODE = NO; @@ -5247,14 +5287,13 @@ "@executable_path/", ); MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = 2.0.1; + MARKETING_VERSION = 2.1.0; OTHER_LDFLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS"; SDKROOT = iphoneos; STRIP_SWIFT_SYMBOLS = NO; SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; SUPPORTS_MACCATALYST = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; @@ -5267,7 +5306,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Resources/FBTT.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 417; + CURRENT_PROJECT_VERSION = 418; DEVELOPMENT_TEAM = GZCZBKH7MY; EAGER_LINKING = YES; ENABLE_BITCODE = NO; @@ -5282,7 +5321,7 @@ "@executable_path/", ); MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = 2.0.1; + MARKETING_VERSION = 2.1.0; OTHER_LDFLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS"; SDKROOT = iphoneos; 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"> CFBundleVersion - 417 + 418 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/Source/App/AppController+URL.swift b/Source/App/AppController+URL.swift index f324559dee..d54be605a8 100644 --- a/Source/App/AppController+URL.swift +++ b/Source/App/AppController+URL.swift @@ -123,14 +123,8 @@ extension AppController { } func pushThreadViewController(for identifier: MessageIdentifier) { - Bots.current.thread(rootKey: identifier) { (root, _, error) in - if let root = root { - let controller = ThreadViewController(with: root) - self.push(controller) - } else if let error = error { - self.alert(error: error) - } - } + let controller = MessageViewBuilder.build(identifier: identifier) + self.push(controller, animated: true) } func pushChannelViewController(for hashtag: String) { diff --git a/Source/Bot/Bot.swift b/Source/Bot/Bot.swift index 79a401fc9b..08f05eeb4a 100644 --- a/Source/Bot/Bot.swift +++ b/Source/Bot/Bot.swift @@ -248,7 +248,12 @@ protocol Bot: AnyObject, Sendable { /// This is useful for showing all the posts from a particular /// person, like in an About screen. func feed(strategy: FeedStrategy, limit: Int, offset: Int?, completion: @escaping MessagesCompletion) - + + /// Fetches the message with the given Identigfier from the database. + func message(identifier: MessageIdentifier) async throws -> Message? + + func likes(identifier: MessageIdentifier, by author: FeedIdentifier) async throws -> Bool + /// Fetches the post with the given ID from the database. func post(from key: MessageIdentifier) throws -> Message diff --git a/Source/Controller/DirectoryViewController.swift b/Source/Controller/DirectoryViewController.swift index 70ec59805d..7e5a61b0f4 100644 --- a/Source/Controller/DirectoryViewController.swift +++ b/Source/Controller/DirectoryViewController.swift @@ -358,7 +358,8 @@ extension DirectoryViewController: UITableViewDelegate { } navigationController?.pushViewController( - ThreadViewController(with: post, startReplying: false), animated: true + MessageViewBuilder.build(identifier: post.id), + animated: true ) case .network: let identity = self.people[indexPath.row].identity diff --git a/Source/Controller/NotificationsViewController.swift b/Source/Controller/NotificationsViewController.swift index 7441a89b5d..da4c6d365f 100644 --- a/Source/Controller/NotificationsViewController.swift +++ b/Source/Controller/NotificationsViewController.swift @@ -270,7 +270,7 @@ private class NotificationsTableViewDelegate: MessageTableViewDelegate { self.viewController?.navigationController?.pushViewController(controller, animated: true) } else if message.contentType == .post { Analytics.shared.trackDidSelectItem(kindName: "post") - let controller = ThreadViewController(with: message) + let controller = MessageViewBuilder.build(identifier: message.id) self.viewController?.navigationController?.pushViewController(controller, animated: true) } } diff --git a/Source/FakeBot/FakeBot.swift b/Source/FakeBot/FakeBot.swift index e177eb1e78..5630371da0 100644 --- a/Source/FakeBot/FakeBot.swift +++ b/Source/FakeBot/FakeBot.swift @@ -328,7 +328,15 @@ class FakeBot: Bot, @unchecked Sendable { func feed(identity: Identity, completion: PaginatedCompletion) { completion(StaticDataProxy(), nil) } - + + func message(identifier: MessageIdentifier) async throws -> Message? { + return nil + } + + func likes(identifier: MessageIdentifier, by author: FeedIdentifier) async throws -> Bool { + return false + } + func post(from key: MessageIdentifier) throws -> Message { throw FakeBotError.runtimeError("not implemented") } diff --git a/Source/Generated/Assets+Planetary.swift b/Source/Generated/Assets+Planetary.swift index 7ae16df8a1..886e4a738b 100644 --- a/Source/Generated/Assets+Planetary.swift +++ b/Source/Generated/Assets+Planetary.swift @@ -210,6 +210,7 @@ extension Image { static let iconTwit = Image("icon-twit", bundle: Bundle.current) static let imageOnboarding = Image("image-onboarding", bundle: Bundle.current) static let launch = Image("launch", bundle: Bundle.current) + static let messageNotVisible = Image("message-not-visible", bundle: Bundle.current) static let missingAboutIcon = Image("missing-about-icon", bundle: Bundle.current) static let navIconCamera = Image("nav-icon-camera", bundle: Bundle.current) static let navIconDismiss = Image("nav-icon-dismiss", bundle: Bundle.current) @@ -280,6 +281,7 @@ extension UIImage { static let iconTwit = UIImage(named: "icon-twit", in: Bundle.current, with: nil)! static let imageOnboarding = UIImage(named: "image-onboarding", in: Bundle.current, with: nil)! static let launch = UIImage(named: "launch", in: Bundle.current, with: nil)! + static let messageNotVisible = UIImage(named: "message-not-visible", in: Bundle.current, with: nil)! static let missingAboutIcon = UIImage(named: "missing-about-icon", in: Bundle.current, with: nil)! static let navIconCamera = UIImage(named: "nav-icon-camera", in: Bundle.current, with: nil)! static let navIconDismiss = UIImage(named: "nav-icon-dismiss", in: Bundle.current, with: nil)! 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 71bd0bf7a4..68e7517476 100644 --- a/Source/GoBot/GoBot.swift +++ b/Source/GoBot/GoBot.swift @@ -1477,7 +1477,33 @@ 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 likes(identifier: MessageIdentifier, by author: FeedIdentifier) async throws -> Bool { + try await withCheckedThrowingContinuation { continuation in + userInitiatedQueue.async { [identifier] in + do { + let liked = try self.database.likesMessage(with: identifier, author: author) + continuation.resume(returning: liked) + } 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 56c5220885..7f9220ecf3 100644 --- a/Source/GoBot/ViewDatabase.swift +++ b/Source/GoBot/ViewDatabase.swift @@ -703,9 +703,127 @@ 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() + + // 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 + } + } + + func likesMessage(with identifier: MessageIdentifier, author: FeedIdentifier) throws -> Bool { + let db = try checkoutConnection() + + // swiftlint:disable indentation_width + let query = """ + SELECT + COUNT(*) > 0 + FROM + tangles t + JOIN messagekeys rmk ON rmk.id = t.root + JOIN messages ON messages.msg_id = t.msg_ref + JOIN authors ON authors.id = messages.author_id + JOIN votes ON votes.msg_ref = t.msg_ref + WHERE + messages.type = 'vote' + AND rmk.key = :message_identifier + AND authors.author = :author_identifier + AND messages.hidden = FALSE + AND messages.is_decrypted = FALSE + AND votes.value > 0 + LIMIT + 1 + """ + // swiftlint:enable indentation_width + + let bindings: [String: Binding?] = [ + ":message_identifier": identifier, + ":author_identifier": author + ] + if let liked = try db.prepare(query).scalar(bindings) as? Int64 { + return liked > 0 + } else { + return false + } } // MARK: pubs & rooms diff --git a/Source/Localization/Localized.swift b/Source/Localization/Localized.swift index 9303bda728..ef2051cd57 100644 --- a/Source/Localization/Localized.swift +++ b/Source/Localization/Localized.swift @@ -81,6 +81,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" @@ -102,6 +103,7 @@ enum Localized: String, Localizable, CaseIterable { case postAction = "Post" case preview = "Preview" case newPost = "New Post" + case newReply = "New Reply" case deletePost = "Delete this post" case editPost = "Edit this post" case confirmDeletePost = "Are you sure you want to delete this post?" @@ -449,6 +451,11 @@ extension Localized { extension Localized { enum Message: String, Localizable, CaseIterable { + case message = "Message" + case reaction = "Reaction" + case contact = "Contact" + case post = "Post" + case reply = "Reply" case noPostsTitle = "No posts here yet" case noPostsDescription = "This means the user hasn't posted anything, or you don't have enough connections in common to synchronize their posts.\n\nLearn [how gossipping works]({{ link }}) on Planetary." case noPostsInHashtagDescription = "This means no messages have been posted under this hashtag, or you don't have enough connections to synchronize these posts.\n\nLearn [how gossipping works]({{ link }}) on Planetary." @@ -612,6 +619,7 @@ extension Localized { extension Localized { enum Post: String, Localizable, CaseIterable { + case title = "Post" case one = "post" case many = "posts" } diff --git a/Source/Localization/en.lproj/Generated.strings b/Source/Localization/en.lproj/Generated.strings index 854eeae1d7..65aa686d5d 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"; @@ -52,6 +53,7 @@ "Localized.postAction" = "Post"; "Localized.preview" = "Preview"; "Localized.newPost" = "New Post"; +"Localized.newReply" = "New Reply"; "Localized.deletePost" = "Delete this post"; "Localized.editPost" = "Edit this post"; "Localized.confirmDeletePost" = "Are you sure you want to delete this post?"; @@ -281,6 +283,11 @@ "ManageRelays.joinPlanetaryRoom" = "Join Planetary Room"; "ManageRelays.joinPlanetaryRoomDescription" = "Joining the official Planetary room server will allow to register aliases like yourname.planetary.name, and sync directly with others in the room."; +"Message.message" = "Message"; +"Message.reaction" = "Reaction"; +"Message.contact" = "Contact"; +"Message.post" = "Post"; +"Message.reply" = "Reply"; "Message.noPostsTitle" = "No posts here yet"; "Message.noPostsDescription" = "This means the user hasn't posted anything, or you don't have enough connections in common to synchronize their posts.\n\nLearn [how gossipping works]({{ link }}) on Planetary."; "Message.noPostsInHashtagDescription" = "This means no messages have been posted under this hashtag, or you don't have enough connections to synchronize these posts.\n\nLearn [how gossipping works]({{ link }}) on Planetary."; @@ -357,6 +364,7 @@ "Channel.one" = "channel"; "Channel.many" = "channels"; +"Post.title" = "Post"; "Post.one" = "post"; "Post.many" = "posts"; diff --git a/Source/Model/Message.swift b/Source/Model/Message.swift index 90c0438c2b..50a141043e 100644 --- a/Source/Model/Message.swift +++ b/Source/Model/Message.swift @@ -95,7 +95,7 @@ struct Message: Codable, Identifiable, @unchecked Sendable { extension Message: Equatable { static func == (lhs: Message, rhs: Message) -> Bool { - lhs.key == rhs.key + lhs.key == rhs.key && lhs.metadata.replies.count == rhs.metadata.replies.count } } diff --git a/Source/Model/Notification+Post.swift b/Source/Model/Notification+Post.swift index 7e2c2bfb26..3fc1b00337 100644 --- a/Source/Model/Notification+Post.swift +++ b/Source/Model/Notification+Post.swift @@ -10,6 +10,7 @@ import Foundation extension Notification.Name { static let didPublishPost = Notification.Name("didPublishPost") + static let didPublishVote = Notification.Name("didPublishVote") } extension Notification { @@ -18,6 +19,10 @@ extension Notification { self.userInfo?["post"] as? Post } + var identifier: MessageIdentifier? { + self.userInfo?["identifier"] as? MessageIdentifier + } + static func didPublishPost(_ post: Post) -> Notification { Notification( name: .didPublishPost, @@ -25,4 +30,12 @@ extension Notification { userInfo: ["post": post] ) } + + static func didPublishVote(to message: MessageIdentifier) -> Notification { + Notification( + name: .didPublishVote, + object: nil, + userInfo: ["identifier": message] + ) + } } 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/Cards/LoadingCard.swift b/Source/UI/Cards/LoadingCard.swift new file mode 100644 index 0000000000..526cd5f1d4 --- /dev/null +++ b/Source/UI/Cards/LoadingCard.swift @@ -0,0 +1,33 @@ +// +// LoadingCard.swift +// Planetary +// +// Created by Martin Dutra on 25/3/23. +// Copyright © 2023 Verse Communications Inc. All rights reserved. +// + +import SwiftUI + +struct LoadingCard: View { + + var style: CardStyle + + var body: some View { + VStack(spacing: 0) { + PeerConnectionAnimationView(peerCount: 3, color: UIColor.secondaryTxt) + .frame(maxWidth: .infinity) + .frame(height: 150) + } + .background( + LinearGradient.cardGradient + ) + .cornerRadius(20) + .padding(EdgeInsets(top: 15, leading: 15, bottom: 0, trailing: 15)) + } +} + +struct LoadingCard_Previews: PreviewProvider { + static var previews: some View { + LoadingCard(style: .compact) + } +} diff --git a/Source/UI/Compose/ComposeView.swift b/Source/UI/Compose/ComposeView.swift index 3ff2284202..88bc77a24f 100644 --- a/Source/UI/Compose/ComposeView.swift +++ b/Source/UI/Compose/ComposeView.swift @@ -19,6 +19,8 @@ struct ComposeView: View { @Binding var isPresenting: Bool + var root: Message? + /// State holding the text the user is typing @StateObject private var textEditorObserver = TextEditorObserver() @@ -69,6 +71,10 @@ struct ComposeView: View { @FocusState private var textEditorIsFocused: Bool + private var isReply: Bool { + root != nil + } + var body: some View { NavigationView { VStack { @@ -158,6 +164,7 @@ struct ComposeView: View { PreviewView( text: textEditorObserver.text, photos: photos, + root: root, isPresenting: $isPresenting ) .task { @@ -185,7 +192,7 @@ struct ComposeView: View { .background { Color.appBg } - .navigationTitle(Localized.newPost.text) + .navigationTitle(isReply ? Localized.newReply.text : Localized.newPost.text) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { diff --git a/Source/UI/Compose/PreviewView.swift b/Source/UI/Compose/PreviewView.swift index 85eb4a000d..c2da630379 100644 --- a/Source/UI/Compose/PreviewView.swift +++ b/Source/UI/Compose/PreviewView.swift @@ -19,6 +19,9 @@ struct PreviewView: View { /// The photos attached in the Post var photos: [UIImage] + /// The root message (if it is a reply) + var root: Message? + /// A Binding used to close the modal containing this view @Binding var isPresenting: Bool @@ -43,78 +46,96 @@ struct PreviewView: View { var body: some View { ZStack { VStack { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .center) { - HStack(alignment: .center) { - if let about = about { - AvatarView(metadata: about.image, size: 24) - if let header = attributedHeader { - Text(header) - .lineLimit(1) - .font(.subheadline) - .foregroundColor(Color.secondaryTxt) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - } + ScrollView(.vertical) { + ZStack(alignment: .top) { + if let root = root { + ZStack { + MessageButton( + message: root, + style: .compact, + shouldDisplayChain: false + ) + .allowsHitTesting(false) + .padding(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) + .opacity(0.7) } + .frame(height: 100, alignment: .top) } - } - .padding(10) - Divider().overlay(Color.cardDivider).shadow(color: .cardDividerShadow, radius: 0, x: 0, y: 1) - VStack(alignment: .leading, spacing: 0) { - Text(text.parseMarkdown()) - .allowsHitTesting(false) - .lineLimit(5) - .font(.body) - .foregroundColor(.primaryTxt) - .accentColor(.accent) - .padding(15) - if !photos.isEmpty { - TabView { - if photos.isEmpty { - Spacer() - } else { - ForEach(photos, id: \.self) { photo in - Image(uiImage: photo) - .resizable() - .scaledToFill() + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center) { + HStack(alignment: .center) { + if let about = about { + AvatarView(metadata: about.image, size: 24) + if let header = attributedHeader { + Text(header) + .lineLimit(1) + .font(.subheadline) + .foregroundColor(Color.secondaryTxt) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + .padding(10) + Divider().overlay(Color.cardDivider).shadow(color: .cardDividerShadow, radius: 0, x: 0, y: 1) + VStack(alignment: .leading, spacing: 0) { + Text(text.parseMarkdown()) + .allowsHitTesting(false) + .font(.body) + .foregroundColor(.primaryTxt) + .accentColor(.accent) + .padding(15) + if !photos.isEmpty { + TabView { + if photos.isEmpty { + Spacer() + } else { + ForEach(photos, id: \.self) { photo in + Image(uiImage: photo) + .resizable() + .scaledToFill() + } + } } + .tabViewStyle(.page) + .aspectRatio(1, contentMode: .fit) } } - .tabViewStyle(.page) - .aspectRatio(1, contentMode: .fit) + Divider().overlay(Color.cardDivider).shadow(color: .cardDividerShadow, radius: 0, x: 0, y: 1) + HStack { + Spacer() + Image.buttonReply + Image.iconLike + } + .padding(15) } - } - Divider().overlay(Color.cardDivider).shadow(color: .cardDividerShadow, radius: 0, x: 0, y: 1) - HStack { - Spacer() - Image.buttonReply - } - .padding(15) - } - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button(Localized.postAction.text) { - Analytics.shared.trackDidTapButton(buttonName: "post") - publishPost() + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(Localized.postAction.text) { + Analytics.shared.trackDidTapButton(buttonName: "post") + publishPost() + } + .allowsHitTesting(!isLoading) + } } - .allowsHitTesting(!isLoading) + .background( + LinearGradient.cardGradient + ) + .cornerRadius(20) + .offset(y: root == nil ? 0 : 100) + .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 + ) + Spacer() } } - .background( - LinearGradient.cardGradient - ) - .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 - ) - Spacer() } if isLoading { LoadingView(text: Localized.NewPost.publishing.text) @@ -198,8 +219,14 @@ struct PreviewView: View { let bot = await botRepository.current let text = await text let photos = await photos + let root = await root do { - let post = Post(text: text) + let post: Post + if let root = root { + post = Post(text: text, root: root.key, branches: [root.key]) + } else { + post = Post(text: text) + } try await bot.publish(post, with: photos) Analytics.shared.trackDidPost(characterCount: text.count) await MainActor.run { @@ -220,7 +247,7 @@ struct PreviewView: View { } private var attributedHeader: AttributedString? { - let localized = Localized.posted + let localized = root == nil ? Localized.posted : Localized.replied let displayName = about?.displayName ?? "You" let string = localized.text(["somebody": "**\(displayName)**"]) do { @@ -236,12 +263,46 @@ struct PreviewView: View { } struct PreviewView_Previews: PreviewProvider { + static var messageValue: MessageValue { + MessageValue( + author: "@QW5uYVZlcnNlWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFg=.ed25519", + content: Content( + from: Post( + blobs: nil, + branches: nil, + hashtags: nil, + mentions: nil, + root: "%somepost", + 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 + } + @State static var isPresenting = false static var previews: some View { NavigationView { - PreviewView(text: "Hey ", photos: [], isPresenting: $isPresenting) + PreviewView(text: "Hey ", photos: [], root: message, isPresenting: $isPresenting) } .injectAppEnvironment(botRepository: .fake) } diff --git a/Source/UI/Message/CompactMessageView.swift b/Source/UI/Message/CompactMessageView.swift new file mode 100644 index 0000000000..7ab2e07874 --- /dev/null +++ b/Source/UI/Message/CompactMessageView.swift @@ -0,0 +1,255 @@ +// +// CompactMessageView.swift +// Planetary +// +// Created by Martin Dutra on 21/3/23. +// Copyright © 2023 Verse Communications Inc. All rights reserved. +// + +import CrashReporting +import Logger +import SwiftUI + +struct CompactMessageView: View { + + var identifierOrMessage: Either + + var shouldTruncateIfNeeded: Bool + + var didTapReply: (() -> Void)? + + @State + private var liked: Bool? + + @EnvironmentObject + private var appController: AppController + + @EnvironmentObject + private var botRepository: BotRepository + + private var message: Message? { + switch identifierOrMessage { + case .left: + return nil + case .right(let message): + return message + } + } + + init(identifier: MessageIdentifier) { + self.init( + identifierOrMessage: .left(identifier), + shouldTruncateIfNeeded: true, + didTapReply: nil + ) + } + + init(message: Message, shouldTruncateIfNeeded: Bool, didTapReply: (() -> Void)? = nil) { + self.init( + identifierOrMessage: .right(message), + shouldTruncateIfNeeded: shouldTruncateIfNeeded, + didTapReply: didTapReply + ) + } + + init( + identifierOrMessage: Either, + shouldTruncateIfNeeded: Bool = true, + didTapReply: (() -> Void)? = nil + ) { + self.identifierOrMessage = identifierOrMessage + self.shouldTruncateIfNeeded = shouldTruncateIfNeeded + self.didTapReply = didTapReply + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let message = message { + MessageHeaderView(message: message) + Divider().overlay(Color.cardDivider).shadow(color: .cardDividerShadow, radius: 0, x: 0, y: 1) + if let contact = message.content.contact?.contact { + Button { + AppController.shared.open(identity: contact) + } label: { + IdentityCard(identity: contact, style: .compact) + } + } else if let post = message.content.post { + CompactPostView(identifier: message.id, post: post, lineLimit: shouldTruncateIfNeeded ? 5 : nil) + } 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(alignment: .center, spacing: 15) { + if !replies.isEmpty { + StackedAvatarsView(avatars: replies, size: 20, border: 0) + } + if let replies = attributedReplies { + Text(replies) + .font(.subheadline) + .foregroundColor(Color.secondaryTxt) + } + Spacer() + Group { + if let didTapReply = didTapReply { + Button { + didTapReply() + } label: { + Image.buttonReply + } + } else { + NavigationLink { + MessageView(message: message, shouldOpenCompose: true, bot: botRepository.current) + .injectAppEnvironment(botRepository: botRepository, appController: appController) + } label: { + Image.buttonReply + } + } + if let liked = liked { + LikeButton(message: message, liked: liked) + } else { + ProgressView() + } + } + .frame(width: 18, height: 18, alignment: .center) + } + .padding(15) + } else { + VStack(alignment: .leading, spacing: 0) { + MessageHeaderView(identifier: identifierOrMessage.id) + Divider() + .overlay(Color.cardDivider) + .shadow(color: .cardDividerShadow, radius: 0, x: 0, y: 1) + VStack(alignment: .center, spacing: 15) { + Image.messageNotVisible + .renderingMode(.template) + .foregroundColor(.primaryTxt) + Text("This message is hidden because the user's profile and information are private") + .foregroundColor(.secondaryTxt) + .multilineTextAlignment(.center) + } + .padding(15) + } + } + } + .background( + LinearGradient.cardGradient + ) + .cornerRadius(20) + .padding(EdgeInsets(top: 15, leading: 15, bottom: 0, trailing: 15)) + .onReceive(NotificationCenter.default.publisher(for: .didPublishVote)) { notification in + guard let identifier = notification.identifier else { + return + } + if identifier == message?.key { + liked = nil + Task { + await loadLikedIfNeeded() + } + } + } + .task(id: message, priority: .userInitiated) { + await loadLikedIfNeeded() + } + } + + private func loadLikedIfNeeded() async { + guard let message = message else { + return + } + guard liked == nil else { + return + } + let identifier = message.id + let bot = botRepository.current + guard let author = bot.identity else { + return + } + do { + let result = try await bot.likes(identifier: identifier, by: author) + await MainActor.run { + liked = result + } + } catch { + Log.optional(error) + CrashReporting.shared.reportIfNeeded(error: error) + await MainActor.run { + liked = nil + } + } + } + + private var replies: [ImageMetadata] { + guard let message = message else { + return [] + } + return Array(message.metadata.replies.abouts.compactMap { $0.image }.prefix(2)) + } + + private var attributedReplies: AttributedString? { + guard let message = message else { + return nil + } + guard !message.metadata.replies.isEmpty else { + return nil + } + let replyCount = message.metadata.replies.count + let localized = replyCount == 1 ? Localized.Reply.one : Localized.Reply.many + let string = localized.text(["count": "**\(replyCount)**"]) + do { + var attributed = try AttributedString(markdown: string) + if let range = attributed.range(of: "\(replyCount)") { + attributed[range].foregroundColor = .primaryTxt + } + return attributed + } catch { + return nil + } + } +} + +struct CompactMessageView_Previews: PreviewProvider { + static var messageValue: MessageValue { + MessageValue( + author: "@QW5uYVZlcnNlWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFg=.ed25519", + content: Content( + from: Post( + blobs: nil, + branches: nil, + hashtags: nil, + mentions: nil, + root: "%somepost", + 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 { + VStack { + CompactMessageView(identifier: "%unset") + CompactMessageView(message: message, shouldTruncateIfNeeded: true) + } + .padding() + .background(Color.appBg) + .injectAppEnvironment(botRepository: .fake, appController: .shared) + } +} 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/GoldenMessageView.swift b/Source/UI/Message/GoldenMessageView.swift new file mode 100644 index 0000000000..c24b1cde9e --- /dev/null +++ b/Source/UI/Message/GoldenMessageView.swift @@ -0,0 +1,158 @@ +// +// GoldenMessageView.swift +// Planetary +// +// Created by Martin Dutra on 21/3/23. +// Copyright © 2023 Verse Communications Inc. All rights reserved. +// + +import SwiftUI + +struct GoldenMessageView: View { + + var identifierOrMessage: Either + + init(identifier: MessageIdentifier) { + self.init(identifierOrMessage: .left(identifier)) + } + + init(message: Message) { + self.init(identifierOrMessage: .right(message)) + } + + init(identifierOrMessage: Either) { + self.identifierOrMessage = identifierOrMessage + } + + private var message: Message? { + switch identifierOrMessage { + case .left: + return nil + case .right(let message): + return message + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Group { + if let message = message { + if let contact = message.content.contact { + GoldenIdentityView( + identity: contact.contact + ) + } else if let post = message.content.post { + GoldenPostView( + identifier: message.id, + post: post, + author: About( + identity: message.author, + name: message.metadata.author.about?.name, + description: nil, + image: message.metadata.author.about?.image, + publicWebHosting: nil + ) + ) + } + } else { + VStack(alignment: .center, spacing: 15) { + Spacer(minLength: 0) + Image.messageNotVisible + .renderingMode(.template) + .foregroundColor(.primaryTxt) + Text("This message is hidden because the user's profile and information are private") + .foregroundColor(.secondaryTxt) + .multilineTextAlignment(.center) + Spacer(minLength: 0) + } + .padding(15) + } + } + .background( + LinearGradient.cardGradient + ) + .cornerRadius(15) + .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } + } +} + +struct GoldenMessageView_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 follow: Message { + var message = Message( + key: "@unset", + value: MessageValue( + author: "@QW5uYVZlcnNlWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFg=.ed25519", + content: Content( + from: Contact( + contact: .null, + following: true + ) + ), + hash: "", + previous: nil, + sequence: 0, + signature: .null, + claimedTimestamp: 0 + ), + 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 { + Group { + LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible())], spacing: 10) { + GoldenMessageView(identifier: "%unset") + GoldenMessageView(message: message) + GoldenMessageView(message: follow) + } + LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible())], spacing: 10) { + GoldenMessageView(message: message) + GoldenMessageView(message: follow) + } + .preferredColorScheme(.dark) + } + .padding(10) + .background(Color.appBg) + .environmentObject(BotRepository.fake) + } +} diff --git a/Source/UI/Message/LikeButton.swift b/Source/UI/Message/LikeButton.swift new file mode 100644 index 0000000000..f8deb781dc --- /dev/null +++ b/Source/UI/Message/LikeButton.swift @@ -0,0 +1,126 @@ +// +// LikeButton.swift +// Planetary +// +// Created by Martin Dutra on 17/3/23. +// Copyright © 2023 Verse Communications Inc. All rights reserved. +// + +import Analytics +import CrashReporting +import Logger +import SwiftUI + +struct LikeButton: View { + + var message: Message + var liked: Bool + + @EnvironmentObject + private var botRepository: BotRepository + + @State + private var isLoading = false + + var body: some View { + Group { + if isLoading { + ProgressView() + } else { + Button { + Analytics.shared.trackDidTapButton(buttonName: "like") + like() + } label: { + if liked { + Image.iconLiked + } else { + Image.iconLike + } + } + .disabled(liked) + } + } + .frame(width: 18, height: 18, alignment: .center) + } + + private func like() { + // TODO: Check root and branches + let vote = ContentVote( + link: message.key, + value: 1, + expression: "💜", + root: message.key, + branches: [message.key] + ) + isLoading = true + Task.detached(priority: .userInitiated) { + let bot = await botRepository.current + do { + try await bot.publish(content: vote) + await MainActor.run { + isLoading = false + NotificationCenter.default.post(.didPublishVote(to: vote.vote.link)) + } + } catch { + Log.optional(error) + CrashReporting.shared.reportIfNeeded(error: error) + await MainActor.run { + // show alert + isLoading = false + } + } + } + } +} + +struct LikeButton_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 { + Group { + VStack { + LikeButton(message: message, liked: false) + LikeButton(message: message, liked: true) + } + VStack { + LikeButton(message: message, liked: false) + LikeButton(message: message, liked: true) + } + .preferredColorScheme(.dark) + } + .injectAppEnvironment(botRepository: .fake) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.appBg) + } +} diff --git a/Source/UI/Message/MessageButton.swift b/Source/UI/Message/MessageButton.swift index 31ef7f3538..b2488544fa 100644 --- a/Source/UI/Message/MessageButton.swift +++ b/Source/UI/Message/MessageButton.swift @@ -13,21 +13,35 @@ import SwiftUI /// /// The button opens ThreadViewController when tapped. struct MessageButton: View { - var message: Message - var style = CardStyle.compact + + var identifierOrMessage: Either + var style: CardStyle + + // If true, it displays a chain line above the card + var shouldDisplayChain: Bool + + init(identifier: MessageIdentifier, style: CardStyle, shouldDisplayChain: Bool = false) { + self.init(identifierOrMessage: .left(identifier), style: style, shouldDisplayChain: shouldDisplayChain) + } + + init(message: Message, style: CardStyle, shouldDisplayChain: Bool = false) { + self.init(identifierOrMessage: .right(message), style: style, shouldDisplayChain: shouldDisplayChain) + } + + init(identifierOrMessage: Either, style: CardStyle, shouldDisplayChain: Bool) { + self.identifierOrMessage = identifierOrMessage + self.style = style + self.shouldDisplayChain = shouldDisplayChain + } @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: identifierOrMessage.id) } label: { - MessageCard(message: message, style: style) + MessageCard(identifierOrMessage: identifierOrMessage, style: style, shouldDisplayChain: shouldDisplayChain) } .buttonStyle(CardButtonStyle()) } @@ -71,9 +85,9 @@ struct MessageButton_Previews: PreviewProvider { static var previews: some View { Group { - MessageButton(message: message) + MessageButton(message: message, style: .compact) MessageButton(message: message, style: .golden) - MessageButton(message: message) + MessageButton(message: message, style: .compact) .preferredColorScheme(.dark) } .environmentObject(AppController.shared) diff --git a/Source/UI/Message/MessageCard.swift b/Source/UI/Message/MessageCard.swift index be7a816382..08d707152e 100644 --- a/Source/UI/Message/MessageCard.swift +++ b/Source/UI/Message/MessageCard.swift @@ -13,155 +13,46 @@ import SwiftUI /// Use this view inside MessageButton to have nice borders. struct MessageCard: View { - var message: Message - var style = CardStyle.compact + var identifierOrMessage: Either + var style: CardStyle + var shouldDisplayChain: Bool - @EnvironmentObject - private var appController: AppController - - private var author: About { - About( - identity: message.author, - name: message.metadata.author.about?.name, - description: nil, - image: message.metadata.author.about?.image, - publicWebHosting: nil - ) + init(identifier: MessageIdentifier, style: CardStyle, shouldDisplayChain: Bool = false) { + self.init(identifierOrMessage: .left(identifier), style: style, shouldDisplayChain: shouldDisplayChain) } - 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) - } - .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 { - 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 - } - .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) - } - } - } - .background( - LinearGradient.cardGradient - ) - .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 - } + init(message: Message, style: CardStyle, shouldDisplayChain: Bool = false) { + self.init(identifierOrMessage: .right(message), style: style, shouldDisplayChain: shouldDisplayChain) } - var padding: EdgeInsets { - switch style { - case .golden: - return EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) - case .compact: - return EdgeInsets(top: 15, leading: 15, bottom: 0, trailing: 15) - } + init(identifierOrMessage: Either, style: CardStyle, shouldDisplayChain: Bool) { + self.identifierOrMessage = identifierOrMessage + self.style = style + self.shouldDisplayChain = shouldDisplayChain } - var cornerRadius: CGFloat { - switch style { - case .golden: - return 15 - case .compact: - return 20 - } - } + @EnvironmentObject + private var appController: AppController - private var replies: [ImageMetadata] { - Array(message.metadata.replies.abouts.compactMap { $0.image }.prefix(2)) - } + @EnvironmentObject + private var botRepository: BotRepository - private var attributedReplies: AttributedString? { - guard !message.metadata.replies.isEmpty else { - return nil - } - let replyCount = message.metadata.replies.count - let localized = replyCount == 1 ? Localized.Reply.one : Localized.Reply.many - let string = localized.text(["count": "**\(replyCount)**"]) - do { - var attributed = try AttributedString(markdown: string) - if let range = attributed.range(of: "\(replyCount)") { - attributed[range].foregroundColor = .primaryTxt + var body: some View { + ZStack { + if shouldDisplayChain { + Path { path in + path.move(to: CGPoint(x: 35, y: -4)) + path.addLine(to: CGPoint(x: 35, y: 15)) + } + .stroke(style: StrokeStyle(lineWidth: 4, lineCap: .round)) + .fill(Color.secondaryTxt) + } + switch style { + case .compact: + CompactMessageView(identifierOrMessage: identifierOrMessage, shouldTruncateIfNeeded: true) + case .golden: + GoldenMessageView(identifierOrMessage: identifierOrMessage) } - return attributed - } catch { - return nil } } } @@ -260,12 +151,12 @@ struct MessageCard_Previews: PreviewProvider { static var previews: some View { Group { ScrollView { - VStack { - MessageCard(message: message) - MessageCard(message: messageWithOneReply) - MessageCard(message: messageWithReplies) - MessageCard(message: messageWithLongAuthor) - MessageCard(message: messageWithUnknownAuthor) + VStack(spacing: 0) { + MessageCard(message: message, style: .compact) + MessageCard(message: messageWithOneReply, style: .compact) + MessageCard(message: messageWithReplies, style: .compact) + MessageCard(message: messageWithLongAuthor, style: .compact) + MessageCard(message: messageWithUnknownAuthor, style: .compact) } } ScrollView { @@ -279,18 +170,17 @@ struct MessageCard_Previews: PreviewProvider { } ScrollView { VStack { - MessageCard(message: message) - MessageCard(message: messageWithOneReply) - MessageCard(message: messageWithReplies) - MessageCard(message: messageWithLongAuthor) - MessageCard(message: messageWithUnknownAuthor) + MessageCard(message: message, style: .compact) + MessageCard(message: messageWithOneReply, style: .compact) + MessageCard(message: messageWithReplies, style: .compact) + MessageCard(message: messageWithLongAuthor, style: .compact) + MessageCard(message: messageWithUnknownAuthor, style: .compact) } } .preferredColorScheme(.dark) } .padding() .background(Color.appBg) - .environmentObject(BotRepository.fake) - .environmentObject(AppController.shared) + .injectAppEnvironment(botRepository: BotRepository.fake) } } diff --git a/Source/UI/Message/MessageHeaderView.swift b/Source/UI/Message/MessageHeaderView.swift index 3b067be4f5..955f17f3d5 100644 --- a/Source/UI/Message/MessageHeaderView.swift +++ b/Source/UI/Message/MessageHeaderView.swift @@ -9,39 +9,54 @@ import SwiftUI struct MessageHeaderView: View { - var message: Message - var attributedTitle: AttributedString? - var shouldDisplayOptions = true + + var identifier: MessageIdentifier + var message: Message? + + init(identifier: MessageIdentifier) { + self.identifier = identifier + } + + init(message: Message) { + self.identifier = message.id + self.message = message + } @EnvironmentObject private var appController: AppController var body: some View { HStack(alignment: .center) { - Button { - appController.open(identity: message.author) - } label: { - HStack(alignment: .center) { - AvatarView(metadata: message.metadata.author.about?.image, size: 24) - if let title = attributedTitle { - Text(title) - .lineLimit(1) - .font(.subheadline) - .foregroundColor(Color.secondaryTxt) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) + if let message = message { + Button { + appController.open(identity: message.author) + } 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) + } } } - } - if shouldDisplayOptions { MessageOptionsButton(message: message) + } else { + Spacer() + MessageOptionsButton(identifier: identifier) } } .padding(10) } private var author: About { - About( + guard let message = message else { + return About(about: .null) + } + return About( identity: message.author, name: message.metadata.author.about?.name, description: nil, @@ -51,6 +66,9 @@ struct MessageHeaderView: View { } private var attributedHeader: AttributedString? { + guard let message = message else { + return nil + } var localized: Localized switch message.contentType { case .post: @@ -73,6 +91,8 @@ struct MessageHeaderView: View { } else { localized = .stoppedFollowing } + case .vote: + localized = .reacted default: return nil } @@ -88,3 +108,49 @@ struct MessageHeaderView: View { } } } + +struct MessageHeaderView_Previews: PreviewProvider { + static var messageValue: MessageValue { + MessageValue( + author: "@QW5uYVZlcnNlWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFg=.ed25519", + content: Content( + from: Post( + blobs: nil, + branches: nil, + hashtags: nil, + mentions: nil, + root: "%somepost", + 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 { + Group { + MessageHeaderView(message: message) + MessageHeaderView(message: message) + .preferredColorScheme(.dark) + } + .padding() + .background(LinearGradient.cardGradient) + .injectAppEnvironment(botRepository: .fake) + } +} diff --git a/Source/UI/Message/MessageList.swift b/Source/UI/Message/MessageList.swift index 1edfb9eca7..a19ebea86d 100644 --- a/Source/UI/Message/MessageList.swift +++ b/Source/UI/Message/MessageList.swift @@ -16,7 +16,7 @@ struct MessageList: View where DataSource: MessageDataSource { var body: some View { InfiniteList(dataSource: dataSource) { message in if let message = message as? Message { - MessageButton(message: message) + MessageButton(message: message, style: .compact) .id(message) } } diff --git a/Source/UI/Message/MessageOptionsButton.swift b/Source/UI/Message/MessageOptionsButton.swift index 2611f3f587..dad66336fe 100644 --- a/Source/UI/Message/MessageOptionsButton.swift +++ b/Source/UI/Message/MessageOptionsButton.swift @@ -11,7 +11,17 @@ import SwiftUI struct MessageOptionsButton: View { - var message: Message + var identifier: MessageIdentifier + var message: Message? + + init(identifier: MessageIdentifier) { + self.identifier = identifier + } + + init(message: Message) { + self.identifier = message.id + self.message = message + } @EnvironmentObject private var botRepository: BotRepository @@ -38,27 +48,33 @@ struct MessageOptionsButton: View { Analytics.shared.trackDidSelectAction(actionName: "copy_message_identifier") copyMessageIdentifier() } - Button(Localized.shareThisMessage.text) { - Analytics.shared.trackDidSelectAction(actionName: "share_message") - showingShare = true - } - Button(Localized.viewSource.text) { - Analytics.shared.trackDidSelectAction(actionName: "view_message_source") - showingSource = true - } - Button(Localized.reportPost.text, role: .destructive) { - Analytics.shared.trackDidSelectAction(actionName: "report_post") - reportPost() + if message != nil { + Button(Localized.shareThisMessage.text) { + Analytics.shared.trackDidSelectAction(actionName: "share_message") + showingShare = true + } + Button(Localized.viewSource.text) { + Analytics.shared.trackDidSelectAction(actionName: "view_message_source") + showingSource = true + } + Button(Localized.reportPost.text, role: .destructive) { + Analytics.shared.trackDidSelectAction(actionName: "report_post") + reportPost() + } } } .sheet(isPresented: $showingSource) { NavigationView { - RawMessageView(viewModel: RawMessageController(message: message, bot: botRepository.current)) - .navigationBarTitleDisplayMode(.inline) + if let message = message { + RawMessageView(viewModel: RawMessageController(message: message, bot: botRepository.current)) + .navigationBarTitleDisplayMode(.inline) + } else { + EmptyView() + } } } .sheet(isPresented: $showingShare) { - if let url = message.key.publicLink { + if let url = message?.key.publicLink { ActivityViewController(activityItems: [url]) } else { ActivityViewController(activityItems: []) @@ -67,11 +83,14 @@ struct MessageOptionsButton: View { } func copyMessageIdentifier() { - UIPasteboard.general.string = message.key + UIPasteboard.general.string = identifier AppController.shared.showToast(Localized.identifierCopied.text) } func reportPost() { + guard let message = message else { + return + } AppController.shared.report(message, in: nil, from: message.author) } } @@ -106,6 +125,7 @@ struct MessageOptionsView_Previews: PreviewProvider { static var previews: some View { Group { MessageOptionsButton(message: message) + MessageOptionsButton(identifier: "%unset") MessageOptionsButton(message: message) .preferredColorScheme(.dark) } diff --git a/Source/UI/Message/MessageStack.swift b/Source/UI/Message/MessageStack.swift index 62e25aad0f..281f7ba4cb 100644 --- a/Source/UI/Message/MessageStack.swift +++ b/Source/UI/Message/MessageStack.swift @@ -8,17 +8,23 @@ import SwiftUI -/// A stack of messages. The primary purpose of this view is to be used in the Profile screen -/// inside the ScrollView defined in that screen. For most cases, consider using MessageList instead -/// that already integrates a ScrollView. +/// A stack of messages. The primary purpose of this view is to be used inside a ScrollView. +/// For most cases, consider using MessageList instead because it already integrates a ScrollView. 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, + style: .compact, + shouldDisplayChain: chained + ) } } } diff --git a/Source/UI/Message/MessageView.swift b/Source/UI/Message/MessageView.swift new file mode 100644 index 0000000000..6c26b5848b --- /dev/null +++ b/Source/UI/Message/MessageView.swift @@ -0,0 +1,327 @@ +// +// 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? + + @State + private var root: Message? + + @EnvironmentObject + private var botRepository: BotRepository + + @ObservedObject + private var dataSource: FeedStrategyMessageDataSource + + @State + private var showCompose = false + + @State + private var isLoadingMessage = false + + @State + private var isLoadingRoot = false + + init(identifier: MessageIdentifier, shouldOpenCompose: Bool = false, bot: Bot) { + self.init(identifierOrMessage: .left(identifier), shouldOpenCompose: shouldOpenCompose, bot: bot) + } + + init(message: Message, shouldOpenCompose: Bool = false, bot: Bot) { + self.init(identifierOrMessage: .right(message), shouldOpenCompose: shouldOpenCompose, bot: bot) + } + + init(identifierOrMessage: Either, shouldOpenCompose: Bool, bot: Bot) { + self.identifierOrMessage = identifierOrMessage + switch identifierOrMessage { + case .right(let message): + self.message = message + default: + self.message = nil + } + self.showCompose = shouldOpenCompose + self.dataSource = FeedStrategyMessageDataSource( + strategy: RepliesStrategy(identifier: identifierOrMessage.id), + bot: bot + ) + } + + var identifierOrLoadedMessage: Either { + if let message = message { + return .right(message) + } else { + return identifierOrMessage + } + } + + var rootIdentifier: Either? { + if let root = root { + return .right(root) + } else if let identifier = message?.content.post?.root { + return .left(identifier) + } else if let identifier = message?.content.vote?.vote.link { + return .left(identifier) + } else { + return nil + } + } + + var body: some View { + Group { + if isLoadingMessage { + LoadingView() + } else { + VStack(spacing: 0) { + ScrollView(.vertical) { + ZStack(alignment: .top) { + if isLoadingRoot { + LoadingCard(style: .compact) + .padding(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) + .opacity(0.7) + } else if let rootIdentifier = rootIdentifier { + ZStack { + MessageButton( + identifierOrMessage: rootIdentifier, + style: .compact, + shouldDisplayChain: false + ) + .padding(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) + .opacity(0.7) + } + .frame(height: 100, alignment: .top) + } + CompactMessageView( + identifierOrMessage: identifierOrLoadedMessage, + shouldTruncateIfNeeded: false, + didTapReply: { + showCompose = true + } + ) + .compositingGroup() + .shadow(color: .cardBorderBottom, radius: 0, x: 0, y: 4) + .shadow( + color: .cardShadowBottom, + radius: 10, + x: 0, + y: 4 + ) + .offset(y: rootIdentifier == nil ? 0 : 100) + .padding( + EdgeInsets(top: 0, leading: 0, bottom: rootIdentifier == nil ? 0 : 100, trailing: 0) + ) + } + MessageStack(dataSource: dataSource, chained: true) + .placeholder(when: dataSource.isEmpty, alignment: .top) { + EmptyView() + } + Spacer(minLength: 15) + } + } + } + } + .background(Color.appBg) + .navigationTitle(title) + .onReceive(NotificationCenter.default.publisher(for: .didPublishPost)) { _ in + Task { + await reloadMessage() + await dataSource.loadFromScratch() + } + } + .onReceive(NotificationCenter.default.publisher(for: .didPublishVote)) { notification in + guard let identifier = notification.identifier else { + return + } + if identifier == message?.key { + Task { + await reloadMessage() + await dataSource.loadFromScratch() + } + } else if let cache = dataSource.cache, cache.contains(where: { $0.key == identifier }) { + Task { + await dataSource.loadFromScratch() + } + } + } + .sheet(isPresented: $showCompose) { + ComposeView(isPresenting: $showCompose, root: message) + } + .task { + loadMessageIfNeeded() + loadRootIfNeeded() + } + } + + private var title: String { + switch identifierOrMessage { + case .left: + return Localized.Message.message.text + case .right(let message): + switch message.content.type { + case .post: + if message.content.post?.root != nil { + return Localized.Message.reply.text + } else { + return Localized.Post.title.text + } + case .contact: + return Localized.Message.contact.text + case .vote: + return Localized.Message.reaction.text + default: + return Localized.Message.message.text + } + } + } + + private func loadMessageIfNeeded() { + guard message == nil else { + return + } + switch identifierOrMessage { + case .left(let messageIdentifier): + isLoadingMessage = true + Task.detached { + let bot = await botRepository.current + do { + let result = try await bot.message(identifier: messageIdentifier) + await MainActor.run { + message = result + isLoadingMessage = false + loadRootIfNeeded() + } + } catch { + Log.optional(error) + CrashReporting.shared.reportIfNeeded(error: error) + await MainActor.run { + message = nil + isLoadingMessage = false + } + } + } + case .right(let message): + self.message = message + } + } + + private func reloadMessage() async { + let messageIdentifier = identifierOrMessage.id + let bot = 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) + } + } + + private func loadRootIfNeeded() { + guard root == nil else { + return + } + guard let content = message?.content else { + return + } + var rootIdentifier: MessageIdentifier? + switch content.type { + case .vote: + rootIdentifier = content.vote?.vote.link + case .post: + rootIdentifier = content.post?.root + default: + rootIdentifier = nil + } + guard let rootIdentifier = rootIdentifier else { + return + } + isLoadingRoot = true + Task.detached { + let bot = await botRepository.current + do { + let result = try await bot.message(identifier: rootIdentifier) + await MainActor.run { + root = result + isLoadingRoot = false + } + } catch { + Log.optional(error) + CrashReporting.shared.reportIfNeeded(error: error) + await MainActor.run { + root = nil + isLoadingRoot = false + } + } + } + } +} + +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: "%somepost", + 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 { + NavigationView { + MessageView(message: message, bot: FakeBot()) + .injectAppEnvironment(botRepository: .fake) + } + } +} diff --git a/Source/UI/MessageUI/MessagePaginatedCollectionViewDelegate.swift b/Source/UI/MessageUI/MessagePaginatedCollectionViewDelegate.swift index fa3a3c10f0..4e4ded5439 100644 --- a/Source/UI/MessageUI/MessagePaginatedCollectionViewDelegate.swift +++ b/Source/UI/MessageUI/MessagePaginatedCollectionViewDelegate.swift @@ -29,7 +29,7 @@ extension MessagePaginatedCollectionViewDelegate: UICollectionViewDelegate { guard let message = dataSource.data.messageBy(index: indexPath.row) else { return } - let controller = ThreadViewController(with: message) + let controller = MessageViewBuilder.build(identifier: message.id) self.viewController?.navigationController?.pushViewController(controller, animated: true) } } diff --git a/Source/UI/SwiftUI Helper Views/InfiniteStack.swift b/Source/UI/SwiftUI Helper Views/InfiniteStack.swift index 9172d12489..663dd55e50 100644 --- a/Source/UI/SwiftUI Helper Views/InfiniteStack.swift +++ b/Source/UI/SwiftUI Helper Views/InfiniteStack.swift @@ -125,7 +125,7 @@ struct InfiniteStack_Previews: PreviewProvider { } static var previews: some View { InfiniteStack(dataSource: StaticMessageDataSource(messages: [message])) { message in - MessageButton(message: message) + MessageButton(message: message, style: .compact) } } } diff --git a/Source/UI/UniversalSearchResultsView.swift b/Source/UI/UniversalSearchResultsView.swift index a09f693f78..02a09339d1 100644 --- a/Source/UI/UniversalSearchResultsView.swift +++ b/Source/UI/UniversalSearchResultsView.swift @@ -481,7 +481,7 @@ class UniversalSearchResultsView: UIView, UITableViewDelegate, UITableViewDataSo return } let post = posts[indexPath.row] - let controller = ThreadViewController(with: post, startReplying: false) + let controller = MessageViewBuilder.build(identifier: post.id) delegate?.present(controller) case .network: guard let inNetworkPeople = searchResults.inNetworkPeople else {