diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 70e01d1e7..104ac08d9 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -188,7 +188,11 @@ DB029E95266A20430062874E /* MastodonAuthenticationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB029E94266A20430062874E /* MastodonAuthenticationController.swift */; }; DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */; }; DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; }; - DB040ECD26526EA600BEE9D8 /* ComposeCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */; }; + DB03F7EB268976B5007B274C /* MastodonMeta in Frameworks */ = {isa = PBXBuildFile; productRef = DB03F7EA268976B5007B274C /* MastodonMeta */; }; + DB03F7ED268976B5007B274C /* MetaTextView in Frameworks */ = {isa = PBXBuildFile; productRef = DB03F7EC268976B5007B274C /* MetaTextView */; }; + DB03F7F026899097007B274C /* ComposeStatusContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB03F7EF26899097007B274C /* ComposeStatusContentTableViewCell.swift */; }; + DB03F7F32689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB03F7F22689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift */; }; + DB03F7F52689B782007B274C /* ComposeTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB03F7F42689B782007B274C /* ComposeTableView.swift */; }; DB040ED126538E3D00BEE9D8 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB040ED026538E3C00BEE9D8 /* Trie.swift */; }; DB084B5725CBC56C00F898ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Status.swift */; }; DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; }; @@ -221,6 +225,13 @@ DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */; }; DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; }; DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC2E26130172006193C9 /* MastodonField.swift */; }; + DB36679D268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB36679C268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift */; }; + DB36679F268ABAF20027D07F /* ComposeStatusAttachmentSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */; }; + DB3667A1268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667A0268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift */; }; + DB3667A4268AE2370027D07F /* ComposeStatusPollTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667A3268AE2370027D07F /* ComposeStatusPollTableViewCell.swift */; }; + DB3667A6268AE2620027D07F /* ComposeStatusPollSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */; }; + DB3667A8268AE2900027D07F /* ComposeStatusPollItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */; }; + DB3667CA268B14A80027D07F /* ReplicaStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3667C9268B14A80027D07F /* ReplicaStatusView.swift */; }; DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; }; DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; }; DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD525BAA00100D1B89D /* AppDelegate.swift */; }; @@ -339,8 +350,6 @@ DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */; }; DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; }; DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; }; - DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; }; - DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */; }; DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */; }; DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; }; DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */; }; @@ -799,7 +808,9 @@ DB029E94266A20430062874E /* MastodonAuthenticationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationController.swift; sourceTree = ""; }; DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReplyLoaderTableViewCell.swift; sourceTree = ""; }; DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = ""; }; - DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCollectionView.swift; sourceTree = ""; }; + DB03F7EF26899097007B274C /* ComposeStatusContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentTableViewCell.swift; sourceTree = ""; }; + DB03F7F22689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentTableViewCell.swift; sourceTree = ""; }; + DB03F7F42689B782007B274C /* ComposeTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTableView.swift; sourceTree = ""; }; DB040ED026538E3C00BEE9D8 /* Trie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trie.swift; sourceTree = ""; }; DB084B5625CBC56C00F898ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = ""; }; @@ -834,6 +845,13 @@ DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRelationshipActionButton.swift; sourceTree = ""; }; DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldView.swift; sourceTree = ""; }; DB35FC2E26130172006193C9 /* MastodonField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonField.swift; sourceTree = ""; }; + DB36679C268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentTableViewCell.swift; sourceTree = ""; }; + DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentSection.swift; sourceTree = ""; }; + DB3667A0268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentItem.swift; sourceTree = ""; }; + DB3667A3268AE2370027D07F /* ComposeStatusPollTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollTableViewCell.swift; sourceTree = ""; }; + DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollSection.swift; sourceTree = ""; }; + DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollItem.swift; sourceTree = ""; }; + DB3667C9268B14A80027D07F /* ReplicaStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplicaStatusView.swift; sourceTree = ""; }; DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = ""; }; DB3D100E25BAA75E00EAA174 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; DB427DD225BAA00100D1B89D /* Mastodon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mastodon.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -949,8 +967,6 @@ DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomScheduler.swift; sourceTree = ""; }; DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; }; DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; - DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = ""; }; - DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentCollectionViewCell.swift; sourceTree = ""; }; DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewModel.swift; sourceTree = ""; }; DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = ""; }; DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = ""; }; @@ -1120,6 +1136,7 @@ DBAEDE5F267A0B1500D25FF5 /* Nuke in Frameworks */, DB6F5E32264E7410009108F4 /* TwitterTextEditor in Frameworks */, DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, + DB03F7ED268976B5007B274C /* MetaTextView in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */, @@ -1138,6 +1155,7 @@ 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */, 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */, DB6804C82637CE2F00430867 /* AppShared.framework in Frameworks */, + DB03F7EB268976B5007B274C /* MastodonMeta in Frameworks */, DBF7A0FC26830C33004176A2 /* FPSIndicator in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1519,6 +1537,8 @@ 2D35237926256D920031AF25 /* NotificationSection.swift */, 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, + DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */, + DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */, DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, DB6D9F7C26358ED4008423CD /* SettingsSection.swift */, 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */, @@ -1583,6 +1603,8 @@ DB1E347725F519300079D7DF /* PickServerItem.swift */, DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */, + DB3667A0268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift */, + DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */, DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */, DB6D9F8326358EEC008423CD /* SettingsItem.swift */, DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */, @@ -1732,6 +1754,17 @@ path = Status; sourceTree = ""; }; + DB03F7F1268990A2007B274C /* TableViewCell */ = { + isa = PBXGroup; + children = ( + DB03F7F22689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift */, + DB03F7EF26899097007B274C /* ComposeStatusContentTableViewCell.swift */, + DB36679C268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift */, + DB3667A3268AE2370027D07F /* ComposeStatusPollTableViewCell.swift */, + ); + path = TableViewCell; + sourceTree = ""; + }; DB084B5125CBC56300F898ED /* CoreDataStack */ = { isa = PBXGroup; children = ( @@ -1940,7 +1973,8 @@ DB55D32225FB4D320002F825 /* View */ = { isa = PBXGroup; children = ( - DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */, + DB3667C9268B14A80027D07F /* ReplicaStatusView.swift */, + DB03F7F42689B782007B274C /* ComposeTableView.swift */, DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */, DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */, DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */, @@ -2082,6 +2116,7 @@ DB6F5E36264E78EA009108F4 /* AutoComplete */, DB55D32225FB4D320002F825 /* View */, DB789A2125F9F76D0071ACA0 /* CollectionViewCell */, + DB03F7F1268990A2007B274C /* TableViewCell */, DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */, DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */, @@ -2093,8 +2128,6 @@ DB789A2125F9F76D0071ACA0 /* CollectionViewCell */ = { isa = PBXGroup; children = ( - DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */, - DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */, DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */, DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */, DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */, @@ -2653,6 +2686,8 @@ DBAC64A0267E6D02007FE9FD /* Fuzi */, DBF7A0FB26830C33004176A2 /* FPSIndicator */, DB0E2D2D26833FF700865C3C /* NukeFLAnimatedImagePlugin */, + DB03F7EA268976B5007B274C /* MastodonMeta */, + DB03F7EC268976B5007B274C /* MetaTextView */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -2846,6 +2881,7 @@ DBAC649F267E6D01007FE9FD /* XCRemoteSwiftPackageReference "Fuzi" */, DBF7A0FA26830C33004176A2 /* XCRemoteSwiftPackageReference "FPSIndicator" */, DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */, + DB03F7E9268976B5007B274C /* XCRemoteSwiftPackageReference "MetaTextView" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -3193,6 +3229,7 @@ DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */, 2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */, + DB3667A8268AE2900027D07F /* ComposeStatusPollItem.swift in Sources */, DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */, @@ -3216,8 +3253,10 @@ DB98338825C945ED00AD9700 /* Assets.swift in Sources */, DB6180E926391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift in Sources */, DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */, + DB36679F268ABAF20027D07F /* ComposeStatusAttachmentSection.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */, + DB03F7F32689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift in Sources */, 2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */, DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, @@ -3304,17 +3343,18 @@ 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */, DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */, + DB3667A6268AE2620027D07F /* ComposeStatusPollSection.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, 2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */, DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */, - DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, 2D084B8D26258EA3003AA3AF /* NotificationViewModel+Diffable.swift in Sources */, DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */, + DB3667A1268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, DBA94434265CBB5300C537E1 /* ProfileFieldSection.swift in Sources */, DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */, @@ -3325,6 +3365,7 @@ DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, + DB03F7F52689B782007B274C /* ComposeTableView.swift in Sources */, 2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */, DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, @@ -3400,6 +3441,7 @@ DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, + DB36679D268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift in Sources */, DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, DBCBCC032680AF6E000F5B51 /* AsyncHomeTimelineViewController+DebugAction.swift in Sources */, DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, @@ -3440,8 +3482,8 @@ DB1D842E26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift in Sources */, DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */, 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */, - DB040ECD26526EA600BEE9D8 /* ComposeCollectionView.swift in Sources */, DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */, + DB3667CA268B14A80027D07F /* ReplicaStatusView.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */, DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */, @@ -3468,9 +3510,11 @@ DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */, DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */, + DB3667A4268AE2370027D07F /* ComposeStatusPollTableViewCell.swift in Sources */, DBBF1DC226524D2900E5B703 /* AutoCompleteTableViewCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, DB1EE7B2267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift in Sources */, + DB03F7F026899097007B274C /* ComposeStatusContentTableViewCell.swift in Sources */, 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */, 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */, DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */, @@ -3489,7 +3533,6 @@ DBAFB7352645463500371D5F /* Emojis.swift in Sources */, DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, - DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */, 5BB04FD5262E7AFF0043BFF6 /* ReportViewController.swift in Sources */, DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */, @@ -3800,7 +3843,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -3808,7 +3851,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.7.6; + MARKETING_VERSION = 0.7.7; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3827,7 +3870,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -3835,7 +3878,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.7.6; + MARKETING_VERSION = 0.7.7; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -4155,7 +4198,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -4163,7 +4206,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.7.6; + MARKETING_VERSION = 0.7.7; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -4269,7 +4312,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -4277,7 +4320,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 0.7.6; + MARKETING_VERSION = 0.7.7; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -4388,7 +4431,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = Mastodon/Info.plist; @@ -4396,7 +4439,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.7.6; + MARKETING_VERSION = 0.7.7; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -4502,7 +4545,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -4510,7 +4553,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 0.7.6; + MARKETING_VERSION = 0.7.7; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -4556,7 +4599,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -4564,7 +4607,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 0.7.6; + MARKETING_VERSION = 0.7.7; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -4579,7 +4622,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = 5Z4GVSS33P; INFOPLIST_FILE = NotificationService/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -4587,7 +4630,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 0.7.6; + MARKETING_VERSION = 0.7.7; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -4730,6 +4773,14 @@ minimumVersion = 0.1.1; }; }; + DB03F7E9268976B5007B274C /* XCRemoteSwiftPackageReference "MetaTextView" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/TwidereProject/MetaTextView.git"; + requirement = { + kind = exactVersion; + version = 1.2.3; + }; + }; DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/kean/Nuke-FLAnimatedImage-Plugin.git"; @@ -4863,6 +4914,16 @@ package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; productName = CommonOSLog; }; + DB03F7EA268976B5007B274C /* MastodonMeta */ = { + isa = XCSwiftPackageProductDependency; + package = DB03F7E9268976B5007B274C /* XCRemoteSwiftPackageReference "MetaTextView" */; + productName = MastodonMeta; + }; + DB03F7EC268976B5007B274C /* MetaTextView */ = { + isa = XCSwiftPackageProductDependency; + package = DB03F7E9268976B5007B274C /* XCRemoteSwiftPackageReference "MetaTextView" */; + productName = MetaTextView; + }; DB0E2D2D26833FF700865C3C /* NukeFLAnimatedImagePlugin */ = { isa = XCSwiftPackageProductDependency; package = DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */; diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index f1135b12e..ebbde8329 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 20 + 21 Mastodon - ASDK.xcscheme_^#shared#^_ @@ -37,7 +37,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 21 + 22 SuppressBuildableAutocreation diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index bf58fb3ce..8414b940a 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -109,6 +109,15 @@ "version": "6.2.1" } }, + { + "package": "MetaTextView", + "repositoryURL": "https://github.com/TwidereProject/MetaTextView.git", + "state": { + "branch": null, + "revision": "5b86b386464be8a6da5383aa714c458c07da6c01", + "version": "1.2.3" + } + }, { "package": "Nuke", "repositoryURL": "https://github.com/kean/Nuke.git", diff --git a/Mastodon/Diffiable/Item/ComposeStatusAttachmentItem.swift b/Mastodon/Diffiable/Item/ComposeStatusAttachmentItem.swift new file mode 100644 index 000000000..834e1da49 --- /dev/null +++ b/Mastodon/Diffiable/Item/ComposeStatusAttachmentItem.swift @@ -0,0 +1,14 @@ +// +// ComposeStatusAttachmentItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-29. +// + +import Foundation + +enum ComposeStatusAttachmentItem { + case attachment(attachmentService: MastodonAttachmentService) +} + +extension ComposeStatusAttachmentItem: Hashable { } diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift index d60a76e82..7c916b166 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -13,14 +13,10 @@ import CoreData enum ComposeStatusItem { case replyTo(statusObjectID: NSManagedObjectID) case input(replyToStatusObjectID: NSManagedObjectID?, attribute: ComposeStatusAttribute) - case attachment(attachmentService: MastodonAttachmentService) - case pollOption(attribute: ComposePollOptionAttribute) - case pollOptionAppendEntry - case pollExpiresOption(attribute: ComposePollExpiresOptionAttribute) + case attachment(attachmentAttribute: ComposeStatusAttachmentAttribute) + case pollOption(pollOptionAttributes: [ComposeStatusPollItem.PollOptionAttribute], pollExpiresOptionAttribute: ComposeStatusPollItem.PollExpiresOptionAttribute) } -extension ComposeStatusItem: Equatable { } - extension ComposeStatusItem: Hashable { } extension ComposeStatusItem { @@ -29,6 +25,7 @@ extension ComposeStatusItem { let avatarURL = CurrentValueSubject(nil) let displayName = CurrentValueSubject(nil) + let emojiDict = CurrentValueSubject([:]) let username = CurrentValueSubject(nil) let composeContent = CurrentValueSubject(nil) @@ -38,6 +35,7 @@ extension ComposeStatusItem { static func == (lhs: ComposeStatusAttribute, rhs: ComposeStatusAttribute) -> Bool { return lhs.avatarURL.value == rhs.avatarURL.value && lhs.displayName.value == rhs.displayName.value && + lhs.emojiDict.value == rhs.emojiDict.value && lhs.username.value == rhs.username.value && lhs.composeContent.value == rhs.composeContent.value && lhs.isContentWarningComposing.value == rhs.isContentWarningComposing.value && @@ -50,88 +48,22 @@ extension ComposeStatusItem { } } -protocol ComposePollAttributeDelegate: AnyObject { - func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?) -} - extension ComposeStatusItem { - final class ComposePollOptionAttribute: Equatable, Hashable { + final class ComposeStatusAttachmentAttribute: Hashable { private let id = UUID() - - var disposeBag = Set() - weak var delegate: ComposePollAttributeDelegate? - let option = CurrentValueSubject("") - - init() { - option - .sink { [weak self] option in - guard let self = self else { return } - self.delegate?.composePollAttribute(self, pollOptionDidChange: option) - } - .store(in: &disposeBag) + var attachmentServices: [MastodonAttachmentService] + + init(attachmentServices: [MastodonAttachmentService]) { + self.attachmentServices = attachmentServices } - - deinit { - disposeBag.removeAll() + + static func == (lhs: ComposeStatusAttachmentAttribute, rhs: ComposeStatusAttachmentAttribute) -> Bool { + return lhs.attachmentServices == rhs.attachmentServices } - - static func == (lhs: ComposePollOptionAttribute, rhs: ComposePollOptionAttribute) -> Bool { - return lhs.id == rhs.id && - lhs.option.value == rhs.option.value - } - + func hash(into hasher: inout Hasher) { hasher.combine(id) } } } - -extension ComposeStatusItem { - final class ComposePollExpiresOptionAttribute: Equatable, Hashable { - private let id = UUID() - - let expiresOption = CurrentValueSubject(.thirtyMinutes) - - - static func == (lhs: ComposePollExpiresOptionAttribute, rhs: ComposePollExpiresOptionAttribute) -> Bool { - return lhs.id == rhs.id && - lhs.expiresOption.value == rhs.expiresOption.value - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - - enum ExpiresOption: Equatable, Hashable, CaseIterable { - case thirtyMinutes - case oneHour - case sixHours - case oneDay - case threeDays - case sevenDays - - var title: String { - switch self { - case .thirtyMinutes: return L10n.Scene.Compose.Poll.thirtyMinutes - case .oneHour: return L10n.Scene.Compose.Poll.oneHour - case .sixHours: return L10n.Scene.Compose.Poll.sixHours - case .oneDay: return L10n.Scene.Compose.Poll.oneDay - case .threeDays: return L10n.Scene.Compose.Poll.threeDays - case .sevenDays: return L10n.Scene.Compose.Poll.sevenDays - } - } - - var seconds: Int { - switch self { - case .thirtyMinutes: return 60 * 30 - case .oneHour: return 60 * 60 * 1 - case .sixHours: return 60 * 60 * 6 - case .oneDay: return 60 * 60 * 24 - case .threeDays: return 60 * 60 * 24 * 3 - case .sevenDays: return 60 * 60 * 24 * 7 - } - } - } - } -} diff --git a/Mastodon/Diffiable/Item/ComposeStatusPollItem.swift b/Mastodon/Diffiable/Item/ComposeStatusPollItem.swift new file mode 100644 index 000000000..a6d9a36e8 --- /dev/null +++ b/Mastodon/Diffiable/Item/ComposeStatusPollItem.swift @@ -0,0 +1,105 @@ +// +// ComposeStatusPollItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-29. +// + +import Foundation +import Combine + +enum ComposeStatusPollItem { + case pollOption(attribute: PollOptionAttribute) + case pollOptionAppendEntry + case pollExpiresOption(attribute: PollExpiresOptionAttribute) +} + +extension ComposeStatusPollItem: Hashable { } + +extension ComposeStatusPollItem { + + final class PollOptionAttribute: Equatable, Hashable { + private let id = UUID() + + var disposeBag = Set() + weak var delegate: ComposePollAttributeDelegate? + + let option = CurrentValueSubject("") + + init() { + option + .sink { [weak self] option in + guard let self = self else { return } + self.delegate?.composePollAttribute(self, pollOptionDidChange: option) + } + .store(in: &disposeBag) + } + + deinit { + disposeBag.removeAll() + } + + static func == (lhs: PollOptionAttribute, rhs: PollOptionAttribute) -> Bool { + return lhs.id == rhs.id && + lhs.option.value == rhs.option.value + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } + +} + +protocol ComposePollAttributeDelegate: AnyObject { + func composePollAttribute(_ attribute: ComposeStatusPollItem.PollOptionAttribute, pollOptionDidChange: String?) +} + +extension ComposeStatusPollItem { + final class PollExpiresOptionAttribute: Equatable, Hashable { + private let id = UUID() + + let expiresOption = CurrentValueSubject(.thirtyMinutes) + + + static func == (lhs: PollExpiresOptionAttribute, rhs: PollExpiresOptionAttribute) -> Bool { + return lhs.id == rhs.id && + lhs.expiresOption.value == rhs.expiresOption.value + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + enum ExpiresOption: Equatable, Hashable, CaseIterable { + case thirtyMinutes + case oneHour + case sixHours + case oneDay + case threeDays + case sevenDays + + var title: String { + switch self { + case .thirtyMinutes: return L10n.Scene.Compose.Poll.thirtyMinutes + case .oneHour: return L10n.Scene.Compose.Poll.oneHour + case .sixHours: return L10n.Scene.Compose.Poll.sixHours + case .oneDay: return L10n.Scene.Compose.Poll.oneDay + case .threeDays: return L10n.Scene.Compose.Poll.threeDays + case .sevenDays: return L10n.Scene.Compose.Poll.sevenDays + } + } + + var seconds: Int { + switch self { + case .thirtyMinutes: return 60 * 30 + case .oneHour: return 60 * 60 * 1 + case .sixHours: return 60 * 60 * 6 + case .oneDay: return 60 * 60 * 24 + case .threeDays: return 60 * 60 * 24 * 3 + case .sevenDays: return 60 * 60 * 24 * 7 + } + } + } + } +} diff --git a/Mastodon/Diffiable/Section/ComposeStatusAttachmentSection.swift b/Mastodon/Diffiable/Section/ComposeStatusAttachmentSection.swift new file mode 100644 index 000000000..4de7653a5 --- /dev/null +++ b/Mastodon/Diffiable/Section/ComposeStatusAttachmentSection.swift @@ -0,0 +1,13 @@ +// +// ComposeStatusAttachmentSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-29. +// + +import Foundation + +enum ComposeStatusAttachmentSection: Hashable { + case main +} + diff --git a/Mastodon/Diffiable/Section/ComposeStatusPollSection.swift b/Mastodon/Diffiable/Section/ComposeStatusPollSection.swift new file mode 100644 index 000000000..cd06572dc --- /dev/null +++ b/Mastodon/Diffiable/Section/ComposeStatusPollSection.swift @@ -0,0 +1,12 @@ +// +// ComposeStatusPollSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-29. +// + +import Foundation + +enum ComposeStatusPollSection: Hashable { + case main +} diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 836d91e73..6a9dd2b3b 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -9,7 +9,8 @@ import UIKit import Combine import CoreData import CoreDataStack -import TwitterTextEditor +import MetaTextView +import MastodonMeta import AlamofireImage enum ComposeStatusSection: Equatable, Hashable { @@ -29,232 +30,9 @@ extension ComposeStatusSection { } extension ComposeStatusSection { - static func collectionViewDiffableDataSource( - for collectionView: UICollectionView, - dependency: NeedsDependency, - managedObjectContext: NSManagedObjectContext, - composeKind: ComposeKind, - repliedToCellFrameSubscriber: CurrentValueSubject, - customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel, - textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, - textEditorViewChangeObserver: TextEditorViewChangeObserver, - composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, - composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, - composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, - composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate - ) -> UICollectionViewDiffableDataSource { - UICollectionViewDiffableDataSource(collectionView: collectionView) { [ - weak customEmojiPickerInputViewModel, - weak textEditorViewTextAttributesDelegate, - weak textEditorViewChangeObserver, - weak composeStatusAttachmentTableViewCellDelegate, - weak composeStatusPollOptionCollectionViewCellDelegate, - weak composeStatusNewPollOptionCollectionViewCellDelegate, - weak composeStatusPollExpiresOptionCollectionViewCellDelegate - ] collectionView, indexPath, item -> UICollectionViewCell? in - switch item { - case .replyTo(let replyToStatusObjectID): - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentCollectionViewCell - // set empty text before retrieve real data to fix pseudo-text display issue - cell.statusView.nameLabel.text = " " - cell.statusView.usernameLabel.text = " " - managedObjectContext.performAndWait { - guard let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else { - return - } - let status = replyTo.reblog ?? replyTo - - // set avatar - cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL())) - // set name username - cell.statusView.nameLabel.text = { - let author = status.author - return author.displayName.isEmpty ? author.username : author.displayName - }() - cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct - // set text - //status.emoji - cell.statusView.activeTextLabel.configure(content: status.content, emojiDict: [:]) - // set date - cell.statusView.dateLabel.text = status.createdAt.slowedTimeAgoSinceNow - - cell.framePublisher.assign(to: \.value, on: repliedToCellFrameSubscriber).store(in: &cell.disposeBag) - } - return cell - case .input(let replyToStatusObjectID, let attribute): - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell - cell.statusContentWarningEditorView.textView.text = attribute.contentWarningContent.value - cell.textEditorView.text = attribute.composeContent.value ?? "" - managedObjectContext.performAndWait { - guard let replyToStatusObjectID = replyToStatusObjectID, - let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else { - cell.statusView.headerContainerView.isHidden = true - return - } - cell.statusView.headerContainerView.isHidden = false - cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) - cell.statusView.headerInfoLabel.text = L10n.Scene.Compose.replyingToUser(replyTo.author.displayNameWithFallback) - } - ComposeStatusSection.configureStatusContent(cell: cell, attribute: attribute) - cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate - cell.textEditorViewChangeObserver = textEditorViewChangeObserver // relay - cell.composeContent - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak collectionView] text in - guard let collectionView = collectionView else { return } - // self size input cell - // needs restore content offset to resolve issue #83 - let oldContentOffset = collectionView.contentOffset - collectionView.collectionViewLayout.invalidateLayout() - collectionView.layoutIfNeeded() - collectionView.contentOffset = oldContentOffset - // bind input data - attribute.composeContent.value = text - } - .store(in: &cell.disposeBag) - attribute.isContentWarningComposing - .receive(on: DispatchQueue.main) - .sink { [weak cell, weak collectionView] isContentWarningComposing in - guard let cell = cell else { return } - guard let collectionView = collectionView else { return } - // self size input cell - collectionView.collectionViewLayout.invalidateLayout() - cell.statusContentWarningEditorView.containerView.isHidden = !isContentWarningComposing - cell.statusContentWarningEditorView.alpha = 0 - UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) { - cell.statusContentWarningEditorView.alpha = 1 - } completion: { _ in - // do nothing - } - } - .store(in: &cell.disposeBag) - cell.contentWarningContent - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak collectionView] text in - guard let collectionView = collectionView else { return } - // self size input cell - collectionView.collectionViewLayout.invalidateLayout() - // bind input data - attribute.contentWarningContent.value = text - } - .store(in: &cell.disposeBag) - ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.textEditorView, disposeBag: &cell.disposeBag) - ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.statusContentWarningEditorView.textView, disposeBag: &cell.disposeBag) - - return cell - case .attachment(let attachmentService): - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self), for: indexPath) as! ComposeStatusAttachmentCollectionViewCell - cell.attachmentContainerView.descriptionTextView.text = attachmentService.description.value - cell.delegate = composeStatusAttachmentTableViewCellDelegate - attachmentService.thumbnailImage - .receive(on: DispatchQueue.main) - .sink { [weak cell] thumbnailImage in - guard let cell = cell else { return } - let size = cell.attachmentContainerView.previewImageView.frame.size != .zero ? cell.attachmentContainerView.previewImageView.frame.size : CGSize(width: 1, height: 1) - guard let image = thumbnailImage else { - let placeholder = UIImage.placeholder( - size: size, - color: Asset.Colors.Background.systemGroupedBackground.color - ) - .af.imageRounded( - withCornerRadius: AttachmentContainerView.containerViewCornerRadius - ) - cell.attachmentContainerView.previewImageView.image = placeholder - return - } - cell.attachmentContainerView.previewImageView.image = image - .af.imageAspectScaled(toFill: size) - .af.imageRounded(withCornerRadius: AttachmentContainerView.containerViewCornerRadius) - } - .store(in: &cell.disposeBag) - Publishers.CombineLatest( - attachmentService.uploadStateMachineSubject.eraseToAnyPublisher(), - attachmentService.error.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak cell, weak attachmentService] uploadState, error in - guard let cell = cell else { return } - guard let attachmentService = attachmentService else { return } - cell.attachmentContainerView.emptyStateView.isHidden = error == nil - cell.attachmentContainerView.descriptionBackgroundView.isHidden = error != nil - if let error = error { - cell.attachmentContainerView.activityIndicatorView.stopAnimating() - cell.attachmentContainerView.emptyStateView.label.text = error.localizedDescription - } else { - guard let uploadState = uploadState else { return } - switch uploadState { - case is MastodonAttachmentService.UploadState.Finish, - is MastodonAttachmentService.UploadState.Fail: - cell.attachmentContainerView.activityIndicatorView.stopAnimating() - cell.attachmentContainerView.emptyStateView.label.text = { - if let file = attachmentService.file.value { - switch file { - case .jpeg, .png, .gif: - return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo) - case .other: - return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video) - } - } else { - return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo) - } - }() - default: - break - } - } - } - .store(in: &cell.disposeBag) - NotificationCenter.default.publisher( - for: UITextView.textDidChangeNotification, - object: cell.attachmentContainerView.descriptionTextView - ) - .receive(on: DispatchQueue.main) - .sink { notification in - guard let textField = notification.object as? UITextView else { return } - let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) - attachmentService.description.value = text - } - .store(in: &cell.disposeBag) - return cell - case .pollOption(let attribute): - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionCollectionViewCell - cell.pollOptionView.optionTextField.text = attribute.option.value - cell.pollOptionView.optionTextField.placeholder = L10n.Scene.Compose.Poll.optionNumber(indexPath.item + 1) - cell.pollOption - .receive(on: DispatchQueue.main) - .assign(to: \.value, on: attribute.option) - .store(in: &cell.disposeBag) - cell.delegate = composeStatusPollOptionCollectionViewCellDelegate - ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.pollOptionView.optionTextField, disposeBag: &cell.disposeBag) - return cell - case .pollOptionAppendEntry: - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionAppendEntryCollectionViewCell - cell.delegate = composeStatusNewPollOptionCollectionViewCellDelegate - return cell - case .pollExpiresOption(let attribute): - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollExpiresOptionCollectionViewCell - cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(attribute.expiresOption.value.title), for: .normal) - attribute.expiresOption - .receive(on: DispatchQueue.main) - .sink { [weak cell] expiresOption in - guard let cell = cell else { return } - cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(expiresOption.title), for: .normal) - } - .store(in: &cell.disposeBag) - cell.delegate = composeStatusPollExpiresOptionCollectionViewCellDelegate - return cell - } - } - } -} - -extension ComposeStatusSection { - static func configureStatusContent( - cell: ComposeStatusContentCollectionViewCell, + cell: ComposeStatusContentTableViewCell, attribute: ComposeStatusItem.ComposeStatusAttribute ) { // set avatar @@ -265,34 +43,23 @@ extension ComposeStatusSection { } .store(in: &cell.disposeBag) // set display name and username - Publishers.CombineLatest( - attribute.displayName.eraseToAnyPublisher(), + Publishers.CombineLatest3( + attribute.displayName, + attribute.emojiDict, attribute.username.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) - .sink { displayName, username in - cell.statusView.nameLabel.text = displayName + .sink { displayName, emojiDict, username in + cell.statusView.nameLabel.configure(content: displayName ?? " ", emojiDict: emojiDict) cell.statusView.usernameLabel.text = username.flatMap { "@" + $0 } ?? " " } .store(in: &cell.disposeBag) - - // bind compose content - cell.composeContent - .map { $0 as String? } - .assign(to: \.value, on: attribute.composeContent) - .store(in: &cell.disposeBag) } } -protocol CustomEmojiReplaceableTextInput: AnyObject { +protocol CustomEmojiReplaceableTextInput: UITextInput & UIResponder { var inputView: UIView? { get set } - func reloadInputViews() - - // UIKeyInput - func insertText(_ text: String) - // UIResponder - var isFirstResponder: Bool { get } } class CustomEmojiReplaceableTextInputReference { @@ -303,16 +70,6 @@ class CustomEmojiReplaceableTextInputReference { } } -extension TextEditorView: CustomEmojiReplaceableTextInput { - func insertText(_ text: String) { - try? updateByReplacing(range: selectedRange, with: text, selectedRange: nil) - } - - public override var isFirstResponder: Bool { - return isEditing - } - -} extension UITextField: CustomEmojiReplaceableTextInput { } extension UITextView: CustomEmojiReplaceableTextInput { } diff --git a/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift b/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift index 20dc5b809..57d7b6019 100644 --- a/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift +++ b/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift @@ -6,7 +6,7 @@ // import UIKit -import Kingfisher +import Nuke enum CustomEmojiPickerSection: Equatable, Hashable { case emoji(name: String) @@ -24,13 +24,13 @@ extension CustomEmojiPickerSection { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell let placeholder = UIImage.placeholder(size: CustomEmojiPickerItemCollectionViewCell.itemSize, color: .systemFill) .af.imageRounded(withCornerRadius: 4) - cell.emojiImageView.kf.setImage( - with: URL(string: attribute.emoji.url), - placeholder: placeholder, - options: [ - .transition(.fade(0.2)) - ], - completionHandler: nil + cell.imageTask = Nuke.loadImage( + with: attribute.emoji.url, + options: .init( + placeholder: placeholder, + transition: .fadeIn(duration: 0.2) + ), + into: cell.emojiImageView ) cell.accessibilityLabel = attribute.emoji.shortcode return cell @@ -48,7 +48,7 @@ extension CustomEmojiPickerSection { let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), for: indexPath) as! CustomEmojiPickerHeaderCollectionReusableView switch section { case .emoji(let name): - header.titlelabel.text = name + header.titleLabel.text = name } return header default: diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 43bc791c0..ce9f07ae0 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -12,7 +12,9 @@ import os.log import UIKit import AVKit import Nuke -import LinkPresentation +import MastodonMeta + +// import LinkPresentation #if ASDK import AsyncDisplayKit @@ -138,12 +140,15 @@ extension StatusSection { cell.delegate = statusTableViewCellDelegate switch item { case .root: - cell.statusView.activeTextLabel.isAccessibilityElement = false + // enable selection only for root + cell.statusView.contentMetaText.textView.isSelectable = true + cell.statusView.contentMetaText.textView.isAccessibilityElement = false var accessibilityElements: [Any] = [] accessibilityElements.append(cell.statusView.avatarView) accessibilityElements.append(cell.statusView.nameLabel) accessibilityElements.append(cell.statusView.dateLabel) - accessibilityElements.append(contentsOf: cell.statusView.activeTextLabel.createAccessibilityElements()) + // TODO: a11y + accessibilityElements.append(cell.statusView.contentMetaText.textView) accessibilityElements.append(contentsOf: cell.statusView.statusMosaicImageViewContainer.imageViews) accessibilityElements.append(cell.statusView.playerContainerView) accessibilityElements.append(cell.statusView.actionToolbarContainer) @@ -554,16 +559,23 @@ extension StatusSection { statusItemAttribute: Item.StatusAttribute ) { // set content - cell.statusView.activeTextLabel.configure( - content: (status.reblog ?? status).content, - emojiDict: (status.reblog ?? status).emojiDict - ) - cell.statusView.activeTextLabel.accessibilityLanguage = (status.reblog ?? status).language + do { + let content = MastodonContent( + content: (status.reblog ?? status).content, + emojis: (status.reblog ?? status).emojiMeta + ) + let metaContent = try MastodonMetaContent.convert(document: content) + cell.statusView.contentMetaText.configure(content: metaContent) + } catch { + cell.statusView.contentMetaText.textView.text = " " + assertionFailure() + } + + cell.statusView.contentMetaText.textView.accessibilityLanguage = (status.reblog ?? status).language // set visibility - if let visibility = (status.reblog ?? status).visibility { + if let visibility = (status.reblog ?? status).visibilityEnum { cell.statusView.updateVisibility(visibility: visibility) - cell.statusView.revealContentWarningButton.publisher(for: \.isHidden) .receive(on: DispatchQueue.main) .sink { [weak cell] isHidden in @@ -940,6 +952,18 @@ extension StatusSection { guard status.reblogsCount.intValue > 0 else { return nil } return L10n.Common.Controls.Timeline.Accessibility.countReblogs(status.reblogsCount.intValue) }() + + // disable reblog if needs (except self) + cell.statusView.actionToolbarContainer.reblogButton.isEnabled = true + if let visibility = status.visibilityEnum, status.author.id != requestUserID { + switch visibility { + case .public, .unlisted: + break + default: + cell.statusView.actionToolbarContainer.reblogButton.isEnabled = false + } + } + // set like let isLike = status.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false let favoriteCountTitle: String = { diff --git a/Mastodon/Extension/CoreDataStack/Emojis.swift b/Mastodon/Extension/CoreDataStack/Emojis.swift index a35e2630e..8d7c29753 100644 --- a/Mastodon/Extension/CoreDataStack/Emojis.swift +++ b/Mastodon/Extension/CoreDataStack/Emojis.swift @@ -7,6 +7,7 @@ import Foundation import MastodonSDK +import MastodonMeta protocol EmojiContainer { var emojisData: Data? { get } @@ -31,6 +32,14 @@ extension EmojiContainer { } return dict } + + var emojiMeta: MastodonContent.Emojis { + var dict = MastodonContent.Emojis() + for emoji in emojis ?? [] { + dict[emoji.shortcode] = emoji.url + } + return dict + } } diff --git a/Mastodon/Extension/CoreDataStack/Status.swift b/Mastodon/Extension/CoreDataStack/Status.swift index d8e1b9307..7b64e6a9d 100644 --- a/Mastodon/Extension/CoreDataStack/Status.swift +++ b/Mastodon/Extension/CoreDataStack/Status.swift @@ -89,3 +89,10 @@ extension Status { } extension Status: EmojiContainer { } + + +extension Status { + var visibilityEnum: Mastodon.Entity.Status.Visibility? { + return visibility.flatMap { Mastodon.Entity.Status.Visibility(rawValue: $0) } + } +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 60d61ecdb..2803113dd 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -12,6 +12,8 @@ import CoreData import CoreDataStack import MastodonSDK import ActiveLabel +import Meta +import MetaTextView // MARK: - StatusViewDelegate extension StatusTableViewCellDelegate where Self: StatusProvider { @@ -27,6 +29,10 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { StatusProviderFacade.responseToStatusActiveLabelAction(provider: self, cell: cell, activeLabel: activeLabel, didTapEntity: entity) } + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { + StatusProviderFacade.responseToStatusMetaTextAction(provider: self, cell: cell, metaText: metaText, didSelectMeta: meta) + } func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) { StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 3122de952..e6711bb1a 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -12,6 +12,8 @@ import CoreData import CoreDataStack import MastodonSDK import ActiveLabel +import Meta +import MetaTextView #if ASDK import AsyncDisplayKit @@ -149,6 +151,31 @@ extension StatusProviderFacade { } } + static func responseToStatusMetaTextAction(provider: StatusProvider, cell: UITableViewCell, metaText: MetaText, didSelectMeta meta: Meta) { + switch meta { + case .url(_, _, let url, _): + guard let url = URL(string: url) else { return } + if let domain = provider.context.authenticationService.activeMastodonAuthenticationBox.value?.domain, url.host == domain, + url.pathComponents.count >= 4, + url.pathComponents[0] == "/", + url.pathComponents[1] == "web", + url.pathComponents[2] == "statuses" { + let statusID = url.pathComponents[3] + let threadViewModel = RemoteThreadViewModel(context: provider.context, statusID: statusID) + provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show) + } else { + provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) + } + case .hashtag(_, let hashtag, _): + let hashtagTimelineViewModel = HashtagTimelineViewModel(context: provider.context, hashtag: hashtag) + provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: provider, transition: .show) + case .mention(_, let mention, _): + coordinateToStatusMentionProfileScene(for: .primary, provider: provider, cell: cell, mention: mention) + default: + break + } + } + #if ASDK static func responseToStatusActiveLabelAction(provider: StatusProvider, node: ASCellNode, didSelectActiveEntityType type: ActiveEntityType) { switch type { diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index b5e381f1b..79185338a 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -29,6 +29,23 @@ extension UserProviderFacade { mastodonUser: provider.mastodonUser().eraseToAnyPublisher() ) } + + static func toggleUserFollowRelationship( + provider: UserProvider, + mastodonUser: MastodonUser + ) -> AnyPublisher, Error> { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() + } + + return _toggleUserFollowRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: Just(mastodonUser).eraseToAnyPublisher() + ) + } private static func _toggleUserFollowRelationship( context: AppContext, @@ -52,6 +69,22 @@ extension UserProviderFacade { } extension UserProviderFacade { + static func toggleUserBlockRelationship( + provider: UserProvider, + mastodonUser: MastodonUser + ) -> AnyPublisher, Error> { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() + } + return _toggleUserBlockRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: Just(mastodonUser).eraseToAnyPublisher() + ) + } + static func toggleUserBlockRelationship( provider: UserProvider, cell: UITableViewCell? @@ -98,6 +131,23 @@ extension UserProviderFacade { } extension UserProviderFacade { + + static func toggleUserMuteRelationship( + provider: UserProvider, + mastodonUser: MastodonUser + ) -> AnyPublisher, Error> { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() + } + return _toggleUserMuteRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: Just(mastodonUser).eraseToAnyPublisher() + ) + } + static func toggleUserMuteRelationship( provider: UserProvider, cell: UITableViewCell? diff --git a/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewController.swift b/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewController.swift index c98f84071..56832b9bf 100644 --- a/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewController.swift +++ b/Mastodon/Scene/Compose/AutoComplete/AutoCompleteViewController.swift @@ -38,7 +38,9 @@ final class AutoCompleteViewController: UIViewController { tableView.backgroundColor = .clear tableView.contentInset.top = AutoCompleteViewController.chevronViewHeight tableView.verticalScrollIndicatorInsets.top = AutoCompleteViewController.chevronViewHeight - tableView.showsVerticalScrollIndicator = false // avoid duplicate to the compose collection view indicator + tableView.showsVerticalScrollIndicator = false // avoid duplicate to the compose collection view indicator + tableView.preservesSuperviewLayoutMargins = false + tableView.cellLayoutMarginsFollowReadableWidth = false return tableView }() @@ -50,6 +52,9 @@ extension AutoCompleteViewController { super.viewDidLoad() view.backgroundColor = .clear + + // we hack the view hierarchy. Do not preserve from superview + view.preservesSuperviewLayoutMargins = false chevronView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(chevronView) diff --git a/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift b/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift index a13e82f31..f629177dd 100644 --- a/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift +++ b/Mastodon/Scene/Compose/AutoComplete/Cell/AutoCompleteTableViewCell.swift @@ -97,8 +97,8 @@ extension AutoCompleteTableViewCell { contentView.addSubview(containerStackView) NSLayoutConstraint.activate([ containerStackView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor), - containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + containerStackView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), ]) avatarImageView.translatesAutoresizingMaskIntoConstraints = false diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift deleted file mode 100644 index bb725e9b8..000000000 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift +++ /dev/null @@ -1,143 +0,0 @@ -// -// ComposeStatusContentCollectionViewCell.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-11. -// - -import os.log -import UIKit -import Combine -import TwitterTextEditor - -final class ComposeStatusContentCollectionViewCell: UICollectionViewCell { - - var disposeBag = Set() - - let statusView = StatusView() - - let statusContentWarningEditorView = StatusContentWarningEditorView() - - let textEditorViewContainerView = UIView() - let textEditorView: TextEditorView = { - let textEditorView = TextEditorView() - textEditorView.font = .preferredFont(forTextStyle: .body) - textEditorView.scrollView.isScrollEnabled = false - textEditorView.isScrollEnabled = false - textEditorView.placeholderText = L10n.Scene.Compose.contentInputPlaceholder - textEditorView.keyboardType = .twitter - return textEditorView - }() - - // input - weak var textEditorViewChangeObserver: TextEditorViewChangeObserver? - - // output - let composeContent = PassthroughSubject() - let contentWarningContent = PassthroughSubject() - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension ComposeStatusContentCollectionViewCell { - - private func _init() { - // selectionStyle = .none - layer.zPosition = 999 - preservesSuperviewLayoutMargins = true - - statusContentWarningEditorView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(statusContentWarningEditorView) - NSLayoutConstraint.activate([ - statusContentWarningEditorView.topAnchor.constraint(equalTo: contentView.topAnchor), - statusContentWarningEditorView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - statusContentWarningEditorView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - ]) - statusContentWarningEditorView.preservesSuperviewLayoutMargins = true - statusContentWarningEditorView.containerBackgroundView.isHidden = false - - statusView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(statusView) - NSLayoutConstraint.activate([ - statusView.topAnchor.constraint(equalTo: statusContentWarningEditorView.bottomAnchor, constant: 20), - statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - statusView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - ]) - statusView.statusContainerStackView.isHidden = true - statusView.actionToolbarContainer.isHidden = true - statusView.nameTrialingDotLabel.isHidden = true - statusView.dateLabel.isHidden = true - - statusView.setContentHuggingPriority(.defaultHigh, for: .vertical) - statusView.setContentCompressionResistancePriority(.required - 1, for: .vertical) - - textEditorViewContainerView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(textEditorViewContainerView) - NSLayoutConstraint.activate([ - textEditorViewContainerView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), - textEditorViewContainerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - textEditorViewContainerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor, constant: 10), - ]) - textEditorViewContainerView.preservesSuperviewLayoutMargins = true - - textEditorView.translatesAutoresizingMaskIntoConstraints = false - textEditorViewContainerView.addSubview(textEditorView) - NSLayoutConstraint.activate([ - textEditorView.topAnchor.constraint(equalTo: textEditorViewContainerView.topAnchor), - textEditorView.leadingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.leadingAnchor), - textEditorView.trailingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.trailingAnchor), - textEditorView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor), - textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), - ]) - textEditorView.setContentCompressionResistancePriority(.required - 2, for: .vertical) - - statusContentWarningEditorView.textView.delegate = self - textEditorView.changeObserver = self - - statusContentWarningEditorView.containerView.isHidden = true - statusView.revealContentWarningButton.isHidden = true - } - -} - -// MARK: - TextEditorViewChangeObserver -extension ComposeStatusContentCollectionViewCell: TextEditorViewChangeObserver { - func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { - defer { - textEditorViewChangeObserver?.textEditorView(textEditorView, didChangeWithChangeResult: changeResult) - } - - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textEditorView.text) - guard changeResult.isTextChanged else { return } - composeContent.send(textEditorView.text) - } -} - -// MARK: - UITextViewDelegate -extension ComposeStatusContentCollectionViewCell: UITextViewDelegate { - - func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - // disable input line break - guard text != "\n" else { return false } - return true - } - - func textViewDidChange(_ textView: UITextView) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textView.text) - guard textView === statusContentWarningEditorView.textView else { return } - // replace line break with space - textView.text = textView.text.replacingOccurrences(of: "\n", with: " ") - contentWarningContent.send(textView.text) - } - -} diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift index 4ef0dbe5a..e4569356f 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift @@ -10,7 +10,7 @@ import UIKit import Combine protocol ComposeStatusPollExpiresOptionCollectionViewCellDelegate: AnyObject { - func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusItem.ComposePollExpiresOptionAttribute.ExpiresOption) + func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusPollItem.PollExpiresOptionAttribute.ExpiresOption) } final class ComposeStatusPollExpiresOptionCollectionViewCell: UICollectionViewCell { @@ -41,7 +41,7 @@ final class ComposeStatusPollExpiresOptionCollectionViewCell: UICollectionViewCe extension ComposeStatusPollExpiresOptionCollectionViewCell { - private typealias ExpiresOption = ComposeStatusItem.ComposePollExpiresOptionAttribute.ExpiresOption + private typealias ExpiresOption = ComposeStatusPollItem.PollExpiresOptionAttribute.ExpiresOption private func _init() { durationButton.translatesAutoresizingMaskIntoConstraints = false diff --git a/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift index 61753a4c2..30d5986ab 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift @@ -9,7 +9,7 @@ import UIKit final class CustomEmojiPickerHeaderCollectionReusableView: UICollectionReusableView { - let titlelabel: UILabel = { + let titleLabel: UILabel = { let label = UILabel() label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .bold)) label.textColor = Asset.Colors.Label.secondary.color @@ -30,13 +30,13 @@ final class CustomEmojiPickerHeaderCollectionReusableView: UICollectionReusableV extension CustomEmojiPickerHeaderCollectionReusableView { private func _init() { - titlelabel.translatesAutoresizingMaskIntoConstraints = false - addSubview(titlelabel) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(titleLabel) NSLayoutConstraint.activate([ - titlelabel.topAnchor.constraint(equalTo: topAnchor, constant: 20), - titlelabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), - titlelabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), - titlelabel.bottomAnchor.constraint(equalTo: bottomAnchor), + titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 20), + titleLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + titleLabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor), ]) } } diff --git a/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift index 49e6c1fe2..7e305dbb0 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift @@ -6,10 +6,13 @@ // import UIKit +import Nuke final class CustomEmojiPickerItemCollectionViewCell: UICollectionViewCell { static let itemSize = CGSize(width: 44, height: 44) + + var imageTask: ImageTask? let emojiImageView: UIImageView = { let imageView = UIImageView() @@ -23,6 +26,12 @@ final class CustomEmojiPickerItemCollectionViewCell: UICollectionViewCell { emojiImageView.alpha = isHighlighted ? 0.5 : 1.0 } } + + override func prepareForReuse() { + super.prepareForReuse() + imageTask?.cancel() + imageTask = nil + } override init(frame: CGRect) { super.init(frame: frame) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index a9fe951f7..361aff090 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -12,6 +12,9 @@ import PhotosUI import Kingfisher import MastodonSDK import TwitterTextEditor +import MetaTextView +import MastodonMeta +import Meta final class ComposeViewController: UIViewController, NeedsDependency { @@ -22,7 +25,9 @@ final class ComposeViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: ComposeViewModel! - + + let logger = Logger(subsystem: "ComposeViewController", category: "logic") + private var suffixedAttachmentViews: [UIView] = [] let publishButton: UIButton = { @@ -43,20 +48,17 @@ final class ComposeViewController: UIViewController, NeedsDependency { let barButtonItem = UIBarButtonItem(customView: publishButton) return barButtonItem }() - - let collectionView: ComposeCollectionView = { - let collectionViewLayout = ComposeViewController.createLayout() - let collectionView = ComposeCollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) - collectionView.register(ComposeRepliedToStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self)) - collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self)) - collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self)) - collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self)) - collectionView.register(ComposeStatusPollOptionAppendEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self)) - collectionView.register(ComposeStatusPollExpiresOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self)) - collectionView.backgroundColor = Asset.Scene.Compose.background.color - collectionView.alwaysBounceVertical = true - collectionView.keyboardDismissMode = .onDrag - return collectionView + + let tableView: ComposeTableView = { + let tableView = ComposeTableView() + tableView.register(ComposeRepliedToStatusContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self)) + tableView.register(ComposeStatusContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeStatusContentTableViewCell.self)) + tableView.register(ComposeStatusAttachmentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeStatusAttachmentTableViewCell.self)) + tableView.backgroundColor = Asset.Scene.Compose.background.color + tableView.alwaysBounceVertical = true + tableView.separatorStyle = .none + tableView.tableFooterView = UIView() + return tableView }() var systemKeyboardHeight: CGFloat = .zero { @@ -148,14 +150,15 @@ extension ComposeViewController { navigationItem.leftBarButtonItem = cancelBarButtonItem navigationItem.rightBarButtonItem = publishBarButtonItem publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) - - collectionView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(collectionView) + + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) NSLayoutConstraint.activate([ - collectionView.topAnchor.constraint(equalTo: view.topAnchor), - collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) composeToolbarView.translatesAutoresizingMaskIntoConstraints = false @@ -178,21 +181,31 @@ extension ComposeViewController { composeToolbarBackgroundView.trailingAnchor.constraint(equalTo: composeToolbarView.trailingAnchor), view.bottomAnchor.constraint(equalTo: composeToolbarBackgroundView.bottomAnchor), ]) - - collectionView.delegate = self + + tableView.delegate = self viewModel.setupDiffableDataSource( - for: collectionView, - dependency: self, + tableView: tableView, + metaTextDelegate: self, + metaTextViewDelegate: self, customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel, - textEditorViewTextAttributesDelegate: self, - textEditorViewChangeObserver: self, - composeStatusAttachmentTableViewCellDelegate: self, + composeStatusAttachmentCollectionViewCellDelegate: self, composeStatusPollOptionCollectionViewCellDelegate: self, - composeStatusNewPollOptionCollectionViewCellDelegate: self, + composeStatusPollOptionAppendEntryCollectionViewCellDelegate: self, composeStatusPollExpiresOptionCollectionViewCellDelegate: self ) - let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeViewController.longPressReorderGestureHandler(_:))) - collectionView.addGestureRecognizer(longPressReorderGesture) + + viewModel.composeStatusAttribute.composeContent + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self = self else { return } + guard self.view.window != nil else { return } + UIView.performWithoutAnimation { + self.tableView.beginUpdates() + self.tableView.endUpdates() + } + } + .store(in: &disposeBag) customEmojiPickerInputView.collectionView.delegate = self viewModel.customEmojiPickerInputViewModel.customEmojiPickerInputView = customEmojiPickerInputView @@ -202,6 +215,7 @@ extension ComposeViewController { ) // update layout when keyboard show/dismiss + view.layoutIfNeeded() let keyboardEventPublishers = Publishers.CombineLatest3( KeyboardResponderService.shared.isShow, KeyboardResponderService.shared.state, @@ -227,8 +241,8 @@ extension ComposeViewController { // update keyboard background color guard isShow, state == .dock else { - self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin - self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin + self.tableView.contentInset.bottom = extraMargin + self.tableView.verticalScrollIndicatorInsets.bottom = extraMargin if let superView = self.autoCompleteViewController.tableView.superview { let autoCompleteTableViewBottomInset: CGFloat = { @@ -263,18 +277,18 @@ extension ComposeViewController { self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset // adjust inset for collectionView - let contentFrame = self.view.convert(self.collectionView.frame, to: nil) + let contentFrame = self.view.convert(self.tableView.frame, to: nil) let padding = contentFrame.maxY + extraMargin - endFrame.minY guard padding > 0 else { - self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin - self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin + self.tableView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin + self.tableView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin self.updateKeyboardBackground(isKeyboardDisplay: false) return } - self.collectionView.contentInset.bottom = padding - self.collectionView.verticalScrollIndicatorInsets.bottom = padding + self.tableView.contentInset.bottom = padding - self.view.safeAreaInsets.bottom + self.tableView.verticalScrollIndicatorInsets.bottom = padding - self.view.safeAreaInsets.bottom UIView.animate(withDuration: 0.3) { self.composeToolbarViewBottomLayoutConstraint.constant = endFrame.height self.view.layoutIfNeeded() @@ -292,15 +306,16 @@ extension ComposeViewController { if self.autoCompleteViewController.view.superview == nil { self.autoCompleteViewController.view.frame = self.view.bounds // add to container view. seealso: `viewDidLayoutSubviews()` - textEditorView.superview!.addSubview(self.autoCompleteViewController.view) + self.viewModel.composeStatusContentTableViewCell.textEditorViewContainerView.addSubview(self.autoCompleteViewController.view) self.addChild(self.autoCompleteViewController) self.autoCompleteViewController.didMove(toParent: self) self.autoCompleteViewController.view.isHidden = true - self.collectionView.autoCompleteViewController = self.autoCompleteViewController + self.tableView.autoCompleteViewController = self.autoCompleteViewController } + self.updateAutoCompleteViewControllerLayout() self.autoCompleteViewController.view.isHidden = info == nil guard let info = info else { return } - let symbolBoundingRectInContainer = textEditorView.convert(info.symbolBoundingRect, to: self.autoCompleteViewController.chevronView) + let symbolBoundingRectInContainer = textEditorView.textView.convert(info.symbolBoundingRect, to: self.autoCompleteViewController.chevronView) self.autoCompleteViewController.view.frame.origin.y = info.textBoundingRect.maxY self.autoCompleteViewController.viewModel.symbolBoundingRect.value = symbolBoundingRectInContainer self.autoCompleteViewController.viewModel.inputText.value = String(info.inputText) @@ -414,8 +429,8 @@ extension ComposeViewController { // setup snap behavior Publishers.CombineLatest( - viewModel.repliedToCellFrame.removeDuplicates().eraseToAnyPublisher(), - viewModel.collectionViewState.eraseToAnyPublisher() + viewModel.repliedToCellFrame, + viewModel.collectionViewState ) .receive(on: DispatchQueue.main) .sink { [weak self] repliedToCellFrame, collectionViewState in @@ -423,9 +438,11 @@ extension ComposeViewController { guard repliedToCellFrame != .zero else { return } switch collectionViewState { case .fold: - self.collectionView.contentInset.top = -repliedToCellFrame.height + self.tableView.contentInset.top = -repliedToCellFrame.height + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: set contentInset.top: -%s", ((#file as NSString).lastPathComponent), #line, #function, repliedToCellFrame.height.description) + case .expand: - self.collectionView.contentInset.top = 0 + self.tableView.contentInset.top = 0 } } .store(in: &disposeBag) @@ -433,12 +450,21 @@ extension ComposeViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - - // Fix AutoLayout conflict issue - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.markTextEditorViewBecomeFirstResponser() + + // using index to make table view layout + // otherwise, the content offset will be wrong + guard let indexPath = tableView.indexPath(for: viewModel.composeStatusContentTableViewCell), + let cell = tableView.cellForRow(at: indexPath) as? ComposeStatusContentTableViewCell else { + assertionFailure() + return } + cell.metaText.textView.becomeFirstResponder() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + viewModel.isViewAppeared = true } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { @@ -449,13 +475,17 @@ extension ComposeViewController { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - - // pin autoCompleteViewController frame to window + updateAutoCompleteViewControllerLayout() + } + + func updateAutoCompleteViewControllerLayout() { + // pin autoCompleteViewController frame to current view if let containerView = autoCompleteViewController.view.superview { - let viewFrameInWindow = containerView.convert(autoCompleteViewController.view.frame, to: nil) + let viewFrameInWindow = containerView.convert(autoCompleteViewController.view.frame, to: view) if viewFrameInWindow.origin.x != 0 { autoCompleteViewController.view.frame.origin.x = -viewFrameInWindow.origin.x } + autoCompleteViewController.view.frame.size.width = view.frame.width } } @@ -463,86 +493,56 @@ extension ComposeViewController { extension ComposeViewController { - private func textEditorView() -> TextEditorView? { - guard let diffableDataSource = viewModel.diffableDataSource else { return nil } - let items = diffableDataSource.snapshot().itemIdentifiers - for item in items { - switch item { - case .input: - guard let indexPath = diffableDataSource.indexPath(for: item), - let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusContentCollectionViewCell else { - continue - } - return cell.textEditorView - default: - continue - } - } - - return nil + private func textEditorView() -> MetaText? { + return viewModel.composeStatusContentTableViewCell.metaText } private func markTextEditorViewBecomeFirstResponser() { - textEditorView()?.isEditing = true + textEditorView()?.textView.becomeFirstResponder() } private func contentWarningEditorTextView() -> UITextView? { - guard let diffableDataSource = viewModel.diffableDataSource else { return nil } - let items = diffableDataSource.snapshot().itemIdentifiers - for item in items { - switch item { - case .input: - guard let indexPath = diffableDataSource.indexPath(for: item), - let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusContentCollectionViewCell else { - continue - } - return cell.statusContentWarningEditorView.textView - default: - continue - } - } - - return nil + viewModel.composeStatusContentTableViewCell.statusContentWarningEditorView.textView } - private func pollOptionCollectionViewCell(of item: ComposeStatusItem) -> ComposeStatusPollOptionCollectionViewCell? { + private func pollOptionCollectionViewCell(of item: ComposeStatusPollItem) -> ComposeStatusPollOptionCollectionViewCell? { guard case .pollOption = item else { return nil } - guard let diffableDataSource = viewModel.diffableDataSource else { return nil } - guard let indexPath = diffableDataSource.indexPath(for: item), - let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusPollOptionCollectionViewCell else { + guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return nil } + guard let indexPath = dataSource.indexPath(for: item), + let cell = viewModel.composeStatusPollTableViewCell.collectionView.cellForItem(at: indexPath) as? ComposeStatusPollOptionCollectionViewCell else { return nil } - + return cell } private func firstPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { - guard let diffableDataSource = viewModel.diffableDataSource else { return nil } - let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) + guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return nil } + let items = dataSource.snapshot().itemIdentifiers(inSection: .main) let firstPollItem = items.first { item -> Bool in guard case .pollOption = item else { return false } return true } - + guard let item = firstPollItem else { return nil } - + return pollOptionCollectionViewCell(of: item) } private func lastPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { - guard let diffableDataSource = viewModel.diffableDataSource else { return nil } - let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) + guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return nil } + let items = dataSource.snapshot().itemIdentifiers(inSection: .main) let lastPollItem = items.last { item -> Bool in guard case .pollOption = item else { return false } return true } - + guard let item = lastPollItem else { return nil } - + return pollOptionCollectionViewCell(of: item) } @@ -631,43 +631,158 @@ extension ComposeViewController { dismiss(animated: true, completion: nil) } - // seealso: ComposeViewModel.setupDiffableDataSource(…) - @objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) { - switch(sender.state) { - case .began: - guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), - let cell = collectionView.cellForItem(at: selectedIndexPath) as? ComposeStatusPollOptionCollectionViewCell else { - break - } - // check if pressing reorder bar no not - let locationInCell = sender.location(in: cell) - guard cell.reorderBarImageView.frame.contains(locationInCell) else { - return - } - - collectionView.beginInteractiveMovementForItem(at: selectedIndexPath) - case .changed: - guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), - let diffableDataSource = viewModel.diffableDataSource else { - break - } - guard let item = diffableDataSource.itemIdentifier(for: selectedIndexPath), - case .pollOption = item else { - collectionView.cancelInteractiveMovement() - return - } +} - var position = sender.location(in: collectionView) - position.x = collectionView.frame.width * 0.5 - collectionView.updateInteractiveMovementTargetPosition(position) - case .ended: - collectionView.endInteractiveMovement() - collectionView.reloadData() - default: - collectionView.cancelInteractiveMovement() +// MARK: - MetaTextDelegate +extension ComposeViewController: MetaTextDelegate { + func metaText(_ metaText: MetaText, processEditing textStorage: MetaTextStorage) -> MetaContent? { + let string = metaText.textStorage.string + let content = MastodonContent( + content: string, + emojis: viewModel.customEmojiViewModel.value?.emojiMapping.value ?? [:] + ) + let metaContent = MastodonMetaContent.convert(text: content) + return metaContent + } +} + +// MARK: - UITextViewDelegate +extension ComposeViewController: UITextViewDelegate { + + func textViewDidChange(_ textView: UITextView) { + if textEditorView()?.textView === textView { + // update model + guard let metaText = textEditorView() else { return } + let backedString = metaText.backedString + viewModel.composeStatusAttribute.composeContent.value = backedString + logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(backedString)") + + // configure auto completion + setupAutoComplete(for: textView) } } - + + struct AutoCompleteInfo { + // model + let inputText: Substring + // range + let symbolRange: Range + let symbolString: Substring + let toCursorRange: Range + let toCursorString: Substring + let toHighlightEndRange: Range + let toHighlightEndString: Substring + // geometry + var textBoundingRect: CGRect = .zero + var symbolBoundingRect: CGRect = .zero + } + + private func setupAutoComplete(for textView: UITextView) { + guard var autoCompletion = ComposeViewController.scanAutoCompleteInfo(textView: textView) else { + viewModel.autoCompleteInfo.value = nil + return + } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompletion.toHighlightEndString), String(autoCompletion.toCursorString)) + + // get layout text bounding rect + var glyphRange = NSRange() + textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.toCursorRange, in: textView.text), actualGlyphRange: &glyphRange) + let textContainer = textView.layoutManager.textContainers[0] + let textBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + + let retryLayoutTimes = viewModel.autoCompleteRetryLayoutTimes.value + guard textBoundingRect.size != .zero else { + viewModel.autoCompleteRetryLayoutTimes.value += 1 + // avoid infinite loop + guard retryLayoutTimes < 3 else { return } + // needs retry calculate layout when the rect position changing + DispatchQueue.main.async { + self.setupAutoComplete(for: textView) + } + return + } + viewModel.autoCompleteRetryLayoutTimes.value = 0 + + // get symbol bounding rect + textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.symbolRange, in: textView.text), actualGlyphRange: &glyphRange) + let symbolBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + + // set bounding rect and trigger layout + autoCompletion.textBoundingRect = textBoundingRect + autoCompletion.symbolBoundingRect = symbolBoundingRect + viewModel.autoCompleteInfo.value = autoCompletion + } + + private static func scanAutoCompleteInfo(textView: UITextView) -> AutoCompleteInfo? { + guard let text = textView.text, + textView.selectedRange.location > 0, !text.isEmpty, + let selectedRange = Range(textView.selectedRange, in: text) else { + return nil + } + let cursorIndex = selectedRange.upperBound + let _highlightStartIndex: String.Index? = { + var index = text.index(before: cursorIndex) + while index > text.startIndex { + let char = text[index] + if char == "@" || char == "#" || char == ":" { + return index + } + index = text.index(before: index) + } + assert(index == text.startIndex) + let char = text[index] + if char == "@" || char == "#" || char == ":" { + return index + } else { + return nil + } + }() + + guard let highlightStartIndex = _highlightStartIndex else { return nil } + let scanRange = NSRange(highlightStartIndex..= cursorIndex else { return nil } + let symbolRange = highlightStartIndex.. Bool { + if textView === textEditorView()?.textView { + return false + } + + return true + } + + func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + if textView === textEditorView()?.textView { + return false + } + + return true + } + } // MARK: - TextEditorViewTextAttributesDelegate @@ -700,7 +815,7 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { } self.suffixedAttachmentViews.removeAll() - // set normal apperance + // set normal appearance let attributedString = NSMutableAttributedString(attributedString: attributedString) attributedString.removeAttribute(.suffixedAttachment, range: stringRange) attributedString.removeAttribute(.underlineStyle, range: stringRange) @@ -811,117 +926,6 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { } -// MARK: - TextEditorViewChangeObserver -extension ComposeViewController: TextEditorViewChangeObserver { - - func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { - guard var autoCompletion = ComposeViewController.scanAutoCompleteInfo(textEditorView: textEditorView) else { - viewModel.autoCompleteInfo.value = nil - return - } - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompletion.toHighlightEndString), String(autoCompletion.toCursorString)) - - // get layout text bounding rect - var glyphRange = NSRange() - textEditorView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.toCursorRange, in: textEditorView.text), actualGlyphRange: &glyphRange) - let textContainer = textEditorView.layoutManager.textContainers[0] - let textBoundingRect = textEditorView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) - - let retryLayoutTimes = viewModel.autoCompleteRetryLayoutTimes.value - guard textBoundingRect.size != .zero else { - viewModel.autoCompleteRetryLayoutTimes.value += 1 - // avoid infinite loop - guard retryLayoutTimes < 3 else { return } - // needs retry calculate layout when the rect position changing - DispatchQueue.main.async { - self.textEditorView(textEditorView, didChangeWithChangeResult: changeResult) - } - return - } - viewModel.autoCompleteRetryLayoutTimes.value = 0 - - // get symbol bounding rect - textEditorView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.symbolRange, in: textEditorView.text), actualGlyphRange: &glyphRange) - let symbolBoundingRect = textEditorView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) - - // set bounding rect and trigger layout - autoCompletion.textBoundingRect = textBoundingRect - autoCompletion.symbolBoundingRect = symbolBoundingRect - viewModel.autoCompleteInfo.value = autoCompletion - } - - struct AutoCompleteInfo { - // model - let inputText: Substring - // range - let symbolRange: Range - let symbolString: Substring - let toCursorRange: Range - let toCursorString: Substring - let toHighlightEndRange: Range - let toHighlightEndString: Substring - // geometry - var textBoundingRect: CGRect = .zero - var symbolBoundingRect: CGRect = .zero - } - - private static func scanAutoCompleteInfo(textEditorView: TextEditorView) -> AutoCompleteInfo? { - let text = textEditorView.text - - guard textEditorView.selectedRange.location > 0, !text.isEmpty, - let selectedRange = Range(textEditorView.selectedRange, in: text) else { - return nil - } - let cursorIndex = selectedRange.upperBound - let _highlightStartIndex: String.Index? = { - var index = text.index(before: cursorIndex) - while index > text.startIndex { - let char = text[index] - if char == "@" || char == "#" || char == ":" { - return index - } - index = text.index(before: index) - } - assert(index == text.startIndex) - let char = text[index] - if char == "@" || char == "#" || char == ":" { - return index - } else { - return nil - } - }() - - guard let highlightStartIndex = _highlightStartIndex else { return nil } - let scanRange = NSRange(highlightStartIndex..= cursorIndex else { return nil } - let symbolRange = highlightStartIndex..) { - guard scrollView === collectionView else { return } + guard scrollView === tableView else { return } let repliedToCellFrame = viewModel.repliedToCellFrame.value guard repliedToCellFrame != .zero else { return } @@ -1007,6 +1011,9 @@ extension ComposeViewController { } } +// MARK: - UITableViewDelegate +extension ComposeViewController: UITableViewDelegate { } + // MARK: - UICollectionViewDelegate extension ComposeViewController: UICollectionViewDelegate { @@ -1018,26 +1025,13 @@ extension ComposeViewController: UICollectionViewDelegate { let item = diffableDataSource.itemIdentifier(for: indexPath) guard case let .emoji(attribute) = item else { return } let emoji = attribute.emoji - let textEditorView = self.textEditorView() - + + // make click sound + UIDevice.current.playInputClick() + // retrieve active text input and insert emoji - // the leading and trailing space is REQUIRED to fix `UITextStorage` layout issue - let reference = viewModel.customEmojiPickerInputViewModel.insertText(" :\(emoji.shortcode): ") - - // workaround: non-user interactive change do not trigger value update event - if reference?.value === textEditorView { - viewModel.composeStatusAttribute.composeContent.value = textEditorView?.text - // update text storage - textEditorView?.setNeedsUpdateTextAttributes() - // collection self-size - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.collectionView.collectionViewLayout.invalidateLayout() - - // make click sound - UIDevice.current.playInputClick() - } - } + // the trailing space is REQUIRED to make regex happy + _ = viewModel.customEmojiPickerInputViewModel.insertText(":\(emoji.shortcode): ") } else { // do nothing } @@ -1124,17 +1118,17 @@ extension ComposeViewController: UIDocumentPickerDelegate { extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelegate { func composeStatusAttachmentCollectionViewCell(_ cell: ComposeStatusAttachmentCollectionViewCell, removeButtonDidPressed button: UIButton) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let indexPath = collectionView.indexPath(for: cell) else { return } + guard let diffableDataSource = viewModel.composeStatusAttachmentTableViewCell.dataSource else { return } + guard let indexPath = viewModel.composeStatusAttachmentTableViewCell.collectionView.indexPath(for: cell) else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } guard case let .attachment(attachmentService) = item else { return } - + var attachmentServices = viewModel.attachmentServices.value guard let index = attachmentServices.firstIndex(of: attachmentService) else { return } let removedItem = attachmentServices[index] attachmentServices.remove(at: index) viewModel.attachmentServices.value = attachmentServices - + // cancel task removedItem.disposeBag.removeAll() } @@ -1155,16 +1149,16 @@ extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelega // handle delete backward event for poll option input func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) { guard (text ?? "").isEmpty else { return } - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let indexPath = collectionView.indexPath(for: cell) else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return } + guard let indexPath = viewModel.composeStatusPollTableViewCell.collectionView.indexPath(for: cell) else { return } + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } guard case let .pollOption(attribute) = item else { return } - + var pollAttributes = viewModel.pollOptionAttributes.value guard let index = pollAttributes.firstIndex(of: attribute) else { return } - + // mark previous (fallback to next) item of removed middle poll option become first responder - let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) + let pollItems = dataSource.snapshot().itemIdentifiers(inSection: .main) if let indexOfItem = pollItems.firstIndex(of: item), index > 0 { func cellBeforeRemoved() -> ComposeStatusPollOptionCollectionViewCell? { guard index > 0 else { return nil } @@ -1172,7 +1166,7 @@ extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelega let itemBeforeRemoved = pollItems[indexBeforeRemoved] return pollOptionCollectionViewCell(of: itemBeforeRemoved) } - + func cellAfterRemoved() -> ComposeStatusPollOptionCollectionViewCell? { guard index < pollItems.count - 1 else { return nil } let indexAfterRemoved = pollItems.index(after: index) @@ -1186,27 +1180,27 @@ extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelega } cell?.pollOptionView.optionTextField.becomeFirstResponder() } - + guard pollAttributes.count > 2 else { return } pollAttributes.remove(at: index) - + // update data source viewModel.pollOptionAttributes.value = pollAttributes } // handle keyboard return event for poll option input func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let indexPath = collectionView.indexPath(for: cell) else { return } - let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll).filter { item in + guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return } + guard let indexPath = viewModel.composeStatusPollTableViewCell.collectionView.indexPath(for: cell) else { return } + let pollItems = dataSource.snapshot().itemIdentifiers(inSection: .main).filter { item in guard case .pollOption = item else { return false } return true } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } guard let index = pollItems.firstIndex(of: item) else { return } - + if index == pollItems.count - 1 { // is the last viewModel.createNewPollOptionIfPossible() @@ -1236,7 +1230,7 @@ extension ComposeViewController: ComposeStatusPollOptionAppendEntryCollectionVie // MARK: - ComposeStatusPollExpiresOptionCollectionViewCellDelegate extension ComposeViewController: ComposeStatusPollExpiresOptionCollectionViewCellDelegate { - func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusItem.ComposePollExpiresOptionAttribute.ExpiresOption) { + func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusPollItem.PollExpiresOptionAttribute.ExpiresOption) { viewModel.pollExpiresOptionAttribute.expiresOption.value = expiresOption } } @@ -1264,14 +1258,22 @@ extension ComposeViewController: AutoCompleteViewControllerDelegate { }() guard let replacedText = _replacedText else { return } - guard let textEditorView = textEditorView() else { return } - let text = textEditorView.text - - do { - try textEditorView.updateByReplacing(range: NSRange(info.toHighlightEndRange, in: text), with: replacedText) - viewModel.autoCompleteInfo.value = nil - } catch { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete fail %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + guard let textEditorView = textEditorView(), + let text = textEditorView.textView.text else { return } + + + let range = NSRange(info.toHighlightEndRange, in: text) + textEditorView.textStorage.replaceCharacters(in: range, with: replacedText) + viewModel.autoCompleteInfo.value = nil + + switch item { + case .emoji, .bottomLoader: + break + default: + // set selected range except emoji + let newRange = NSRange(location: range.location + (replacedText as NSString).length, length: 0) + guard textEditorView.textStorage.length <= newRange.location else { return } + textEditorView.textView.selectedRange = newRange } } } diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 0b8d3e8f1..fa38afcfe 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -5,73 +5,98 @@ // Created by MainasuK Cirno on 2021-3-11. // +import os.log import UIKit import Combine +import CoreDataStack import TwitterTextEditor import MastodonSDK +import MastodonMeta +import MetaTextView extension ComposeViewModel { - + func setupDiffableDataSource( - for collectionView: UICollectionView, - dependency: NeedsDependency, + tableView: UITableView, + metaTextDelegate: MetaTextDelegate, + metaTextViewDelegate: UITextViewDelegate, customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel, - textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, - textEditorViewChangeObserver: TextEditorViewChangeObserver, - composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, + composeStatusAttachmentCollectionViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, - composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, + composeStatusPollOptionAppendEntryCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate ) { - let diffableDataSource = ComposeStatusSection.collectionViewDiffableDataSource( - for: collectionView, - dependency: dependency, - managedObjectContext: context.managedObjectContext, - composeKind: composeKind, - repliedToCellFrameSubscriber: repliedToCellFrame, - customEmojiPickerInputViewModel: customEmojiPickerInputViewModel, - textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate, - textEditorViewChangeObserver: textEditorViewChangeObserver, - composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate, - composeStatusPollOptionCollectionViewCellDelegate: composeStatusPollOptionCollectionViewCellDelegate, - composeStatusNewPollOptionCollectionViewCellDelegate: composeStatusNewPollOptionCollectionViewCellDelegate, - composeStatusPollExpiresOptionCollectionViewCellDelegate: composeStatusPollExpiresOptionCollectionViewCellDelegate - ) + // content + composeStatusContentTableViewCell.metaText.delegate = metaTextDelegate + composeStatusContentTableViewCell.metaText.textView.delegate = metaTextViewDelegate + // attachment + composeStatusAttachmentTableViewCell.composeStatusAttachmentCollectionViewCellDelegate = composeStatusAttachmentCollectionViewCellDelegate + // poll + composeStatusPollTableViewCell.delegate = self + composeStatusPollTableViewCell.customEmojiPickerInputViewModel = customEmojiPickerInputViewModel + composeStatusPollTableViewCell.composeStatusPollOptionCollectionViewCellDelegate = composeStatusPollOptionCollectionViewCellDelegate + composeStatusPollTableViewCell.composeStatusPollOptionAppendEntryCollectionViewCellDelegate = composeStatusPollOptionAppendEntryCollectionViewCellDelegate + composeStatusPollTableViewCell.composeStatusPollExpiresOptionCollectionViewCellDelegate = composeStatusPollExpiresOptionCollectionViewCellDelegate - diffableDataSource.reorderingHandlers.canReorderItem = { item in - switch item { - case .pollOption: return true - default: return false + // setup data source + tableView.dataSource = self + + attachmentServices + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] attachmentServices in + guard let self = self else { return } + guard self.isViewAppeared else { return } + + let cell = self.composeStatusAttachmentTableViewCell + guard let dataSource = cell.dataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + let items = attachmentServices.map { ComposeStatusAttachmentItem.attachment(attachmentService: $0) } + snapshot.appendItems(items, toSection: .main) + + tableView.performBatchUpdates { + dataSource.apply(snapshot, animatingDifferences: true) + } completion: { _ in + // do nothing + } } - } - - // update reordered data source - diffableDataSource.reorderingHandlers.didReorder = { [weak self] transaction in + .store(in: &disposeBag) + + Publishers.CombineLatest( + isPollComposing, + pollOptionAttributes + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isPollComposing, pollOptionAttributes in guard let self = self else { return } - - let items = transaction.finalSnapshot.itemIdentifiers - var pollOptionAttributes: [ComposeStatusItem.ComposePollOptionAttribute] = [] - for item in items { - guard case let .pollOption(attribute) = item else { continue } - pollOptionAttributes.append(attribute) + guard self.isViewAppeared else { return } + + let cell = self.composeStatusPollTableViewCell + guard let dataSource = cell.dataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + var items: [ComposeStatusPollItem] = [] + if isPollComposing { + for attribute in pollOptionAttributes { + items.append(.pollOption(attribute: attribute)) + } + if pollOptionAttributes.count < 4 { + items.append(.pollOptionAppendEntry) + } + items.append(.pollExpiresOption(attribute: self.pollExpiresOptionAttribute)) + } + snapshot.appendItems(items, toSection: .main) + + tableView.performBatchUpdates { + dataSource.apply(snapshot, animatingDifferences: true) + } completion: { _ in + // do nothing } - self.pollOptionAttributes.value = pollOptionAttributes } - - self.diffableDataSource = diffableDataSource - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.repliedTo, .status, .attachment, .poll]) - switch composeKind { - case .reply(let statusObjectID): - snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo) - snapshot.appendItems([.input(replyToStatusObjectID: statusObjectID, attribute: composeStatusAttribute)], toSection: .repliedTo) - case .hashtag, .mention, .post: - snapshot.appendItems([.input(replyToStatusObjectID: nil, attribute: composeStatusAttribute)], toSection: .status) - } - diffableDataSource.apply(snapshot, animatingDifferences: false) - - // some magic fix modal presentation animation issue - collectionView.dataSource = diffableDataSource + .store(in: &disposeBag) } func setupCustomEmojiPickerDiffableDataSource( @@ -120,3 +145,154 @@ extension ComposeViewModel { } } + +// MARK: - UITableViewDataSource +extension ComposeViewModel: UITableViewDataSource { + + enum Section: CaseIterable { + case repliedTo + case status + case attachment + case poll + } + + func numberOfSections(in tableView: UITableView) -> Int { + return Section.allCases.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch Section.allCases[section] { + case .repliedTo: + switch composeKind { + case .reply: return 1 + default: return 0 + } + case .status: return 1 + case .attachment: + return 1 + case .poll: + return 1 + } + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch Section.allCases[indexPath.section] { + case .repliedTo: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentTableViewCell + guard case let .reply(statusObjectID) = composeKind else { return cell } + cell.framePublisher + .receive(on: DispatchQueue.main) + .assign(to: \.value, on: self.repliedToCellFrame) + .store(in: &cell.disposeBag) + let managedObjectContext = context.managedObjectContext + managedObjectContext.performAndWait { + guard let replyTo = managedObjectContext.object(with: statusObjectID) as? Status else { + return + } + let status = replyTo.reblog ?? replyTo + + // set avatar + cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL())) + // set name username + cell.statusView.nameLabel.configure(content: status.author.displayNameWithFallback, emojiDict: status.author.emojiDict) + cell.statusView.usernameLabel.text = "@" + status.author.acct + // set text + let content = MastodonContent(content: status.content, emojis: status.emojiMeta) + do { + let metaContent = try MastodonMetaContent.convert(document: content) + cell.statusView.contentMetaText.configure(content: metaContent) + } catch { + cell.statusView.contentMetaText.textView.text = " " + assertionFailure() + } + // set date + cell.statusView.dateLabel.text = status.createdAt.slowedTimeAgoSinceNow + } + return cell + case .status: + let cell = self.composeStatusContentTableViewCell + // configure header + let managedObjectContext = context.managedObjectContext + managedObjectContext.performAndWait { + guard case let .reply(replyToStatusObjectID) = self.composeKind, + let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else { + cell.statusView.headerContainerView.isHidden = true + return + } + cell.statusView.headerContainerView.isHidden = false + cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) + let headerText: String = { + let author = replyTo.author + let name = author.displayName.isEmpty ? author.username : author.displayName + return L10n.Scene.Compose.replyingToUser(name) + }() + MastodonStatusContent.parseResult(content: headerText, emojiDict: replyTo.author.emojiDict) + .receive(on: DispatchQueue.main) + .sink { [weak cell] parseResult in + guard let cell = cell else { return } + cell.statusView.headerInfoLabel.configure(contentParseResult: parseResult) + } + .store(in: &cell.disposeBag) + } + // configure author + ComposeStatusSection.configureStatusContent(cell: cell, attribute: composeStatusAttribute) + // configure content. bind text in UITextViewDelegate + if let composeContent = composeStatusAttribute.composeContent.value { + cell.metaText.textView.text = composeContent + } + // configure content warning + cell.statusContentWarningEditorView.textView.text = composeStatusAttribute.contentWarningContent.value + // bind content warning + composeStatusAttribute.isContentWarningComposing + .receive(on: DispatchQueue.main) + .sink { [weak cell, weak tableView] isContentWarningComposing in + guard let cell = cell else { return } + guard let tableView = tableView else { return } + // self size input cell + cell.statusContentWarningEditorView.isHidden = !isContentWarningComposing + cell.statusContentWarningEditorView.alpha = 0 + UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) { + cell.statusContentWarningEditorView.alpha = 1 + } completion: { _ in + // do nothing + } + } + .store(in: &cell.disposeBag) + cell.contentWarningContent + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak tableView, weak self] text in + guard let self = self else { return } + // bind input data + self.composeStatusAttribute.contentWarningContent.value = text + + // self size input cell + guard let tableView = tableView else { return } + UIView.performWithoutAnimation { + tableView.beginUpdates() + tableView.endUpdates() + } + } + .store(in: &cell.disposeBag) + // configure custom emoji picker + ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.metaText.textView, disposeBag: &cell.disposeBag) + ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.statusContentWarningEditorView.textView, disposeBag: &cell.disposeBag) + return cell + case .attachment: + let cell = self.composeStatusAttachmentTableViewCell + return cell + case .poll: + let cell = self.composeStatusPollTableViewCell + return cell + } + } +} + +// MARK: - ComposeStatusPollTableViewCellDelegate +extension ComposeViewModel: ComposeStatusPollTableViewCellDelegate { + func composeStatusPollTableViewCell(_ cell: ComposeStatusPollTableViewCell, pollOptionAttributesDidReorder options: [ComposeStatusPollItem.PollOptionAttribute]) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + self.pollOptionAttributes.value = options + } +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index a368bfbb9..15dd0ef3b 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -13,7 +13,7 @@ import CoreDataStack import GameplayKit import MastodonSDK -final class ComposeViewModel { +final class ComposeViewModel: NSObject { static let composeContentLimit: Int = 500 @@ -33,9 +33,14 @@ final class ComposeViewModel { let repliedToCellFrame = CurrentValueSubject(.zero) let autoCompleteRetryLayoutTimes = CurrentValueSubject(0) let autoCompleteInfo = CurrentValueSubject(nil) + var isViewAppeared = false // output - var diffableDataSource: UICollectionViewDiffableDataSource! + let composeStatusContentTableViewCell = ComposeStatusContentTableViewCell() + let composeStatusAttachmentTableViewCell = ComposeStatusAttachmentTableViewCell() + let composeStatusPollTableViewCell = ComposeStatusPollTableViewCell() + + var dataSource: UITableViewDiffableDataSource! var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource! private(set) lazy var publishStateMachine: GKStateMachine = { // exclude timeline middle fetcher state @@ -61,7 +66,7 @@ final class ComposeViewModel { let characterCount = CurrentValueSubject(0) let collectionViewState = CurrentValueSubject(.fold) - // for hashtag: "# " + // for hashtag: "# " // for mention: "@ " private(set) var preInsertedContent: String? @@ -75,8 +80,8 @@ final class ComposeViewModel { let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([]) // polls - let pollOptionAttributes = CurrentValueSubject<[ComposeStatusItem.ComposePollOptionAttribute], Never>([]) - let pollExpiresOptionAttribute = ComposeStatusItem.ComposePollExpiresOptionAttribute() + let pollOptionAttributes = CurrentValueSubject<[ComposeStatusPollItem.PollOptionAttribute], Never>([]) + let pollExpiresOptionAttribute = ComposeStatusPollItem.PollExpiresOptionAttribute() init( context: AppContext, @@ -91,7 +96,9 @@ final class ComposeViewModel { self.selectedStatusVisibility = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value?.user.locked == true ? .private : .public) self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) + super.init() // end init + switch composeKind { case .reply(let repliedToStatusObjectID): context.managedObjectContext.performAndWait { @@ -143,7 +150,7 @@ final class ComposeViewModel { case .post: self.preInsertedContent = nil } - + isCustomEmojiComposing .assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing) .store(in: &disposeBag) @@ -174,6 +181,7 @@ final class ComposeViewModel { } return displayName }() + self.composeStatusAttribute.emojiDict.value = mastodonUser?.emojiDict ?? [:] self.composeStatusAttribute.username.value = username } .store(in: &disposeBag) @@ -282,67 +290,34 @@ final class ComposeViewModel { self.customEmojiViewModel.value = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) } .store(in: &disposeBag) - - // bind snapshot - Publishers.CombineLatest3( - attachmentServices.eraseToAnyPublisher(), - isPollComposing.eraseToAnyPublisher(), - pollOptionAttributes.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] attachmentServices, isPollComposing, pollAttributes in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: trigger attachments upload…", ((#file as NSString).lastPathComponent), #line, #function) - guard let self = self else { return } - guard let diffableDataSource = self.diffableDataSource else { return } - var snapshot = diffableDataSource.snapshot() - - snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .attachment)) - var attachmentItems: [ComposeStatusItem] = [] - for attachmentService in attachmentServices { - let item = ComposeStatusItem.attachment(attachmentService: attachmentService) - attachmentItems.append(item) - } - snapshot.appendItems(attachmentItems, toSection: .attachment) - - snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .poll)) - if isPollComposing { - var pollItems: [ComposeStatusItem] = [] - for pollAttribute in pollAttributes { - let item = ComposeStatusItem.pollOption(attribute: pollAttribute) - pollItems.append(item) - } - snapshot.appendItems(pollItems, toSection: .poll) - if pollAttributes.count < 4 { - snapshot.appendItems([ComposeStatusItem.pollOptionAppendEntry], toSection: .poll) - } - snapshot.appendItems([ComposeStatusItem.pollExpiresOption(attribute: self.pollExpiresOptionAttribute)], toSection: .poll) - } - - diffableDataSource.apply(snapshot) - - // drive service upload state - // make image upload in the queue - for attachmentService in attachmentServices { - // skip when prefix N task when task finish OR fail OR uploading - guard let currentState = attachmentService.uploadStateMachine.currentState else { break } - if currentState is MastodonAttachmentService.UploadState.Fail { - continue - } - if currentState is MastodonAttachmentService.UploadState.Finish { - continue - } - if currentState is MastodonAttachmentService.UploadState.Uploading { - break - } - // trigger uploading one by one - if currentState is MastodonAttachmentService.UploadState.Initial { - attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self) - break + // setup attribute updater + attachmentServices + .receive(on: DispatchQueue.main) + .debounce(for: 0.3, scheduler: DispatchQueue.main) + .sink { attachmentServices in + // drive service upload state + // make image upload in the queue + for attachmentService in attachmentServices { + // skip when prefix N task when task finish OR fail OR uploading + guard let currentState = attachmentService.uploadStateMachine.currentState else { break } + if currentState is MastodonAttachmentService.UploadState.Fail { + continue + } + if currentState is MastodonAttachmentService.UploadState.Finish { + continue + } + if currentState is MastodonAttachmentService.UploadState.Uploading { + break + } + // trigger uploading one by one + if currentState is MastodonAttachmentService.UploadState.Initial { + attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self) + break + } } } - } - .store(in: &disposeBag) + .store(in: &disposeBag) // bind delegate attachmentServices @@ -393,7 +368,7 @@ extension ComposeViewModel { func createNewPollOptionIfPossible() { guard pollOptionAttributes.value.count < 4 else { return } - let attribute = ComposeStatusItem.ComposePollOptionAttribute() + let attribute = ComposeStatusPollItem.PollOptionAttribute() pollOptionAttributes.value = pollOptionAttributes.value + [attribute] } @@ -465,7 +440,7 @@ extension ComposeViewModel: MastodonAttachmentServiceDelegate { // MARK: - ComposePollAttributeDelegate extension ComposeViewModel: ComposePollAttributeDelegate { - func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?) { + func composePollAttribute(_ attribute: ComposeStatusPollItem.PollOptionAttribute, pollOptionDidChange: String?) { // trigger update pollOptionAttributes.value = pollOptionAttributes.value } diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift similarity index 57% rename from Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift rename to Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift index 8da4c0729..4ba68cedd 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToStatusContentTableViewCell.swift @@ -1,62 +1,61 @@ // -// ComposeRepliedToStatusContentCollectionViewCell.swift +// ComposeRepliedToStatusContentTableViewCell.swift // Mastodon // -// Created by MainasuK Cirno on 2021-3-11. +// Created by MainasuK Cirno on 2021-6-28. // import UIKit import Combine -final class ComposeRepliedToStatusContentCollectionViewCell: UICollectionViewCell { - +final class ComposeRepliedToStatusContentTableViewCell: UITableViewCell { + var disposeBag = Set() - - let statusView = StatusView() - + + let statusView = ReplicaStatusView() + let framePublisher = PassthroughSubject() override func prepareForReuse() { super.prepareForReuse() - - statusView.updateContentWarningDisplay(isHidden: true, animated: false) + disposeBag.removeAll() } - - override init(frame: CGRect) { - super.init(frame: frame) + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) _init() } - + required init?(coder: NSCoder) { super.init(coder: coder) _init() } - + override func layoutSubviews() { super.layoutSubviews() framePublisher.send(bounds) } - + } -extension ComposeRepliedToStatusContentCollectionViewCell { - +extension ComposeRepliedToStatusContentTableViewCell { + private func _init() { + selectionStyle = .none backgroundColor = .clear - statusView.actionToolbarContainer.isHidden = true - statusView.revealContentWarningButton.isHidden = true - statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) NSLayoutConstraint.activate([ statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20).identifier("statusView.top to ComposeRepliedToStatusContentCollectionViewCell.contentView.top"), - statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), + statusView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10).identifier("ComposeRepliedToStatusContentCollectionViewCell.contentView.bottom to statusView.bottom"), ]) + + statusView.headerContainerView.isHidden = true } - + } diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift new file mode 100644 index 000000000..cb4da5765 --- /dev/null +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift @@ -0,0 +1,154 @@ +// +// ComposeStatusAttachmentTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-29. +// + +import UIKit +import Combine +import AlamofireImage + +final class ComposeStatusAttachmentTableViewCell: UITableViewCell { + + private(set) var dataSource: UICollectionViewDiffableDataSource! + weak var composeStatusAttachmentCollectionViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate? + var observations = Set() + + private static func createLayout() -> UICollectionViewLayout { + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) + let section = NSCollectionLayoutSection(group: group) + section.contentInsetsReference = .readableContent + return UICollectionViewCompositionalLayout(section: section) + } + + private(set) var collectionViewHeightLayoutConstraint: NSLayoutConstraint! + let collectionView: UICollectionView = { + let collectionViewLayout = ComposeStatusAttachmentTableViewCell.createLayout() + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) + collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self)) + collectionView.backgroundColor = Asset.Scene.Compose.background.color + collectionView.alwaysBounceVertical = true + collectionView.isScrollEnabled = false + return collectionView + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeStatusAttachmentTableViewCell { + + private func _init() { + collectionView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(collectionView) + collectionViewHeightLayoutConstraint = collectionView.heightAnchor.constraint(equalToConstant: 200).priority(.defaultHigh) + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: contentView.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + collectionViewHeightLayoutConstraint, + ]) + + collectionView.observe(\.contentSize, options: [.initial, .new]) { [weak self] collectionView, _ in + guard let self = self else { return } + self.collectionViewHeightLayoutConstraint.constant = collectionView.contentSize.height + } + .store(in: &observations) + + self.dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [ + weak self + ] collectionView, indexPath, item -> UICollectionViewCell? in + guard let self = self else { return UICollectionViewCell() } + switch item { + case .attachment(let attachmentService): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self), for: indexPath) as! ComposeStatusAttachmentCollectionViewCell + cell.attachmentContainerView.descriptionTextView.text = attachmentService.description.value + cell.delegate = self.composeStatusAttachmentCollectionViewCellDelegate + attachmentService.thumbnailImage + .receive(on: DispatchQueue.main) + .sink { [weak cell] thumbnailImage in + guard let cell = cell else { return } + let size = cell.attachmentContainerView.previewImageView.frame.size != .zero ? cell.attachmentContainerView.previewImageView.frame.size : CGSize(width: 1, height: 1) + guard let image = thumbnailImage else { + let placeholder = UIImage.placeholder( + size: size, + color: Asset.Colors.Background.systemGroupedBackground.color + ) + .af.imageRounded( + withCornerRadius: AttachmentContainerView.containerViewCornerRadius + ) + cell.attachmentContainerView.previewImageView.image = placeholder + return + } + // cannot get correct size. set corner radius on layer + cell.attachmentContainerView.previewImageView.image = image + } + .store(in: &cell.disposeBag) + Publishers.CombineLatest( + attachmentService.uploadStateMachineSubject.eraseToAnyPublisher(), + attachmentService.error.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak cell, weak attachmentService] uploadState, error in + guard let cell = cell else { return } + guard let attachmentService = attachmentService else { return } + cell.attachmentContainerView.emptyStateView.isHidden = error == nil + cell.attachmentContainerView.descriptionBackgroundView.isHidden = error != nil + if let error = error { + cell.attachmentContainerView.activityIndicatorView.stopAnimating() + cell.attachmentContainerView.emptyStateView.label.text = error.localizedDescription + } else { + guard let uploadState = uploadState else { return } + switch uploadState { + case is MastodonAttachmentService.UploadState.Finish, + is MastodonAttachmentService.UploadState.Fail: + cell.attachmentContainerView.activityIndicatorView.stopAnimating() + cell.attachmentContainerView.emptyStateView.label.text = { + if let file = attachmentService.file.value { + switch file { + case .jpeg, .png, .gif: + return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo) + case .other: + return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video) + } + } else { + return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo) + } + }() + default: + break + } + } + } + .store(in: &cell.disposeBag) + NotificationCenter.default.publisher( + for: UITextView.textDidChangeNotification, + object: cell.attachmentContainerView.descriptionTextView + ) + .receive(on: DispatchQueue.main) + .sink { notification in + guard let textField = notification.object as? UITextView else { return } + let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) + attachmentService.description.value = text + } + .store(in: &cell.disposeBag) + return cell + } + } + } + +} + diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift new file mode 100644 index 000000000..aa641fe4a --- /dev/null +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift @@ -0,0 +1,160 @@ +// +// ComposeStatusContentTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-28. +// + + +import os.log +import UIKit +import Combine +import MetaTextView + +final class ComposeStatusContentTableViewCell: UITableViewCell { + + var disposeBag = Set() + + let statusView = ReplicaStatusView() + + let statusContentWarningEditorView = StatusContentWarningEditorView() + + let textEditorViewContainerView = UIView() + + static let metaTextViewTag: Int = 333 + let metaText: MetaText = { + let metaText = MetaText() + metaText.textView.backgroundColor = .clear + metaText.textView.isScrollEnabled = false + metaText.textView.keyboardType = .twitter + metaText.textView.textDragInteraction?.isEnabled = false // disable drag for link and attachment + metaText.textView.textContainer.lineFragmentPadding = 10 // leading inset + metaText.textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) + metaText.textView.attributedPlaceholder = { + var attributes = metaText.textAttributes + attributes[.foregroundColor] = Asset.Colors.Label.secondary.color + return NSAttributedString( + string: L10n.Scene.Compose.contentInputPlaceholder, + attributes: attributes + ) + }() + let paragraphStyle: NSMutableParagraphStyle = { + let style = NSMutableParagraphStyle() + style.lineSpacing = 5 + return style + }() + metaText.textAttributes = [ + .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)), + .foregroundColor: Asset.Colors.Label.primary.color, + .paragraphStyle: paragraphStyle, + ] + metaText.linkAttributes = [ + .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)), + .foregroundColor: Asset.Colors.brandBlue.color, + .paragraphStyle: paragraphStyle, + ] + return metaText + }() + + // output + let contentWarningContent = PassthroughSubject() + + override func prepareForReuse() { + super.prepareForReuse() + + metaText.delegate = nil + metaText.textView.delegate = nil + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeStatusContentTableViewCell { + + private func _init() { + selectionStyle = .none + layer.zPosition = 999 + backgroundColor = .clear + preservesSuperviewLayoutMargins = true + + let containerStackView = UIStackView() + containerStackView.axis = .vertical + containerStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + containerStackView.preservesSuperviewLayoutMargins = true + + containerStackView.addArrangedSubview(statusContentWarningEditorView) + statusContentWarningEditorView.setContentHuggingPriority(.required - 1, for: .vertical) + + let statusContainerView = UIView() + statusContainerView.preservesSuperviewLayoutMargins = true + containerStackView.addArrangedSubview(statusContainerView) + statusView.translatesAutoresizingMaskIntoConstraints = false + statusContainerView.addSubview(statusView) + NSLayoutConstraint.activate([ + statusView.topAnchor.constraint(equalTo: statusContainerView.topAnchor, constant: 20), + statusView.leadingAnchor.constraint(equalTo: statusContainerView.layoutMarginsGuide.leadingAnchor), + statusView.trailingAnchor.constraint(equalTo: statusContainerView.layoutMarginsGuide.trailingAnchor), + statusView.bottomAnchor.constraint(equalTo: statusContainerView.bottomAnchor), + ]) + + containerStackView.addArrangedSubview(textEditorViewContainerView) + metaText.textView.translatesAutoresizingMaskIntoConstraints = false + textEditorViewContainerView.addSubview(metaText.textView) + NSLayoutConstraint.activate([ + metaText.textView.topAnchor.constraint(equalTo: textEditorViewContainerView.topAnchor), + metaText.textView.leadingAnchor.constraint(equalTo: textEditorViewContainerView.layoutMarginsGuide.leadingAnchor), + metaText.textView.trailingAnchor.constraint(equalTo: textEditorViewContainerView.layoutMarginsGuide.trailingAnchor), + metaText.textView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor), + metaText.textView.heightAnchor.constraint(greaterThanOrEqualToConstant: 88).priority(.defaultHigh), + ]) + statusContentWarningEditorView.textView.delegate = self + + statusView.nameTrialingDotLabel.isHidden = true + statusView.dateLabel.isHidden = true + statusContentWarningEditorView.isHidden = true + statusView.statusContainerStackView.isHidden = true + } + +} + +// MARK: - UITextViewDelegate +extension ComposeStatusContentTableViewCell: UITextViewDelegate { + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + switch textView { + case statusContentWarningEditorView.textView: + // disable input line break + guard text != "\n" else { return false } + return true + default: + assertionFailure() + return true + } + } + + func textViewDidChange(_ textView: UITextView) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textView.text) + guard textView === statusContentWarningEditorView.textView else { return } + // replace line break with space + textView.text = textView.text.replacingOccurrences(of: "\n", with: " ") + contentWarningContent.send(textView.text) + } + +} + diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift new file mode 100644 index 000000000..c12c346f3 --- /dev/null +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusPollTableViewCell.swift @@ -0,0 +1,184 @@ +// +// ComposeStatusPollTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-29. +// + +import UIKit + +protocol ComposeStatusPollTableViewCellDelegate: AnyObject { + func composeStatusPollTableViewCell(_ cell: ComposeStatusPollTableViewCell, pollOptionAttributesDidReorder options: [ComposeStatusPollItem.PollOptionAttribute]) +} + +final class ComposeStatusPollTableViewCell: UITableViewCell { + + private(set) var dataSource: UICollectionViewDiffableDataSource! + var observations = Set() + + weak var customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel? + weak var delegate: ComposeStatusPollTableViewCellDelegate? + weak var composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate? + weak var composeStatusPollOptionAppendEntryCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate? + weak var composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate? + + + private static func createLayout() -> UICollectionViewLayout { + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) + let section = NSCollectionLayoutSection(group: group) + section.contentInsetsReference = .readableContent + return UICollectionViewCompositionalLayout(section: section) + } + + private(set) var collectionViewHeightLayoutConstraint: NSLayoutConstraint! + let collectionView: UICollectionView = { + let collectionViewLayout = ComposeStatusPollTableViewCell.createLayout() + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) + collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self)) + collectionView.register(ComposeStatusPollOptionAppendEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self)) + collectionView.register(ComposeStatusPollExpiresOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self)) + collectionView.backgroundColor = Asset.Scene.Compose.background.color + collectionView.alwaysBounceVertical = true + collectionView.isScrollEnabled = false + return collectionView + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeStatusPollTableViewCell { + + private func _init() { + collectionView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(collectionView) + collectionViewHeightLayoutConstraint = collectionView.heightAnchor.constraint(equalToConstant: 300).priority(.defaultHigh) + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: contentView.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + collectionViewHeightLayoutConstraint, + ]) + + let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeStatusPollTableViewCell.longPressReorderGestureHandler(_:))) + collectionView.addGestureRecognizer(longPressReorderGesture) + + collectionView.observe(\.contentSize, options: [.initial, .new]) { [weak self] collectionView, _ in + guard let self = self else { return } + print(collectionView.contentSize) + self.collectionViewHeightLayoutConstraint.constant = collectionView.contentSize.height + } + .store(in: &observations) + + self.dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [ + weak self + ] collectionView, indexPath, item -> UICollectionViewCell? in + guard let self = self else { return UICollectionViewCell() } + + switch item { + case .pollOption(let attribute): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionCollectionViewCell + cell.pollOptionView.optionTextField.text = attribute.option.value + cell.pollOptionView.optionTextField.placeholder = L10n.Scene.Compose.Poll.optionNumber(indexPath.item + 1) + cell.pollOption + .receive(on: DispatchQueue.main) + .assign(to: \.value, on: attribute.option) + .store(in: &cell.disposeBag) + cell.delegate = self.composeStatusPollOptionCollectionViewCellDelegate + if let customEmojiPickerInputViewModel = self.customEmojiPickerInputViewModel { + ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.pollOptionView.optionTextField, disposeBag: &cell.disposeBag) + } + return cell + case .pollOptionAppendEntry: + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionAppendEntryCollectionViewCell + cell.delegate = self.composeStatusPollOptionAppendEntryCollectionViewCellDelegate + return cell + case .pollExpiresOption(let attribute): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollExpiresOptionCollectionViewCell + cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(attribute.expiresOption.value.title), for: .normal) + attribute.expiresOption + .receive(on: DispatchQueue.main) + .sink { [weak cell] expiresOption in + guard let cell = cell else { return } + cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(expiresOption.title), for: .normal) + } + .store(in: &cell.disposeBag) + cell.delegate = self.composeStatusPollExpiresOptionCollectionViewCellDelegate + return cell + } + } + + dataSource.reorderingHandlers.canReorderItem = { item in + switch item { + case .pollOption: return true + default: return false + } + } + + // update reordered data source + dataSource.reorderingHandlers.didReorder = { [weak self] transaction in + guard let self = self else { return } + + let items = transaction.finalSnapshot.itemIdentifiers + var pollOptionAttributes: [ComposeStatusPollItem.PollOptionAttribute] = [] + for item in items { + guard case let .pollOption(attribute) = item else { continue } + pollOptionAttributes.append(attribute) + } + self.delegate?.composeStatusPollTableViewCell(self, pollOptionAttributesDidReorder: pollOptionAttributes) + } + } + +} + +extension ComposeStatusPollTableViewCell { + + @objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) { + switch(sender.state) { + case .began: + guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), + let cell = collectionView.cellForItem(at: selectedIndexPath) as? ComposeStatusPollOptionCollectionViewCell else { + break + } + // check if pressing reorder bar no not + let locationInCell = sender.location(in: cell) + guard cell.reorderBarImageView.frame.contains(locationInCell) else { + return + } + + collectionView.beginInteractiveMovementForItem(at: selectedIndexPath) + case .changed: + guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), + let dataSource = self.dataSource else { + break + } + guard let item = dataSource.itemIdentifier(for: selectedIndexPath), + case .pollOption = item else { + collectionView.cancelInteractiveMovement() + return + } + + var position = sender.location(in: collectionView) + position.x = collectionView.frame.width * 0.5 + collectionView.updateInteractiveMovementTargetPosition(position) + case .ended: + collectionView.endInteractiveMovement() + collectionView.reloadData() + default: + collectionView.cancelInteractiveMovement() + } + } + +} diff --git a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift index eb5f01f41..71893c474 100644 --- a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift +++ b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift @@ -19,6 +19,8 @@ final class AttachmentContainerView: UIView { let previewImageView: UIImageView = { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill + imageView.layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius + imageView.layer.cornerCurve = .continuous imageView.layer.masksToBounds = true return imageView }() diff --git a/Mastodon/Scene/Compose/View/ComposeCollectionView.swift b/Mastodon/Scene/Compose/View/ComposeTableView.swift similarity index 80% rename from Mastodon/Scene/Compose/View/ComposeCollectionView.swift rename to Mastodon/Scene/Compose/View/ComposeTableView.swift index 2dc03bb84..9d95df03a 100644 --- a/Mastodon/Scene/Compose/View/ComposeCollectionView.swift +++ b/Mastodon/Scene/Compose/View/ComposeTableView.swift @@ -1,22 +1,22 @@ // -// ComposeCollectionView.swift +// ComposeTableView.swift // Mastodon // -// Created by MainasuK Cirno on 2021-5-17. +// Created by MainasuK Cirno on 2021-6-28. // import UIKit -final class ComposeCollectionView: UICollectionView { - +final class ComposeTableView: UITableView { + weak var autoCompleteViewController: AutoCompleteViewController? - + // adjust hitTest for auto-complete override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { guard let autoCompleteViewController = autoCompleteViewController else { return super.hitTest(point, with: event) } - + let thePoint = convert(point, to: autoCompleteViewController.view) if let hitView = autoCompleteViewController.view.hitTest(thePoint, with: event) { return hitView @@ -24,5 +24,5 @@ final class ComposeCollectionView: UICollectionView { return super.hitTest(point, with: event) } } - + } diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index 1dd19c552..3edb17e41 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -202,15 +202,6 @@ extension ComposeToolbarView { } } - func imageNameForTimeline() -> String { - switch self { - case .public: return "globe" - // case .unlisted: return "eye.slash" - case .private: return "person.3" - case .direct: return "at" - } - } - var visibility: Mastodon.Entity.Status.Visibility { switch self { case .public: return .public diff --git a/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift b/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift index 8314dfc3f..760203a39 100644 --- a/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift +++ b/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift @@ -46,8 +46,23 @@ extension CustomEmojiPickerInputViewModel { removeEmptyReferences() for reference in customEmojiReplaceableTextInputReferences { - guard reference.value?.isFirstResponder == true else { continue } - reference.value?.insertText(text) + guard let textInput = reference.value else { continue } + guard textInput.isFirstResponder == true else { continue } + + let selectedTextRange = textInput.selectedTextRange + textInput.insertText(text) + + // due to insert text render as attachment + // the cursor reset logic not works + // hack with hard code +2 offset + assert(text.hasSuffix(": ")) + if text.hasPrefix(":") && text.hasSuffix(": "), + let selectedTextRange = selectedTextRange, + let newPosition = textInput.position(from: selectedTextRange.start, offset: 2) { + let newSelectedTextRange = textInput.textRange(from: newPosition, to: newPosition) + textInput.selectedTextRange = newSelectedTextRange + } + return reference } diff --git a/Mastodon/Scene/Compose/View/ReplicaStatusView.swift b/Mastodon/Scene/Compose/View/ReplicaStatusView.swift new file mode 100644 index 000000000..0f53b113a --- /dev/null +++ b/Mastodon/Scene/Compose/View/ReplicaStatusView.swift @@ -0,0 +1,266 @@ +// +// ReplicaStatusView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-29. +// + +import os.log +import UIKit +import ActiveLabel +import FLAnimatedImage +import MetaTextView + +final class ReplicaStatusView: UIView { + + static let avatarImageSize = CGSize(width: 42, height: 42) + static let avatarImageCornerRadius: CGFloat = 4 + static let avatarToLabelSpacing: CGFloat = 5 + static let contentWarningBlurRadius: CGFloat = 12 + static let containerStackViewSpacing: CGFloat = 10 + + let containerStackView = UIStackView() + let headerContainerView = UIView() + let authorContainerView = UIView() + + static let reblogIconImage: UIImage = { + let font = UIFont.systemFont(ofSize: 13, weight: .medium) + let configuration = UIImage.SymbolConfiguration(font: font) + let image = UIImage(systemName: "arrow.2.squarepath", withConfiguration: configuration)!.withTintColor(Asset.Colors.Label.secondary.color) + return image + }() + + static let replyIconImage: UIImage = { + let font = UIFont.systemFont(ofSize: 13, weight: .medium) + let configuration = UIImage.SymbolConfiguration(font: font) + let image = UIImage(systemName: "arrowshape.turn.up.left.fill", withConfiguration: configuration)!.withTintColor(Asset.Colors.Label.secondary.color) + return image + }() + + static func iconAttributedString(image: UIImage) -> NSAttributedString { + let attributedString = NSMutableAttributedString() + let imageTextAttachment = NSTextAttachment() + let imageAttribute = NSAttributedString(attachment: imageTextAttachment) + imageTextAttachment.image = image + attributedString.append(imageAttribute) + return attributedString + } + + let headerIconLabel: UILabel = { + let label = UILabel() + label.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage) + return label + }() + + let headerInfoLabel: ActiveLabel = { + let label = ActiveLabel(style: .statusHeader) + label.text = "Bob reblogged" + label.layer.masksToBounds = false + return label + }() + + let avatarView: UIView = { + let view = UIView() + view.isAccessibilityElement = true + view.accessibilityTraits = .button + view.accessibilityLabel = L10n.Common.Controls.Status.showUserProfile + return view + }() + let avatarImageView: UIImageView = FLAnimatedImageView() + let avatarStackedContainerButton: AvatarStackContainerButton = AvatarStackContainerButton() + + let nameLabel: ActiveLabel = { + let label = ActiveLabel(style: .statusName) + return label + }() + + let nameTrialingDotLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = .systemFont(ofSize: 17) + label.text = "·" + label.isAccessibilityElement = false + return label + }() + + let usernameLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 15, weight: .regular) + label.textColor = Asset.Colors.Label.secondary.color + label.text = "@alice" + label.isAccessibilityElement = false + return label + }() + + let dateLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = Asset.Colors.Label.secondary.color + label.text = "1d" + return label + }() + + let contentMetaText: MetaText = { + let metaText = MetaText() + metaText.textView.backgroundColor = .clear + metaText.textView.isEditable = false + metaText.textView.isSelectable = false + metaText.textView.isScrollEnabled = false + metaText.textView.textContainer.lineFragmentPadding = 0 + metaText.textView.textContainerInset = .zero + metaText.textView.layer.masksToBounds = false + return metaText + }() + + let statusContainerStackView = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ReplicaStatusView { + private func _init() { + // container: [reblog | author | status | action toolbar] + // note: do not set spacing for nested stackView to avoid SDK layout conflict issue + containerStackView.axis = .vertical + // containerStackView.spacing = 10 + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor), + bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor), + ]) + containerStackView.setContentHuggingPriority(.required - 1, for: .vertical) + containerStackView.setContentCompressionResistancePriority(.required - 1, for: .vertical) + + // header container: [icon | info] + let headerContainerStackView = UIStackView() + headerContainerStackView.axis = .horizontal + headerContainerStackView.spacing = 4 + headerContainerStackView.addArrangedSubview(headerIconLabel) + headerContainerStackView.addArrangedSubview(headerInfoLabel) + headerIconLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + headerContainerStackView.translatesAutoresizingMaskIntoConstraints = false + headerContainerView.addSubview(headerContainerStackView) + NSLayoutConstraint.activate([ + headerContainerStackView.topAnchor.constraint(equalTo: headerContainerView.topAnchor), + headerContainerStackView.leadingAnchor.constraint(equalTo: headerContainerView.leadingAnchor), + headerContainerStackView.trailingAnchor.constraint(equalTo: headerContainerView.trailingAnchor), + headerContainerView.bottomAnchor.constraint(equalTo: headerContainerStackView.bottomAnchor, constant: StatusView.containerStackViewSpacing).priority(.defaultHigh), + ]) + containerStackView.addArrangedSubview(headerContainerView) + defer { + containerStackView.bringSubviewToFront(headerContainerView) + } + + // author container: [avatar | author meta container | reveal button] + let authorContainerStackView = UIStackView() + authorContainerStackView.axis = .horizontal + authorContainerStackView.spacing = StatusView.avatarToLabelSpacing + authorContainerStackView.distribution = .fill + + // avatar + avatarView.translatesAutoresizingMaskIntoConstraints = false + authorContainerStackView.addArrangedSubview(avatarView) + NSLayoutConstraint.activate([ + avatarView.widthAnchor.constraint(equalToConstant: StatusView.avatarImageSize.width).priority(.required - 1), + avatarView.heightAnchor.constraint(equalToConstant: StatusView.avatarImageSize.height).priority(.required - 1), + ]) + avatarImageView.translatesAutoresizingMaskIntoConstraints = false + avatarView.addSubview(avatarImageView) + NSLayoutConstraint.activate([ + avatarImageView.topAnchor.constraint(equalTo: avatarView.topAnchor), + avatarImageView.leadingAnchor.constraint(equalTo: avatarView.leadingAnchor), + avatarImageView.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor), + avatarImageView.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor), + ]) + avatarStackedContainerButton.translatesAutoresizingMaskIntoConstraints = false + avatarView.addSubview(avatarStackedContainerButton) + NSLayoutConstraint.activate([ + avatarStackedContainerButton.topAnchor.constraint(equalTo: avatarView.topAnchor), + avatarStackedContainerButton.leadingAnchor.constraint(equalTo: avatarView.leadingAnchor), + avatarStackedContainerButton.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor), + avatarStackedContainerButton.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor), + ]) + + // author meta container: [title container | subtitle container] + let authorMetaContainerStackView = UIStackView() + authorContainerStackView.addArrangedSubview(authorMetaContainerStackView) + authorMetaContainerStackView.axis = .vertical + authorMetaContainerStackView.spacing = 4 + + // title container: [display name | "·" | date | padding] + let titleContainerStackView = UIStackView() + authorMetaContainerStackView.addArrangedSubview(titleContainerStackView) + titleContainerStackView.axis = .horizontal + titleContainerStackView.spacing = 4 + nameLabel.translatesAutoresizingMaskIntoConstraints = false + titleContainerStackView.addArrangedSubview(nameLabel) + NSLayoutConstraint.activate([ + nameLabel.heightAnchor.constraint(equalToConstant: 22).priority(.defaultHigh), + ]) + titleContainerStackView.alignment = .firstBaseline + titleContainerStackView.addArrangedSubview(nameTrialingDotLabel) + titleContainerStackView.addArrangedSubview(dateLabel) + let padding = UIView() + titleContainerStackView.addArrangedSubview(padding) // padding + nameLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) + nameTrialingDotLabel.setContentHuggingPriority(.defaultHigh + 2, for: .horizontal) + nameTrialingDotLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal) + dateLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + dateLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) + padding.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) + padding.setContentCompressionResistancePriority(.defaultLow - 1, for: .horizontal) + + // subtitle container: [username] + let subtitleContainerStackView = UIStackView() + authorMetaContainerStackView.addArrangedSubview(subtitleContainerStackView) + subtitleContainerStackView.axis = .horizontal + subtitleContainerStackView.addArrangedSubview(usernameLabel) + + authorContainerStackView.translatesAutoresizingMaskIntoConstraints = false + authorContainerView.addSubview(authorContainerStackView) + NSLayoutConstraint.activate([ + authorContainerStackView.topAnchor.constraint(equalTo: authorContainerView.topAnchor), + authorContainerStackView.leadingAnchor.constraint(equalTo: authorContainerView.leadingAnchor), + authorContainerStackView.trailingAnchor.constraint(equalTo: authorContainerView.trailingAnchor), + authorContainerView.bottomAnchor.constraint(equalTo: authorContainerStackView.bottomAnchor, constant: StatusView.containerStackViewSpacing).priority(.defaultHigh), + ]) + containerStackView.addArrangedSubview(authorContainerView) + + // status container: [status] + containerStackView.addArrangedSubview(statusContainerStackView) + statusContainerStackView.axis = .vertical + statusContainerStackView.spacing = 10 + + // avoid overlay behind other views + defer { + containerStackView.bringSubviewToFront(authorContainerView) + } + + // status + statusContainerStackView.addArrangedSubview(contentMetaText.textView) + contentMetaText.textView.setContentCompressionResistancePriority(.required - 1, for: .vertical) + + avatarStackedContainerButton.isHidden = true + } +} + +// MARK: - AvatarConfigurableView +extension ReplicaStatusView: AvatarConfigurableView { + static var configurableAvatarImageSize: CGSize { return Self.avatarImageSize } + static var configurableAvatarImageCornerRadius: CGFloat { return 4 } + var configurableAvatarImageView: UIImageView? { avatarImageView } + var configurableAvatarButton: UIButton? { nil } +} diff --git a/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift b/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift index 510edd464..d782f7024 100644 --- a/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift +++ b/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift @@ -8,19 +8,12 @@ import UIKit final class StatusContentWarningEditorView: UIView { - - let containerView: UIView = { - let view = UIView() - view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color - return view - }() - - // due to section following readable inset. We overlap the bleeding to make backgorund fill + + // due to section following readable inset. We overlap the bleeding to make background fill // default hidden let containerBackgroundView: UIView = { let view = UIView() view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color - view.isHidden = true return view }() @@ -55,44 +48,38 @@ final class StatusContentWarningEditorView: UIView { extension StatusContentWarningEditorView { private func _init() { - let contentWarningStackView = UIStackView() - contentWarningStackView.axis = .horizontal - contentWarningStackView.translatesAutoresizingMaskIntoConstraints = false - addSubview(contentWarningStackView) - NSLayoutConstraint.activate([ - contentWarningStackView.topAnchor.constraint(equalTo: topAnchor), - contentWarningStackView.leadingAnchor.constraint(equalTo: leadingAnchor), - contentWarningStackView.trailingAnchor.constraint(equalTo: trailingAnchor), - contentWarningStackView.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - contentWarningStackView.addArrangedSubview(containerView) - + containerBackgroundView.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(containerBackgroundView) + addSubview(containerBackgroundView) NSLayoutConstraint.activate([ - containerBackgroundView.topAnchor.constraint(equalTo: containerView.topAnchor), - containerBackgroundView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: -1024), - containerBackgroundView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 1024), - containerBackgroundView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + containerBackgroundView.topAnchor.constraint(equalTo: topAnchor), + containerBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -1024), + containerBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 1024), + containerBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) iconImageView.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(iconImageView) + addSubview(iconImageView) NSLayoutConstraint.activate([ - iconImageView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), - iconImageView.leadingAnchor.constraint(equalTo: containerView.readableContentGuide.leadingAnchor), + iconImageView.centerYAnchor.constraint(equalTo: centerYAnchor), + iconImageView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), iconImageView.widthAnchor.constraint(equalToConstant: StatusView.avatarImageSize.width).priority(.defaultHigh), // center alignment to avatar ]) - iconImageView.setContentHuggingPriority(.defaultHigh, for: .horizontal) + iconImageView.setContentHuggingPriority(.required - 2, for: .horizontal) textView.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(textView) + addSubview(textView) NSLayoutConstraint.activate([ - textView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 6), - textView.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: StatusView.avatarToLabelSpacing - 4), // align to name label. minus magic 4pt to remove addtion inset - textView.trailingAnchor.constraint(equalTo: containerView.readableContentGuide.trailingAnchor), - containerView.bottomAnchor.constraint(equalTo: textView.bottomAnchor, constant: 6), + textView.centerYAnchor.constraint(equalTo: centerYAnchor), + textView.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: 6).priority(.required - 1), + textView.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: StatusView.avatarToLabelSpacing - 4), // align to name label. minus magic 4pt to remove addition inset + textView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + bottomAnchor.constraint(greaterThanOrEqualTo: textView.bottomAnchor, constant: 6).priority(.required - 1), + //textView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), ]) + + textView.setContentHuggingPriority(.required - 1, for: .vertical) + textView.setContentCompressionResistancePriority(.required - 1, for: .vertical) } } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index eb7753337..ee5c44971 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -56,7 +56,7 @@ extension HashtagTimelineViewController { super.viewDidLoad() title = "#\(viewModel.hashtag)" - titleView.update(title: viewModel.hashtag, subtitle: nil) + titleView.update(title: viewModel.hashtag, subtitle: nil, emojiDict: [:]) navigationItem.titleView = titleView view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color @@ -143,7 +143,7 @@ extension HashtagTimelineViewController { private func updatePromptTitle() { var subtitle: String? defer { - titleView.update(title: "#" + viewModel.hashtag, subtitle: subtitle) + titleView.update(title: "#" + viewModel.hashtag, subtitle: subtitle, emojiDict: [:]) } guard let histories = viewModel.hashtagEntity.value?.history else { return diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 322f804af..6004c48df 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -93,6 +93,7 @@ extension NotificationViewController { .receive(on: RunLoop.main) .sink { [weak self] in guard let self = self else { return } + guard self.viewModel.needsScrollToTopAfterDataSourceUpdate else { return } self.viewModel.needsScrollToTopAfterDataSourceUpdate = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.33) { self.scrollToTop(animated: true) @@ -106,6 +107,9 @@ extension NotificationViewController { .sink { [weak self] segment in guard let self = self else { return } self.segmentControl.selectedSegmentIndex = segment.rawValue + + // trigger scroll-to-top after data reload + self.viewModel.needsScrollToTopAfterDataSourceUpdate = true guard let domain = self.viewModel.activeMastodonAuthenticationBox.value?.domain, let userID = self.viewModel.activeMastodonAuthenticationBox.value?.userID else { return diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index f6ca84c95..3fb93d89c 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -9,6 +9,8 @@ import Combine import Foundation import UIKit import ActiveLabel +import MetaTextView +import Meta final class NotificationStatusTableViewCell: UITableViewCell, StatusCell { static let actionImageBorderWidth: CGFloat = 2 @@ -255,6 +257,10 @@ extension NotificationStatusTableViewCell: StatusViewDelegate { func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { // do nothing } + + func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { + // do nothing + } } diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 46c5e234d..012f173d9 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -44,7 +44,6 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency tableView.backgroundColor = .clear tableView.keyboardDismissMode = .onDrag tableView.translatesAutoresizingMaskIntoConstraints = false - return tableView }() @@ -319,7 +318,6 @@ extension MastodonPickServerViewController { ) self.mastodonAuthenticationController = authenticationController - authenticationController.authenticationSession?.prefersEphemeralWebBrowserSession = true authenticationController.authenticationSession?.presentationContextProvider = self authenticationController.authenticationSession?.start() diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift index 46b88796e..d34240a85 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift @@ -49,7 +49,7 @@ extension FavoriteViewController { view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color navigationItem.titleView = titleView - titleView.update(title: L10n.Scene.Favorite.title, subtitle: nil) + titleView.update(title: L10n.Scene.Favorite.title, subtitle: nil, emojiDict: [:]) tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 48470a241..94be3e6f5 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -13,6 +13,7 @@ import ActiveLabel import AlamofireImage import CropViewController import TwitterTextEditor +import MastodonMeta protocol ProfileHeaderViewControllerDelegate: AnyObject { func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) @@ -166,14 +167,27 @@ extension ProfileHeaderViewController { ) } .store(in: &disposeBag) - Publishers.CombineLatest3( - viewModel.isEditing.eraseToAnyPublisher(), - viewModel.displayProfileInfo.name.removeDuplicates().eraseToAnyPublisher(), - viewModel.editProfileInfo.name.removeDuplicates().eraseToAnyPublisher() + Publishers.CombineLatest4( + viewModel.isEditing, + viewModel.displayProfileInfo.name.removeDuplicates(), + viewModel.editProfileInfo.name.removeDuplicates(), + viewModel.emojiDict ) .receive(on: DispatchQueue.main) - .sink { [weak self] isEditing, name, editingName in + .sink { [weak self] isEditing, name, editingName, emojiDict in guard let self = self else { return } + do { + var emojis = MastodonContent.Emojis() + for (key, value) in emojiDict { + emojis[key] = value.absoluteString + } + let metaContent = try MastodonMetaContent.convert( + document: MastodonContent(content: name ?? " ", emojis: emojis) + ) + self.profileHeaderView.nameMetaText.configure(content: metaContent) + } catch { + assertionFailure() + } self.profileHeaderView.nameTextField.text = isEditing ? editingName : name } .store(in: &disposeBag) @@ -412,7 +426,7 @@ extension ProfileHeaderViewController { profileHeaderView.avatarImageView.alpha = alpha profileHeaderView.editAvatarBackgroundView.alpha = alpha profileHeaderView.nameTextFieldBackgroundView.alpha = alpha - profileHeaderView.nameTextField.alpha = alpha + profileHeaderView.displayNameStackView.alpha = alpha profileHeaderView.usernameLabel.alpha = alpha } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index f9f2e98d4..abeac9855 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -10,6 +10,7 @@ import UIKit import ActiveLabel import TwitterTextEditor import FLAnimatedImage +import MetaTextView protocol ProfileHeaderViewDelegate: AnyObject { func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView) @@ -111,7 +112,24 @@ final class ProfileHeaderView: UIView { view.layer.cornerRadius = 10 return view }() - + + let displayNameStackView = UIStackView() + let nameMetaText: MetaText = { + let metaText = MetaText() + metaText.textView.backgroundColor = .clear + metaText.textView.isEditable = false + metaText.textView.isSelectable = false + metaText.textView.isScrollEnabled = false + metaText.textView.layer.masksToBounds = false + metaText.textView.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold), maximumPointSize: 28) + metaText.textView.textColor = .white + metaText.textView.textContainer.lineFragmentPadding = 0 + metaText.textAttributes = [ + .font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold), maximumPointSize: 28), + .foregroundColor: UIColor.white + ] + return metaText + }() let nameTextField: UITextField = { let textField = UITextField() textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold), maximumPointSize: 28) @@ -303,7 +321,6 @@ extension ProfileHeaderView { nameContainerStackView.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor), ]) - let displayNameStackView = UIStackView() displayNameStackView.axis = .horizontal nameTextField.translatesAutoresizingMaskIntoConstraints = false displayNameStackView.addArrangedSubview(nameTextField) @@ -321,6 +338,16 @@ extension ProfileHeaderView { ]) displayNameStackView.bringSubviewToFront(nameTextField) displayNameStackView.addArrangedSubview(UIView()) + + // overlay meta text for display name + nameMetaText.textView.translatesAutoresizingMaskIntoConstraints = false + displayNameStackView.addSubview(nameMetaText.textView) + NSLayoutConstraint.activate([ + nameMetaText.textView.topAnchor.constraint(equalTo: nameTextField.topAnchor), + nameMetaText.textView.leadingAnchor.constraint(equalTo: nameTextField.leadingAnchor), + nameMetaText.textView.trailingAnchor.constraint(equalTo: nameTextField.trailingAnchor), + nameMetaText.textView.bottomAnchor.constraint(equalTo: nameTextField.bottomAnchor), + ]) nameContainerStackView.addArrangedSubview(displayNameStackView) nameContainerStackView.addArrangedSubview(usernameLabel) @@ -436,6 +463,8 @@ extension ProfileHeaderView { switch state { case .normal: + nameMetaText.textView.alpha = 1 + nameTextField.alpha = 0 nameTextField.isEnabled = false bioActiveLabelContainer.isHidden = false bioTextEditorView.isHidden = true @@ -449,7 +478,9 @@ extension ProfileHeaderView { self.editAvatarBackgroundView.isHidden = true } case .editing: + nameMetaText.textView.alpha = 0 nameTextField.isEnabled = true + nameTextField.alpha = 1 bioActiveLabelContainer.isHidden = true bioTextEditorView.isHidden = false diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 476d03a9d..7b6a1db86 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -303,12 +303,13 @@ extension ProfileViewController { profileSegmentedViewController.pagingViewController.pagingDelegate = self // bind view model - Publishers.CombineLatest( - viewModel.name.eraseToAnyPublisher(), - viewModel.statusesCount.eraseToAnyPublisher() + Publishers.CombineLatest3( + viewModel.name, + viewModel.emojiDict, + viewModel.statusesCount ) .receive(on: DispatchQueue.main) - .sink { [weak self] name, statusesCount in + .sink { [weak self] name, emojiDict, statusesCount in guard let self = self else { return } guard let title = name, let statusesCount = statusesCount, let formattedStatusCount = MastodonMetricFormatter().string(from: statusesCount) else { @@ -316,7 +317,7 @@ extension ProfileViewController { return } let subtitle = L10n.Scene.Profile.subtitle(formattedStatusCount) - self.titleView.update(title: title, subtitle: subtitle) + self.titleView.update(title: title, subtitle: subtitle, emojiDict: emojiDict) self.titleView.isHidden = false } .store(in: &disposeBag) @@ -368,7 +369,7 @@ extension ProfileViewController { .receive(on: DispatchQueue.main) .assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.name) .store(in: &disposeBag) - viewModel.fileds + viewModel.fields .removeDuplicates() .map { fields -> [ProfileFieldItem.FieldValue] in fields.map { ProfileFieldItem.FieldValue(name: $0.name, value: $0.value) } diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index ddd1ee291..45a2386be 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -39,7 +39,7 @@ class ProfileViewModel: NSObject { let statusesCount: CurrentValueSubject let followingCount: CurrentValueSubject let followersCount: CurrentValueSubject - let fileds: CurrentValueSubject<[Mastodon.Entity.Field], Never> + let fields: CurrentValueSubject<[Mastodon.Entity.Field], Never> let emojiDict: CurrentValueSubject // fulfill this before editing @@ -82,7 +82,7 @@ class ProfileViewModel: NSObject { self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) }) self.protected = CurrentValueSubject(mastodonUser?.locked) self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false) - self.fileds = CurrentValueSubject(mastodonUser?.fields ?? []) + self.fields = CurrentValueSubject(mastodonUser?.fields ?? []) self.emojiDict = CurrentValueSubject(mastodonUser?.emojiDict ?? [:]) super.init() @@ -257,7 +257,7 @@ extension ProfileViewModel { self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) } self.protected.value = mastodonUser?.locked self.suspended.value = mastodonUser?.suspended ?? false - self.fileds.value = mastodonUser?.fields ?? [] + self.fields.value = mastodonUser?.fields ?? [] self.emojiDict.value = mastodonUser?.emojiDict ?? [:] } diff --git a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift index 3a71a64b6..f5fa003a3 100644 --- a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift +++ b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift @@ -12,6 +12,8 @@ import Combine import CoreData import CoreDataStack import ActiveLabel +import Meta +import MetaTextView final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { @@ -203,4 +205,8 @@ extension ReportedStatusTableViewCell: StatusViewDelegate { func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { } + + func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { + } + } diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift index d76cb24bd..b69914dbe 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -10,6 +10,7 @@ import CoreDataStack import Foundation import MastodonSDK import UIKit +import ActiveLabel protocol SearchRecommendAccountsCollectionViewCellDelegate: NSObject { func followButtonDidPressed(clickedUser: MastodonUser) @@ -42,8 +43,8 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) - let displayNameLabel: UILabel = { - let label = UILabel() + let displayNameLabel: ActiveLabel = { + let label = ActiveLabel(style: .statusName) label.textColor = .white label.textAlignment = .center label.font = .systemFont(ofSize: 18, weight: .semibold) @@ -164,7 +165,7 @@ extension SearchRecommendAccountsCollectionViewCell { } func config(with mastodonUser: MastodonUser) { - displayNameLabel.text = mastodonUser.displayName.isEmpty ? mastodonUser.username : mastodonUser.displayName + displayNameLabel.configure(content: mastodonUser.displayNameWithFallback, emojiDict: mastodonUser.emojiDict) acctLabel.text = "@" + mastodonUser.acct avatarImageView.af.setImage( withURL: URL(string: mastodonUser.avatar)!, diff --git a/Mastodon/Scene/Search/SearchViewController+Follow.swift b/Mastodon/Scene/Search/SearchViewController+Follow.swift index c31f6d82a..c345336df 100644 --- a/Mastodon/Scene/Search/SearchViewController+Follow.swift +++ b/Mastodon/Scene/Search/SearchViewController+Follow.swift @@ -20,14 +20,13 @@ extension SearchViewController: UserProvider { func mastodonUser() -> Future { Future { promise in - promise(.success(self.viewModel.mastodonUser.value)) + promise(.success(nil)) } } } extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegate { func followButtonDidPressed(clickedUser: MastodonUser) { - viewModel.mastodonUser.value = clickedUser guard let currentMastodonUser = viewModel.currentMastodonUser.value else { return } @@ -36,17 +35,17 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat case .none: break case .follow, .following: - UserProviderFacade.toggleUserFollowRelationship(provider: self) + UserProviderFacade.toggleUserFollowRelationship(provider: self, mastodonUser: clickedUser) .sink { _ in - + // error handling } receiveValue: { _ in + // success } .store(in: &disposeBag) case .pending: break case .muting: - guard let mastodonUser = viewModel.mastodonUser.value else { return } - let name = mastodonUser.displayNameWithFallback + let name = clickedUser.displayNameWithFallback let alertController = UIAlertController( title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title, message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(name), @@ -54,7 +53,7 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat ) let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unmute, style: .default) { [weak self] _ in guard let self = self else { return } - UserProviderFacade.toggleUserMuteRelationship(provider: self, cell: nil) + UserProviderFacade.toggleUserMuteRelationship(provider: self, mastodonUser: clickedUser) .sink { _ in // do nothing } receiveValue: { _ in @@ -67,8 +66,7 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat alertController.addAction(cancelAction) present(alertController, animated: true, completion: nil) case .blocking: - guard let mastodonUser = viewModel.mastodonUser.value else { return } - let name = mastodonUser.displayNameWithFallback + let name = clickedUser.displayNameWithFallback let alertController = UIAlertController( title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.title, message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.message(name), @@ -76,7 +74,7 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat ) let unblockAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unblock, style: .default) { [weak self] _ in guard let self = self else { return } - UserProviderFacade.toggleUserBlockRelationship(provider: self, cell: nil) + UserProviderFacade.toggleUserBlockRelationship(provider: self, mastodonUser: clickedUser) .sink { _ in // do nothing } receiveValue: { _ in diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 3731f118b..10dd46119 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -151,6 +151,12 @@ extension SearchViewController { view.bringSubviewToFront(statusBar) } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + viewModel.viewDidAppeared.send() + } + func setupSearchBar() { searchBar.delegate = self view.addSubview(searchBar) diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index e10b04c9e..5c443e09a 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -21,8 +21,8 @@ final class SearchViewModel: NSObject { let context: AppContext weak var coordinator: SceneCoordinator! - let mastodonUser = CurrentValueSubject(nil) let currentMastodonUser = CurrentValueSubject(nil) + let viewDidAppeared = PassthroughSubject() // output let searchText = CurrentValueSubject("") @@ -32,7 +32,7 @@ final class SearchViewModel: NSObject { let searchResult = CurrentValueSubject(nil) - var recommendHashTags = [Mastodon.Entity.Tag]() + // var recommendHashTags = [Mastodon.Entity.Tag]() var recommendAccounts = [NSManagedObjectID]() var recommendAccountsFallback = PassthroughSubject() @@ -60,11 +60,7 @@ final class SearchViewModel: NSObject { self.coordinator = coordinator self.context = context super.init() - - guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - + // bind active authentication context.authenticationService.activeMastodonAuthentication .sink { [weak self] activeMastodonAuthentication in @@ -85,26 +81,43 @@ final class SearchViewModel: NSObject { .filter { text, _ in !text.isEmpty } - .flatMap { (text, scope) -> AnyPublisher, Error> in - - let query = Mastodon.API.V2.Search.Query(q: text, - type: scope, - accountID: nil, - maxID: nil, - minID: nil, - excludeUnreviewed: nil, - resolve: nil, - limit: nil, - offset: nil, - following: nil) - return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .compactMap { (text, scope) -> AnyPublisher, Error>, Never>? in + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return nil } + let query = Mastodon.API.V2.Search.Query( + q: text, + type: scope, + accountID: nil, + maxID: nil, + minID: nil, + excludeUnreviewed: nil, + resolve: nil, + limit: nil, + offset: nil, + following: nil + ) + return context.apiService.search( + domain: activeMastodonAuthenticationBox.domain, + query: query, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + // .retry(3) // iOS 14.0 SDK may not works here. needs testing before add this + .map { response in Result, Error> { response } } + .catch { error in Just(Result, Error> { throw error }) } + .eraseToAnyPublisher() } - .sink { _ in - } receiveValue: { [weak self] result in - self?.searchResult.value = result.value + .switchToLatest() + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let response): + guard self.isSearching.value else { return } + self.searchResult.value = response.value + case .failure(let error): + break + } } .store(in: &disposeBag) - + isSearching .sink { [weak self] isSearching in if !isSearching { @@ -145,46 +158,72 @@ final class SearchViewModel: NSObject { dataSource.apply(snapshot, animatingDifferences: false, completion: nil) } .store(in: &disposeBag) - - requestRecommendHashTags() - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self = self else { return } - if !self.recommendHashTags.isEmpty { - guard let dataSource = self.hashtagDiffableDataSource else { return } - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(self.recommendHashTags, toSection: .main) - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) - } - } receiveValue: { _ in + + Publishers.CombineLatest( + context.authenticationService.activeMastodonAuthenticationBox, + viewDidAppeared + ) + .compactMap { activeMastodonAuthenticationBox, _ -> AuthenticationService.MastodonAuthenticationBox? in + return activeMastodonAuthenticationBox + } + .throttle(for: 1, scheduler: DispatchQueue.main, latest: false) + .flatMap { box in + context.apiService.recommendTrends(domain: box.domain, query: nil) + .map { response in Result, Error> { response } } + .catch { error in Just(Result, Error> { throw error }) } + .eraseToAnyPublisher() + } + .receive(on: RunLoop.main) + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let response): + guard let dataSource = self.hashtagDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(response.value, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + case .failure(let error): + break } - .store(in: &disposeBag) - - requestRecommendAccountsV2() - .sink { [weak self] _ in - guard let self = self else { return } - if !self.recommendAccounts.isEmpty { - self.applyDataSource() - } - } receiveValue: { _ in - } - .store(in: &disposeBag) - - recommendAccountsFallback - .sink { [weak self] _ in - guard let self = self else { return } - self.requestRecommendAccounts() - .sink { [weak self] _ in - guard let self = self else { return } - if !self.recommendAccounts.isEmpty { - self.applyDataSource() - } - } receiveValue: { _ in + } + .store(in: &disposeBag) + + Publishers.CombineLatest( + context.authenticationService.activeMastodonAuthenticationBox, + viewDidAppeared + ) + .compactMap { activeMastodonAuthenticationBox, _ -> AuthenticationService.MastodonAuthenticationBox? in + return activeMastodonAuthenticationBox + } + .throttle(for: 1, scheduler: DispatchQueue.main, latest: false) + .flatMap { box -> AnyPublisher, Never> in + context.apiService.suggestionAccountV2(domain: box.domain, query: nil, mastodonAuthenticationBox: box) + .map { response in Result<[Mastodon.Entity.Account.ID], Error> { response.value.map { $0.account.id } } } + .catch { error -> AnyPublisher, Never> in + if let apiError = error as? Mastodon.API.Error, apiError.httpResponseStatus == .notFound { + return context.apiService.suggestionAccount(domain: box.domain, query: nil, mastodonAuthenticationBox: box) + .map { response in Result<[Mastodon.Entity.Account.ID], Error> { response.value.map { $0.id } } } + .catch { error in Just(Result<[Mastodon.Entity.Account.ID], Error> { throw error }) } + .eraseToAnyPublisher() + } else { + return Just(Result<[Mastodon.Entity.Account.ID], Error> { throw error }) + .eraseToAnyPublisher() } - .store(in: &self.disposeBag) + } + .eraseToAnyPublisher() + } + .receive(on: RunLoop.main) + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let userIDs): + self.receiveAccounts(ids: userIDs) + case .failure(let error): + break } - .store(in: &disposeBag) + } + .store(in: &disposeBag) searchResult .receive(on: DispatchQueue.main) @@ -213,96 +252,7 @@ final class SearchViewModel: NSObject { .store(in: &disposeBag) } - func requestRecommendHashTags() -> Future { - Future { promise in - guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { - promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) - return - } - self.context.apiService.recommendTrends(domain: activeMastodonAuthenticationBox.domain, query: nil) - .sink { completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - promise(.failure(error)) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request success", (#file as NSString).lastPathComponent, #line, #function) - promise(.success(())) - } - } receiveValue: { [weak self] tags in - guard let self = self else { return } - self.recommendHashTags = tags.value - } - .store(in: &self.disposeBag) - } - } - - func requestRecommendAccountsV2() -> Future { - Future { promise in - guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { - promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) - return - } - self.context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) - .sink { [weak self] completion in - switch completion { - case .failure(let error): - if let apiError = error as? Mastodon.API.Error { - if apiError.httpResponseStatus == .notFound { - self?.recommendAccountsFallback.send() - } - } - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - promise(.failure(error)) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request success", (#file as NSString).lastPathComponent, #line, #function) - promise(.success(())) - } - } receiveValue: { [weak self] accounts in - guard let self = self else { return } - let ids = accounts.value.compactMap({$0.account.id}) - self.receiveAccounts(ids: ids) - } - .store(in: &self.disposeBag) - } - } - - func requestRecommendAccounts() -> Future { - Future { promise in - guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { - promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) - return - } - self.context.apiService.suggestionAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) - .sink { completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - promise(.failure(error)) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request success", (#file as NSString).lastPathComponent, #line, #function) - promise(.success(())) - } - } receiveValue: { [weak self] accounts in - guard let self = self else { return } - let ids = accounts.value.compactMap({$0.id}) - self.receiveAccounts(ids: ids) - } - .store(in: &self.disposeBag) - } - } - - func applyDataSource() { - DispatchQueue.main.async { - guard let dataSource = self.accountDiffableDataSource else { return } - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(self.recommendAccounts, toSection: .main) - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) - } - } - - func receiveAccounts(ids: [String]) { + func receiveAccounts(ids: [Mastodon.Entity.Account.ID]) { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } @@ -319,12 +269,23 @@ final class SearchViewModel: NSObject { return nil } }() - if let users = mastodonUsers { - let sortedUsers = users.sorted { (user1, user2) -> Bool in - (ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0) + guard let users = mastodonUsers else { return } + let objectIDs: [NSManagedObjectID] = users + .compactMap { object in + ids.firstIndex(of: object.id).map { index in (index, object) } } - recommendAccounts = sortedUsers.map(\.objectID) - } + .sorted { $0.0 < $1.0 } + .map { $0.1.objectID } + + // append at front + let newObjectIDs = objectIDs.filter { !self.recommendAccounts.contains($0) } + self.recommendAccounts = newObjectIDs + self.recommendAccounts + + guard let dataSource = self.accountDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(self.recommendAccounts, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) } func accountCollectionViewItemDidSelected(mastodonUser: MastodonUser, from: UIViewController) { diff --git a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift index c4cf4f490..8516db569 100644 --- a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift @@ -98,15 +98,15 @@ extension AudioContainerView { NSLayoutConstraint.activate([ playButton.centerXAnchor.constraint(equalTo: playButtonBackgroundView.centerXAnchor), playButton.centerYAnchor.constraint(equalTo: playButtonBackgroundView.centerYAnchor), - playButtonBackgroundView.heightAnchor.constraint(equalToConstant: 32), - playButtonBackgroundView.widthAnchor.constraint(equalToConstant: 32), + playButtonBackgroundView.heightAnchor.constraint(equalToConstant: 32).priority(.required - 1), + playButtonBackgroundView.widthAnchor.constraint(equalToConstant: 32).priority(.required - 1), ]) container.addArrangedSubview(slider) container.addArrangedSubview(timeLabel) NSLayoutConstraint.activate([ - timeLabel.widthAnchor.constraint(equalToConstant: 40), + timeLabel.widthAnchor.constraint(equalToConstant: 40).priority(.required - 1), ]) } } diff --git a/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift b/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift index b136859a8..33ef86dd0 100644 --- a/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift +++ b/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift @@ -6,13 +6,14 @@ // import UIKit +import ActiveLabel final class DoubleTitleLabelNavigationBarTitleView: UIView { let containerView = UIStackView() - let titleLabel: UILabel = { - let label = UILabel() + let titleLabel: ActiveLabel = { + let label = ActiveLabel(style: .default) label.font = .systemFont(ofSize: 17, weight: .semibold) label.textColor = Asset.Colors.Label.primary.color label.textAlignment = .center @@ -58,8 +59,8 @@ extension DoubleTitleLabelNavigationBarTitleView { containerView.addArrangedSubview(subtitleLabel) } - func update(title: String, subtitle: String?) { - titleLabel.text = title + func update(title: String, subtitle: String?, emojiDict: MastodonStatusContent.EmojiDict) { + titleLabel.configure(content: title, emojiDict: emojiDict) if let subtitle = subtitle { subtitleLabel.text = subtitle subtitleLabel.isHidden = false diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index d6d3dfe25..2b51f028d 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -12,6 +12,9 @@ import AVKit import ActiveLabel import AlamofireImage import FLAnimatedImage +import MetaTextView +import Meta +import MastodonSDK // TODO: // import LinkPresentation @@ -24,9 +27,12 @@ protocol StatusViewDelegate: AnyObject { func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) + func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) } final class StatusView: UIView { + + let logger = Logger(subsystem: "StatusView", category: "logic") var statusPollTableViewHeightObservation: NSKeyValueObservation? var pollCountdownSubscription: AnyCancellable? @@ -78,6 +84,7 @@ final class StatusView: UIView { let headerInfoLabel: ActiveLabel = { let label = ActiveLabel(style: .statusHeader) label.text = "Bob reblogged" + label.layer.masksToBounds = false return label }() @@ -201,7 +208,32 @@ final class StatusView: UIView { return actionToolbarContainer }() - let activeTextLabel = ActiveLabel(style: .default) + let contentMetaText: MetaText = { + let metaText = MetaText() + metaText.textView.backgroundColor = .clear + metaText.textView.isEditable = false + metaText.textView.isSelectable = false + metaText.textView.isScrollEnabled = false + metaText.textView.textContainer.lineFragmentPadding = 0 + metaText.textView.textContainerInset = .zero + metaText.textView.layer.masksToBounds = false + let paragraphStyle: NSMutableParagraphStyle = { + let style = NSMutableParagraphStyle() + style.lineSpacing = 5 + return style + }() + metaText.textAttributes = [ + .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)), + .foregroundColor: Asset.Colors.Label.primary.color, + .paragraphStyle: paragraphStyle, + ] + metaText.linkAttributes = [ + .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)), + .foregroundColor: Asset.Colors.brandBlue.color, + .paragraphStyle: paragraphStyle, + ] + return metaText + }() private let headerInfoLabelTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer @@ -261,6 +293,9 @@ extension StatusView { headerContainerView.bottomAnchor.constraint(equalTo: headerContainerStackView.bottomAnchor, constant: StatusView.containerStackViewSpacing).priority(.defaultHigh), ]) containerStackView.addArrangedSubview(headerContainerView) + defer { + containerStackView.bringSubviewToFront(headerContainerView) + } // author container: [avatar | author meta container | reveal button] let authorContainerStackView = UIStackView() @@ -318,7 +353,9 @@ extension StatusView { nameTrialingDotLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal) dateLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) dateLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - visibilityImageView.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) + visibilityImageView.setContentHuggingPriority(.required - 1, for: .horizontal) + visibilityImageView.setContentHuggingPriority(.required - 1, for: .vertical) + visibilityImageView.setContentCompressionResistancePriority(.required - 1, for: .horizontal) // subtitle container: [username] let subtitleContainerStackView = UIStackView() @@ -360,8 +397,8 @@ extension StatusView { } // status - statusContainerStackView.addArrangedSubview(activeTextLabel) - activeTextLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) + statusContainerStackView.addArrangedSubview(contentMetaText.textView) + contentMetaText.textView.setContentCompressionResistancePriority(.required - 1, for: .vertical) // TODO: // link preview @@ -423,8 +460,9 @@ extension StatusView { avatarStackedContainerButton.isHidden = true contentWarningOverlayView.isHidden = true - - activeTextLabel.delegate = self + + contentMetaText.textView.delegate = self + contentMetaText.textView.linkDelegate = self playerContainerView.delegate = self contentWarningOverlayView.delegate = self @@ -478,10 +516,20 @@ extension StatusView { } // TODO: a11y } - - func updateVisibility(visibility: String) { - guard let visibility = ComposeToolbarView.VisibilitySelectionType(rawValue: visibility) else { return } - visibilityImageView.image = UIImage(systemName: visibility.imageNameForTimeline(), withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)) + + func updateVisibility(visibility: Mastodon.Entity.Status.Visibility) { + switch visibility { + case .public: + visibilityImageView.image = UIImage(systemName: "globe", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)) + case .private: + visibilityImageView.image = UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)) + case .unlisted: + visibilityImageView.image = UIImage(systemName: "eye.slash", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)) + case .direct: + visibilityImageView.image = UIImage(systemName: "at", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)) + case ._other: + visibilityImageView.image = nil + } } } @@ -515,6 +563,34 @@ extension StatusView { } +// MARK: - MetaTextViewDelegate +extension StatusView: MetaTextViewDelegate { + func metaTextView(_ metaTextView: MetaTextView, didSelectLink link: URL) { + logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + switch metaTextView { + case contentMetaText.textView: + guard let meta = Meta(url: link) else { return } + delegate?.statusView(self, metaText: contentMetaText, didSelectMeta: meta) + default: + assertionFailure() + break + } + } +} + +// MARK: - UITextViewDelegate +extension StatusView: UITextViewDelegate { + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + switch textView { + case contentMetaText.textView: + return false + default: + assertionFailure() + return true + } + } +} + // MARK: - ActiveLabelDelegate extension StatusView: ActiveLabelDelegate { func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 784a4bfa2..ceb211a7b 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -12,6 +12,8 @@ import Combine import CoreData import CoreDataStack import ActiveLabel +import Meta +import MetaTextView protocol StatusTableViewCellDelegate: AnyObject { var context: AppContext! { get } @@ -26,6 +28,7 @@ protocol StatusTableViewCellDelegate: AnyObject { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) @@ -71,6 +74,7 @@ final class StatusTableViewCell: UITableViewCell, StatusCell { override func prepareForReuse() { super.prepareForReuse() selectionStyle = .default + statusView.contentMetaText.textView.isSelectable = false statusView.updateContentWarningDisplay(isHidden: true, animated: false) statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = true statusView.pollTableView.dataSource = nil @@ -301,6 +305,10 @@ extension StatusTableViewCell: StatusViewDelegate { delegate?.statusTableViewCell(self, statusView: statusView, activeLabel: activeLabel, didSelectActiveEntity: entity) } + func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) { + delegate?.statusTableViewCell(self, statusView: statusView, metaText: metaText, didSelectMeta: meta) + } + } // MARK: - MosaicImageViewDelegate diff --git a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift index 6da59aef0..64bde2e66 100644 --- a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift @@ -37,7 +37,8 @@ final class VideoPlayerViewModel { private var timeControlStatusObservation: NSKeyValueObservation? let timeControlStatus = CurrentValueSubject(.paused) - + let playbackState = CurrentValueSubject(PlaybackState.unknown) + init(previewImageURL: URL?, videoURL: URL, videoSize: CGSize, videoKind: VideoPlayerViewModel.Kind) { self.previewImageURL = previewImageURL self.videoURL = videoURL @@ -58,18 +59,42 @@ final class VideoPlayerViewModel { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: player state: %s", (#file as NSString).lastPathComponent, #line, #function, player.timeControlStatus.debugDescription) self.timeControlStatus.value = player.timeControlStatus } - - // update audio session category for user interactive event stream + + player.publisher(for: \.status, options: [.initial, .new]) + .sink(receiveValue: { [weak self] status in + guard let self = self else { return } + switch status { + case .failed: + self.playbackState.value = .failed + case .readyToPlay: + self.playbackState.value = .readyToPlay + case .unknown: + self.playbackState.value = .unknown + @unknown default: + assertionFailure() + } + }) + .store(in: &disposeBag) + timeControlStatus .sink { [weak self] timeControlStatus in - guard let _ = self else { return } - guard timeControlStatus == .playing else { return } - NotificationCenter.default.post(name: VideoPlayerViewModel.appWillPlayVideoNotification, object: nil) - switch videoKind { - case .gif: - break - case .video: - try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + guard let self = self else { return } + + // emit playing event + if timeControlStatus == .playing { + NotificationCenter.default.post(name: VideoPlayerViewModel.appWillPlayVideoNotification, object: nil) + } + + switch timeControlStatus { + case .paused: + self.playbackState.value = .paused + case .waitingToPlayAtSpecifiedRate: + self.playbackState.value = .buffering + case .playing: + self.playbackState.value = .playing + @unknown default: + assertionFailure() + self.playbackState.value = .unknown } } .store(in: &disposeBag) @@ -81,6 +106,27 @@ final class VideoPlayerViewModel { isPlay ? self.play() : self.pause() } .store(in: &disposeBag) + + let sessionName = videoKind == .gif ? "GIF" : "Video" + playbackState + .receive(on: RunLoop.main) + .sink { [weak self] status in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: %s status: %s", ((#file as NSString).lastPathComponent), #line, #function, sessionName, status.description) + guard let self = self else { return } + // only update audio session for video + guard self.videoKind == .video else { return } + switch status { + case .unknown, .buffering, .readyToPlay: + break + case .playing: + try? AVAudioSession.sharedInstance().setCategory(.soloAmbient) + try? AVAudioSession.sharedInstance().setActive(true) + case .paused, .stopped, .failed: + try? AVAudioSession.sharedInstance().setCategory(.ambient) + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + } + .store(in: &disposeBag) } deinit { @@ -107,7 +153,8 @@ extension VideoPlayerViewModel { case .gif: break case .video: - try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + break +// try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) } player.play() diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift index db56d63ca..221f9a208 100644 --- a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -11,6 +11,7 @@ import CoreDataStack import Foundation import MastodonSDK import UIKit +import ActiveLabel protocol SuggestionAccountTableViewCellDelegate: AnyObject { func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) @@ -28,8 +29,8 @@ final class SuggestionAccountTableViewCell: UITableViewCell { return imageView }() - let titleLabel: UILabel = { - let label = UILabel() + let titleLabel: ActiveLabel = { + let label = ActiveLabel(style: .statusName) label.textColor = Asset.Colors.brandBlue.color label.font = .systemFont(ofSize: 17, weight: .semibold) label.lineBreakMode = .byTruncatingTail @@ -153,7 +154,7 @@ extension SuggestionAccountTableViewCell { imageTransition: .crossDissolve(0.2) ) } - titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName + titleLabel.configure(content: account.displayNameWithFallback, emojiDict: account.emojiDict) subTitleLabel.text = account.acct button.isSelected = isSelected button.publisher(for: .touchUpInside) diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift index 2023d1b1d..02cf3c38d 100644 --- a/Mastodon/Scene/Thread/ThreadViewController.swift +++ b/Mastodon/Scene/Thread/ThreadViewController.swift @@ -80,9 +80,13 @@ extension ThreadViewController { viewModel.navigationBarTitle .receive(on: DispatchQueue.main) - .sink { [weak self] title in + .sink { [weak self] tuple in guard let self = self else { return } - self.titleView.update(title: title ?? L10n.Scene.Thread.backTitle, subtitle: nil) + guard let (title, emojiDict) = tuple else { + self.titleView.update(title: L10n.Scene.Thread.backTitle, subtitle: nil, emojiDict: [:]) + return + } + self.titleView.update(title: title, subtitle: nil, emojiDict: emojiDict) } .store(in: &disposeBag) } diff --git a/Mastodon/Scene/Thread/ThreadViewModel.swift b/Mastodon/Scene/Thread/ThreadViewModel.swift index febc34d17..26be56430 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel.swift @@ -45,7 +45,7 @@ class ThreadViewModel { let ancestorItems = CurrentValueSubject<[Item], Never>([]) let descendantNodes = CurrentValueSubject<[LeafNode], Never>([]) let descendantItems = CurrentValueSubject<[Item], Never>([]) - let navigationBarTitle: CurrentValueSubject + let navigationBarTitle: CurrentValueSubject<(String, MastodonStatusContent.EmojiDict)?, Never> init(context: AppContext, optionalStatus: Status?) { self.context = context @@ -53,7 +53,7 @@ class ThreadViewModel { self.rootItem = CurrentValueSubject(optionalStatus.flatMap { Item.root(statusObjectID: $0.objectID, attribute: Item.StatusAttribute()) }) self.existStatusFetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: nil, additionalTweetPredicate: nil) self.navigationBarTitle = CurrentValueSubject( - optionalStatus.flatMap { L10n.Scene.Thread.title($0.author.displayNameWithFallback) } + optionalStatus.flatMap { (L10n.Scene.Thread.title($0.author.displayNameWithFallback), $0.author.emojiDict) } ) // bind fetcher domain @@ -85,7 +85,7 @@ class ThreadViewModel { return } self.rootNode.value = RootNode(domain: status.domain, statusID: status.id, replyToID: status.inReplyToID) - self.navigationBarTitle.value = L10n.Scene.Thread.title(status.author.displayNameWithFallback) + self.navigationBarTitle.value = (L10n.Scene.Thread.title(status.author.displayNameWithFallback), status.author.emojiDict) } } .store(in: &disposeBag) @@ -239,7 +239,7 @@ extension ThreadViewModel { nextID = object.inReplyToID } } - return nodes.reversed() + return nodes } } diff --git a/Mastodon/Service/AudioPlaybackService.swift b/Mastodon/Service/AudioPlaybackService.swift index 274cd5598..6fdac4bf3 100644 --- a/Mastodon/Service/AudioPlaybackService.swift +++ b/Mastodon/Service/AudioPlaybackService.swift @@ -23,7 +23,6 @@ final class AudioPlaybackService: NSObject { var statusObserver: Any? var attachment: Attachment? - let session = AVAudioSession.sharedInstance() let playbackState = CurrentValueSubject(PlaybackState.unknown) let currentTimeSubject = CurrentValueSubject(0) @@ -31,6 +30,23 @@ final class AudioPlaybackService: NSObject { override init() { super.init() addObserver() + + playbackState + .receive(on: RunLoop.main) + .sink { status in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: audio status: %s", ((#file as NSString).lastPathComponent), #line, #function, status.description) + switch status { + case .unknown, .buffering, .readyToPlay: + break + case .playing: + try? AVAudioSession.sharedInstance().setCategory(.soloAmbient) + try? AVAudioSession.sharedInstance().setActive(true) + case .paused, .stopped, .failed: + try? AVAudioSession.sharedInstance().setCategory(.ambient) + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + } + .store(in: &disposeBag) } } @@ -39,12 +55,6 @@ extension AudioPlaybackService { guard let url = URL(string: audioAttachment.url) else { return } - do { - try session.setCategory(.playback) - } catch { - print(error) - return - } notifyWillPlayAudioNotification() if audioAttachment == attachment { @@ -64,27 +74,6 @@ extension AudioPlaybackService { } func addObserver() { - UIDevice.current.isProximityMonitoringEnabled = true - NotificationCenter.default.publisher(for: UIDevice.proximityStateDidChangeNotification, object: nil) - .sink { [weak self] _ in - guard let self = self else { return } - if UIDevice.current.proximityState == true { - do { - try self.session.setCategory(.playAndRecord) - } catch { - print(error) - return - } - } else { - do { - try self.session.setCategory(.playback) - } catch { - print(error) - return - } - } - } - .store(in: &disposeBag) NotificationCenter.default.publisher(for: VideoPlayerViewModel.appWillPlayVideoNotification) .sink { [weak self] _ in guard let self = self else { return } @@ -96,7 +85,7 @@ extension AudioPlaybackService { guard let self = self else { return } self.currentTimeSubject.value = time.seconds }) - player.publisher(for: \.status, options: .new) + player.publisher(for: \.status, options: [.initial, .new]) .sink(receiveValue: { [weak self] status in guard let self = self else { return } switch status { diff --git a/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift index fb6e5ec01..b0ee6cb80 100644 --- a/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift +++ b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift @@ -33,6 +33,7 @@ extension EmojiService { }() let emojis = CurrentValueSubject<[Mastodon.Entity.Emoji], Never>([]) let emojiDict = CurrentValueSubject<[String: [Mastodon.Entity.Emoji]], Never>([:]) + let emojiMapping = CurrentValueSubject<[String: String], Never>([:]) let emojiTrie = CurrentValueSubject?, Never>(nil) private var learnedEmoji: Set = Set() @@ -45,6 +46,18 @@ extension EmojiService { .map { Dictionary(grouping: $0, by: { $0.shortcode }) } .assign(to: \.value, on: emojiDict) .store(in: &disposeBag) + + emojiDict + .map { dict in + var mapping: [String: String] = [:] + for (key, values) in dict { + guard let emoji = values.first else { continue } + mapping[key] = emoji.url + } + return mapping + } + .assign(to: \.value, on: emojiMapping) + .store(in: &disposeBag) emojis .map { emojis -> Trie? in diff --git a/Mastodon/Service/PlaybackState.swift b/Mastodon/Service/PlaybackState.swift index 75fced7bb..8e62fa145 100644 --- a/Mastodon/Service/PlaybackState.swift +++ b/Mastodon/Service/PlaybackState.swift @@ -23,3 +23,21 @@ public enum PlaybackState : Int { case failed = 6 } + +// MARK: - CustomStringConvertible +extension PlaybackState: CustomStringConvertible { + public var description: String { + switch self { + case .unknown: return "unknown" + case .buffering: return "buffering" + case .readyToPlay: return "readyToPlay" + case .playing: return "playing" + case .paused: return "paused" + case .stopped: return "stopped" + case .failed: return "failed" + default: + assertionFailure() + return "" + } + } +} diff --git a/Mastodon/Service/VideoPlaybackService.swift b/Mastodon/Service/VideoPlaybackService.swift index a15431f01..e0ac5e6ff 100644 --- a/Mastodon/Service/VideoPlaybackService.swift +++ b/Mastodon/Service/VideoPlaybackService.swift @@ -40,7 +40,7 @@ extension VideoPlaybackService { } else { if latestPlayingVideoPlayerViewModel === playerViewModel { latestPlayingVideoPlayerViewModel = nil - try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) +// try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) } } } @@ -111,7 +111,7 @@ extension VideoPlaybackService { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) // note: do not retain view controller - // pause all player when view disppear exclude full screen player and other transitioning scene + // pause all player when view disappear exclude full screen player and other transitioning scene for viewModel in viewPlayerViewModelDict.values { guard !viewModel.isTransitioning else { viewModel.isTransitioning = false diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 4b219380a..70d259ca7 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -9,6 +9,7 @@ import os.log import UIKit import UserNotifications import AppShared +import AVFoundation #if ASDK import AsyncDisplayKit @@ -55,12 +56,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Use this method to release any resources that were specific to the discarded scenes, as they will not return. } - } extension AppDelegate { - func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + #if DEBUG + return .all + #else return UIDevice.current.userInterfaceIdiom == .phone ? .portrait : .all + #endif } } diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 7c961dbd2..697e90653 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -83,6 +83,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Called as the scene transitions from the foreground to the background. // Use this method to save data, release shared resources, and store enough scene-specific state information // to restore the scene back to its current state. + AppContext.shared.audioPlaybackService.pauseIfNeed() }