Merge branch 'release/0.7.7'

This commit is contained in:
CMK 2021-06-30 18:57:54 +08:00
commit cec4ab9063
63 changed files with 2283 additions and 1287 deletions

View File

@ -188,7 +188,11 @@
DB029E95266A20430062874E /* MastodonAuthenticationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB029E94266A20430062874E /* MastodonAuthenticationController.swift */; }; DB029E95266A20430062874E /* MastodonAuthenticationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB029E94266A20430062874E /* MastodonAuthenticationController.swift */; };
DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */; }; DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */; };
DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.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 */; }; DB040ED126538E3D00BEE9D8 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB040ED026538E3C00BEE9D8 /* Trie.swift */; };
DB084B5725CBC56C00F898ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Status.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 */; }; 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 */; }; DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */; };
DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; }; DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; };
DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC2E26130172006193C9 /* MastodonField.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 */; }; DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; };
DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; }; DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; };
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD525BAA00100D1B89D /* AppDelegate.swift */; }; 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 */; }; DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */; };
DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; }; DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; };
DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.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 */; }; DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */; };
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; }; DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; };
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.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 = "<group>"; }; DB029E94266A20430062874E /* MastodonAuthenticationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationController.swift; sourceTree = "<group>"; };
DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReplyLoaderTableViewCell.swift; sourceTree = "<group>"; }; DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReplyLoaderTableViewCell.swift; sourceTree = "<group>"; };
DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = "<group>"; }; DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = "<group>"; };
DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCollectionView.swift; sourceTree = "<group>"; }; DB03F7EF26899097007B274C /* ComposeStatusContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentTableViewCell.swift; sourceTree = "<group>"; };
DB03F7F22689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentTableViewCell.swift; sourceTree = "<group>"; };
DB03F7F42689B782007B274C /* ComposeTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTableView.swift; sourceTree = "<group>"; };
DB040ED026538E3C00BEE9D8 /* Trie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trie.swift; sourceTree = "<group>"; }; DB040ED026538E3C00BEE9D8 /* Trie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trie.swift; sourceTree = "<group>"; };
DB084B5625CBC56C00F898ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = "<group>"; }; DB084B5625CBC56C00F898ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = "<group>"; };
DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = "<group>"; }; DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = "<group>"; };
@ -834,6 +845,13 @@
DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRelationshipActionButton.swift; sourceTree = "<group>"; }; DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRelationshipActionButton.swift; sourceTree = "<group>"; };
DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldView.swift; sourceTree = "<group>"; }; DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldView.swift; sourceTree = "<group>"; };
DB35FC2E26130172006193C9 /* MastodonField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonField.swift; sourceTree = "<group>"; }; DB35FC2E26130172006193C9 /* MastodonField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonField.swift; sourceTree = "<group>"; };
DB36679C268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentTableViewCell.swift; sourceTree = "<group>"; };
DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentSection.swift; sourceTree = "<group>"; };
DB3667A0268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentItem.swift; sourceTree = "<group>"; };
DB3667A3268AE2370027D07F /* ComposeStatusPollTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollTableViewCell.swift; sourceTree = "<group>"; };
DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollSection.swift; sourceTree = "<group>"; };
DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollItem.swift; sourceTree = "<group>"; };
DB3667C9268B14A80027D07F /* ReplicaStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplicaStatusView.swift; sourceTree = "<group>"; };
DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = "<group>"; }; DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = "<group>"; };
DB3D100E25BAA75E00EAA174 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; }; DB3D100E25BAA75E00EAA174 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
DB427DD225BAA00100D1B89D /* Mastodon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mastodon.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomScheduler.swift; sourceTree = "<group>"; };
DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = "<group>"; }; DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = "<group>"; };
DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = "<group>"; }; DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = "<group>"; };
DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = "<group>"; };
DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentCollectionViewCell.swift; sourceTree = "<group>"; };
DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewModel.swift; sourceTree = "<group>"; }; DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewModel.swift; sourceTree = "<group>"; };
DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = "<group>"; }; DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = "<group>"; };
DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = "<group>"; }; DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = "<group>"; };
@ -1120,6 +1136,7 @@
DBAEDE5F267A0B1500D25FF5 /* Nuke in Frameworks */, DBAEDE5F267A0B1500D25FF5 /* Nuke in Frameworks */,
DB6F5E32264E7410009108F4 /* TwitterTextEditor in Frameworks */, DB6F5E32264E7410009108F4 /* TwitterTextEditor in Frameworks */,
DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */,
DB03F7ED268976B5007B274C /* MetaTextView in Frameworks */,
DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */,
2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */,
DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */, DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */,
@ -1138,6 +1155,7 @@
2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */, 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */,
87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */, 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */,
DB6804C82637CE2F00430867 /* AppShared.framework in Frameworks */, DB6804C82637CE2F00430867 /* AppShared.framework in Frameworks */,
DB03F7EB268976B5007B274C /* MastodonMeta in Frameworks */,
DBF7A0FC26830C33004176A2 /* FPSIndicator in Frameworks */, DBF7A0FC26830C33004176A2 /* FPSIndicator in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -1519,6 +1537,8 @@
2D35237926256D920031AF25 /* NotificationSection.swift */, 2D35237926256D920031AF25 /* NotificationSection.swift */,
2D198648261C0B8500F0B013 /* SearchResultSection.swift */, 2D198648261C0B8500F0B013 /* SearchResultSection.swift */,
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */,
DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */,
DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */,
DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */,
DB6D9F7C26358ED4008423CD /* SettingsSection.swift */, DB6D9F7C26358ED4008423CD /* SettingsSection.swift */,
5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */, 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */,
@ -1583,6 +1603,8 @@
DB1E347725F519300079D7DF /* PickServerItem.swift */, DB1E347725F519300079D7DF /* PickServerItem.swift */,
DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */,
DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */, DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */,
DB3667A0268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift */,
DB3667A7268AE2900027D07F /* ComposeStatusPollItem.swift */,
DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */, DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */,
DB6D9F8326358EEC008423CD /* SettingsItem.swift */, DB6D9F8326358EEC008423CD /* SettingsItem.swift */,
DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */, DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */,
@ -1732,6 +1754,17 @@
path = Status; path = Status;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
DB03F7F1268990A2007B274C /* TableViewCell */ = {
isa = PBXGroup;
children = (
DB03F7F22689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift */,
DB03F7EF26899097007B274C /* ComposeStatusContentTableViewCell.swift */,
DB36679C268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift */,
DB3667A3268AE2370027D07F /* ComposeStatusPollTableViewCell.swift */,
);
path = TableViewCell;
sourceTree = "<group>";
};
DB084B5125CBC56300F898ED /* CoreDataStack */ = { DB084B5125CBC56300F898ED /* CoreDataStack */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -1940,7 +1973,8 @@
DB55D32225FB4D320002F825 /* View */ = { DB55D32225FB4D320002F825 /* View */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */, DB3667C9268B14A80027D07F /* ReplicaStatusView.swift */,
DB03F7F42689B782007B274C /* ComposeTableView.swift */,
DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */, DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */,
DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */, DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */,
DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */, DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */,
@ -2082,6 +2116,7 @@
DB6F5E36264E78EA009108F4 /* AutoComplete */, DB6F5E36264E78EA009108F4 /* AutoComplete */,
DB55D32225FB4D320002F825 /* View */, DB55D32225FB4D320002F825 /* View */,
DB789A2125F9F76D0071ACA0 /* CollectionViewCell */, DB789A2125F9F76D0071ACA0 /* CollectionViewCell */,
DB03F7F1268990A2007B274C /* TableViewCell */,
DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */,
DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */, DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */,
DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */, DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */,
@ -2093,8 +2128,6 @@
DB789A2125F9F76D0071ACA0 /* CollectionViewCell */ = { DB789A2125F9F76D0071ACA0 /* CollectionViewCell */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */,
DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */,
DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */, DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */,
DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */, DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */,
DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */, DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */,
@ -2653,6 +2686,8 @@
DBAC64A0267E6D02007FE9FD /* Fuzi */, DBAC64A0267E6D02007FE9FD /* Fuzi */,
DBF7A0FB26830C33004176A2 /* FPSIndicator */, DBF7A0FB26830C33004176A2 /* FPSIndicator */,
DB0E2D2D26833FF700865C3C /* NukeFLAnimatedImagePlugin */, DB0E2D2D26833FF700865C3C /* NukeFLAnimatedImagePlugin */,
DB03F7EA268976B5007B274C /* MastodonMeta */,
DB03F7EC268976B5007B274C /* MetaTextView */,
); );
productName = Mastodon; productName = Mastodon;
productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */;
@ -2846,6 +2881,7 @@
DBAC649F267E6D01007FE9FD /* XCRemoteSwiftPackageReference "Fuzi" */, DBAC649F267E6D01007FE9FD /* XCRemoteSwiftPackageReference "Fuzi" */,
DBF7A0FA26830C33004176A2 /* XCRemoteSwiftPackageReference "FPSIndicator" */, DBF7A0FA26830C33004176A2 /* XCRemoteSwiftPackageReference "FPSIndicator" */,
DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */, DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */,
DB03F7E9268976B5007B274C /* XCRemoteSwiftPackageReference "MetaTextView" */,
); );
productRefGroup = DB427DD325BAA00100D1B89D /* Products */; productRefGroup = DB427DD325BAA00100D1B89D /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -3193,6 +3229,7 @@
DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */,
2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */, 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */,
2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */, 2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */,
DB3667A8268AE2900027D07F /* ComposeStatusPollItem.swift in Sources */,
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */,
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */, 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */,
@ -3216,8 +3253,10 @@
DB98338825C945ED00AD9700 /* Assets.swift in Sources */, DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
DB6180E926391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift in Sources */, DB6180E926391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift in Sources */,
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */, DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */,
DB36679F268ABAF20027D07F /* ComposeStatusAttachmentSection.swift in Sources */,
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */,
DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */, DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */,
DB03F7F32689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift in Sources */,
2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */, 2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */,
DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */, DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */,
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
@ -3304,17 +3343,18 @@
2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */,
DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */, DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */,
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */, DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */,
DB3667A6268AE2620027D07F /* ComposeStatusPollSection.swift in Sources */,
DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */,
DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */,
2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */, 2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */,
DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */, DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */,
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,
DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */, DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */,
DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */,
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */,
2D084B8D26258EA3003AA3AF /* NotificationViewModel+Diffable.swift in Sources */, 2D084B8D26258EA3003AA3AF /* NotificationViewModel+Diffable.swift in Sources */,
DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */, DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */,
DB3667A1268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift in Sources */,
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
DBA94434265CBB5300C537E1 /* ProfileFieldSection.swift in Sources */, DBA94434265CBB5300C537E1 /* ProfileFieldSection.swift in Sources */,
DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */, DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */,
@ -3325,6 +3365,7 @@
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */,
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */,
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
DB03F7F52689B782007B274C /* ComposeTableView.swift in Sources */,
2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */, 2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */,
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */,
DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */,
@ -3400,6 +3441,7 @@
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */,
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */,
2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */,
DB36679D268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift in Sources */,
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */,
DBCBCC032680AF6E000F5B51 /* AsyncHomeTimelineViewController+DebugAction.swift in Sources */, DBCBCC032680AF6E000F5B51 /* AsyncHomeTimelineViewController+DebugAction.swift in Sources */,
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */,
@ -3440,8 +3482,8 @@
DB1D842E26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift in Sources */, DB1D842E26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift in Sources */,
DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */, DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */,
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */, 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */,
DB040ECD26526EA600BEE9D8 /* ComposeCollectionView.swift in Sources */,
DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */, DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */,
DB3667CA268B14A80027D07F /* ReplicaStatusView.swift in Sources */,
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */, 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */,
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */, DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */,
@ -3468,9 +3510,11 @@
DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */,
DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */, DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */,
DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */, DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */,
DB3667A4268AE2370027D07F /* ComposeStatusPollTableViewCell.swift in Sources */,
DBBF1DC226524D2900E5B703 /* AutoCompleteTableViewCell.swift in Sources */, DBBF1DC226524D2900E5B703 /* AutoCompleteTableViewCell.swift in Sources */,
2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */,
DB1EE7B2267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift in Sources */, DB1EE7B2267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift in Sources */,
DB03F7F026899097007B274C /* ComposeStatusContentTableViewCell.swift in Sources */,
5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */, 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */,
5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */, 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */,
DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */, DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */,
@ -3489,7 +3533,6 @@
DBAFB7352645463500371D5F /* Emojis.swift in Sources */, DBAFB7352645463500371D5F /* Emojis.swift in Sources */,
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */,
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */,
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */,
DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */, DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */,
5BB04FD5262E7AFF0043BFF6 /* ReportViewController.swift in Sources */, 5BB04FD5262E7AFF0043BFF6 /* ReportViewController.swift in Sources */,
DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */, DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */,
@ -3800,7 +3843,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
DEVELOPMENT_TEAM = 5Z4GVSS33P; DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist; INFOPLIST_FILE = Mastodon/Info.plist;
@ -3808,7 +3851,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.7.6; MARKETING_VERSION = 0.7.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -3827,7 +3870,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
DEVELOPMENT_TEAM = 5Z4GVSS33P; DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist; INFOPLIST_FILE = Mastodon/Info.plist;
@ -3835,7 +3878,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.7.6; MARKETING_VERSION = 0.7.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -4155,7 +4198,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
DEVELOPMENT_TEAM = 5Z4GVSS33P; DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist; INFOPLIST_FILE = Mastodon/Info.plist;
@ -4163,7 +4206,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.7.6; MARKETING_VERSION = 0.7.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -4269,7 +4312,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_TEAM = 5Z4GVSS33P; DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -4277,7 +4320,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 0.7.6; MARKETING_VERSION = 0.7.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -4388,7 +4431,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
DEVELOPMENT_TEAM = 5Z4GVSS33P; DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist; INFOPLIST_FILE = Mastodon/Info.plist;
@ -4396,7 +4439,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.7.6; MARKETING_VERSION = 0.7.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -4502,7 +4545,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_TEAM = 5Z4GVSS33P; DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -4510,7 +4553,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 0.7.6; MARKETING_VERSION = 0.7.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -4556,7 +4599,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_TEAM = 5Z4GVSS33P; DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -4564,7 +4607,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 0.7.6; MARKETING_VERSION = 0.7.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -4579,7 +4622,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_TEAM = 5Z4GVSS33P; DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_FILE = NotificationService/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -4587,7 +4630,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 0.7.6; MARKETING_VERSION = 0.7.7;
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -4730,6 +4773,14 @@
minimumVersion = 0.1.1; 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" */ = { DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kean/Nuke-FLAnimatedImage-Plugin.git"; repositoryURL = "https://github.com/kean/Nuke-FLAnimatedImage-Plugin.git";
@ -4863,6 +4914,16 @@
package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */;
productName = 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 */ = { DB0E2D2D26833FF700865C3C /* NukeFLAnimatedImagePlugin */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */; package = DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */;

View File

@ -12,7 +12,7 @@
<key>CoreDataStack.xcscheme_^#shared#^_</key> <key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>20</integer> <integer>21</integer>
</dict> </dict>
<key>Mastodon - ASDK.xcscheme_^#shared#^_</key> <key>Mastodon - ASDK.xcscheme_^#shared#^_</key>
<dict> <dict>
@ -37,7 +37,7 @@
<key>NotificationService.xcscheme_^#shared#^_</key> <key>NotificationService.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>21</integer> <integer>22</integer>
</dict> </dict>
</dict> </dict>
<key>SuppressBuildableAutocreation</key> <key>SuppressBuildableAutocreation</key>

View File

@ -109,6 +109,15 @@
"version": "6.2.1" "version": "6.2.1"
} }
}, },
{
"package": "MetaTextView",
"repositoryURL": "https://github.com/TwidereProject/MetaTextView.git",
"state": {
"branch": null,
"revision": "5b86b386464be8a6da5383aa714c458c07da6c01",
"version": "1.2.3"
}
},
{ {
"package": "Nuke", "package": "Nuke",
"repositoryURL": "https://github.com/kean/Nuke.git", "repositoryURL": "https://github.com/kean/Nuke.git",

View File

@ -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 { }

View File

@ -13,14 +13,10 @@ import CoreData
enum ComposeStatusItem { enum ComposeStatusItem {
case replyTo(statusObjectID: NSManagedObjectID) case replyTo(statusObjectID: NSManagedObjectID)
case input(replyToStatusObjectID: NSManagedObjectID?, attribute: ComposeStatusAttribute) case input(replyToStatusObjectID: NSManagedObjectID?, attribute: ComposeStatusAttribute)
case attachment(attachmentService: MastodonAttachmentService) case attachment(attachmentAttribute: ComposeStatusAttachmentAttribute)
case pollOption(attribute: ComposePollOptionAttribute) case pollOption(pollOptionAttributes: [ComposeStatusPollItem.PollOptionAttribute], pollExpiresOptionAttribute: ComposeStatusPollItem.PollExpiresOptionAttribute)
case pollOptionAppendEntry
case pollExpiresOption(attribute: ComposePollExpiresOptionAttribute)
} }
extension ComposeStatusItem: Equatable { }
extension ComposeStatusItem: Hashable { } extension ComposeStatusItem: Hashable { }
extension ComposeStatusItem { extension ComposeStatusItem {
@ -29,6 +25,7 @@ extension ComposeStatusItem {
let avatarURL = CurrentValueSubject<URL?, Never>(nil) let avatarURL = CurrentValueSubject<URL?, Never>(nil)
let displayName = CurrentValueSubject<String?, Never>(nil) let displayName = CurrentValueSubject<String?, Never>(nil)
let emojiDict = CurrentValueSubject<MastodonStatusContent.EmojiDict, Never>([:])
let username = CurrentValueSubject<String?, Never>(nil) let username = CurrentValueSubject<String?, Never>(nil)
let composeContent = CurrentValueSubject<String?, Never>(nil) let composeContent = CurrentValueSubject<String?, Never>(nil)
@ -38,6 +35,7 @@ extension ComposeStatusItem {
static func == (lhs: ComposeStatusAttribute, rhs: ComposeStatusAttribute) -> Bool { static func == (lhs: ComposeStatusAttribute, rhs: ComposeStatusAttribute) -> Bool {
return lhs.avatarURL.value == rhs.avatarURL.value && return lhs.avatarURL.value == rhs.avatarURL.value &&
lhs.displayName.value == rhs.displayName.value && lhs.displayName.value == rhs.displayName.value &&
lhs.emojiDict.value == rhs.emojiDict.value &&
lhs.username.value == rhs.username.value && lhs.username.value == rhs.username.value &&
lhs.composeContent.value == rhs.composeContent.value && lhs.composeContent.value == rhs.composeContent.value &&
lhs.isContentWarningComposing.value == rhs.isContentWarningComposing.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 { extension ComposeStatusItem {
final class ComposePollOptionAttribute: Equatable, Hashable { final class ComposeStatusAttachmentAttribute: Hashable {
private let id = UUID() private let id = UUID()
var disposeBag = Set<AnyCancellable>()
weak var delegate: ComposePollAttributeDelegate?
let option = CurrentValueSubject<String, Never>("") var attachmentServices: [MastodonAttachmentService]
init() { init(attachmentServices: [MastodonAttachmentService]) {
option self.attachmentServices = attachmentServices
.sink { [weak self] option in
guard let self = self else { return }
self.delegate?.composePollAttribute(self, pollOptionDidChange: option)
}
.store(in: &disposeBag)
} }
deinit { static func == (lhs: ComposeStatusAttachmentAttribute, rhs: ComposeStatusAttachmentAttribute) -> Bool {
disposeBag.removeAll() 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) { func hash(into hasher: inout Hasher) {
hasher.combine(id) hasher.combine(id)
} }
} }
} }
extension ComposeStatusItem {
final class ComposePollExpiresOptionAttribute: Equatable, Hashable {
private let id = UUID()
let expiresOption = CurrentValueSubject<ExpiresOption, Never>(.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
}
}
}
}
}

View File

@ -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<AnyCancellable>()
weak var delegate: ComposePollAttributeDelegate?
let option = CurrentValueSubject<String, Never>("")
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<ExpiresOption, Never>(.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
}
}
}
}
}

View File

@ -0,0 +1,13 @@
//
// ComposeStatusAttachmentSection.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-6-29.
//
import Foundation
enum ComposeStatusAttachmentSection: Hashable {
case main
}

View File

@ -0,0 +1,12 @@
//
// ComposeStatusPollSection.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-6-29.
//
import Foundation
enum ComposeStatusPollSection: Hashable {
case main
}

View File

@ -9,7 +9,8 @@ import UIKit
import Combine import Combine
import CoreData import CoreData
import CoreDataStack import CoreDataStack
import TwitterTextEditor import MetaTextView
import MastodonMeta
import AlamofireImage import AlamofireImage
enum ComposeStatusSection: Equatable, Hashable { enum ComposeStatusSection: Equatable, Hashable {
@ -29,232 +30,9 @@ extension ComposeStatusSection {
} }
extension ComposeStatusSection { extension ComposeStatusSection {
static func collectionViewDiffableDataSource(
for collectionView: UICollectionView,
dependency: NeedsDependency,
managedObjectContext: NSManagedObjectContext,
composeKind: ComposeKind,
repliedToCellFrameSubscriber: CurrentValueSubject<CGRect, Never>,
customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel,
textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate,
textEditorViewChangeObserver: TextEditorViewChangeObserver,
composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate,
composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate,
composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate
) -> UICollectionViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem> {
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( static func configureStatusContent(
cell: ComposeStatusContentCollectionViewCell, cell: ComposeStatusContentTableViewCell,
attribute: ComposeStatusItem.ComposeStatusAttribute attribute: ComposeStatusItem.ComposeStatusAttribute
) { ) {
// set avatar // set avatar
@ -265,34 +43,23 @@ extension ComposeStatusSection {
} }
.store(in: &cell.disposeBag) .store(in: &cell.disposeBag)
// set display name and username // set display name and username
Publishers.CombineLatest( Publishers.CombineLatest3(
attribute.displayName.eraseToAnyPublisher(), attribute.displayName,
attribute.emojiDict,
attribute.username.eraseToAnyPublisher() attribute.username.eraseToAnyPublisher()
) )
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { displayName, username in .sink { displayName, emojiDict, username in
cell.statusView.nameLabel.text = displayName cell.statusView.nameLabel.configure(content: displayName ?? " ", emojiDict: emojiDict)
cell.statusView.usernameLabel.text = username.flatMap { "@" + $0 } ?? " " cell.statusView.usernameLabel.text = username.flatMap { "@" + $0 } ?? " "
} }
.store(in: &cell.disposeBag) .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 } var inputView: UIView? { get set }
func reloadInputViews()
// UIKeyInput
func insertText(_ text: String)
// UIResponder
var isFirstResponder: Bool { get }
} }
class CustomEmojiReplaceableTextInputReference { 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 UITextField: CustomEmojiReplaceableTextInput { }
extension UITextView: CustomEmojiReplaceableTextInput { } extension UITextView: CustomEmojiReplaceableTextInput { }

View File

@ -6,7 +6,7 @@
// //
import UIKit import UIKit
import Kingfisher import Nuke
enum CustomEmojiPickerSection: Equatable, Hashable { enum CustomEmojiPickerSection: Equatable, Hashable {
case emoji(name: String) case emoji(name: String)
@ -24,13 +24,13 @@ extension CustomEmojiPickerSection {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell
let placeholder = UIImage.placeholder(size: CustomEmojiPickerItemCollectionViewCell.itemSize, color: .systemFill) let placeholder = UIImage.placeholder(size: CustomEmojiPickerItemCollectionViewCell.itemSize, color: .systemFill)
.af.imageRounded(withCornerRadius: 4) .af.imageRounded(withCornerRadius: 4)
cell.emojiImageView.kf.setImage( cell.imageTask = Nuke.loadImage(
with: URL(string: attribute.emoji.url), with: attribute.emoji.url,
placeholder: placeholder, options: .init(
options: [ placeholder: placeholder,
.transition(.fade(0.2)) transition: .fadeIn(duration: 0.2)
], ),
completionHandler: nil into: cell.emojiImageView
) )
cell.accessibilityLabel = attribute.emoji.shortcode cell.accessibilityLabel = attribute.emoji.shortcode
return cell return cell
@ -48,7 +48,7 @@ extension CustomEmojiPickerSection {
let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), for: indexPath) as! CustomEmojiPickerHeaderCollectionReusableView let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), for: indexPath) as! CustomEmojiPickerHeaderCollectionReusableView
switch section { switch section {
case .emoji(let name): case .emoji(let name):
header.titlelabel.text = name header.titleLabel.text = name
} }
return header return header
default: default:

View File

@ -12,7 +12,9 @@ import os.log
import UIKit import UIKit
import AVKit import AVKit
import Nuke import Nuke
import LinkPresentation import MastodonMeta
// import LinkPresentation
#if ASDK #if ASDK
import AsyncDisplayKit import AsyncDisplayKit
@ -138,12 +140,15 @@ extension StatusSection {
cell.delegate = statusTableViewCellDelegate cell.delegate = statusTableViewCellDelegate
switch item { switch item {
case .root: 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] = [] var accessibilityElements: [Any] = []
accessibilityElements.append(cell.statusView.avatarView) accessibilityElements.append(cell.statusView.avatarView)
accessibilityElements.append(cell.statusView.nameLabel) accessibilityElements.append(cell.statusView.nameLabel)
accessibilityElements.append(cell.statusView.dateLabel) 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(contentsOf: cell.statusView.statusMosaicImageViewContainer.imageViews)
accessibilityElements.append(cell.statusView.playerContainerView) accessibilityElements.append(cell.statusView.playerContainerView)
accessibilityElements.append(cell.statusView.actionToolbarContainer) accessibilityElements.append(cell.statusView.actionToolbarContainer)
@ -554,16 +559,23 @@ extension StatusSection {
statusItemAttribute: Item.StatusAttribute statusItemAttribute: Item.StatusAttribute
) { ) {
// set content // set content
cell.statusView.activeTextLabel.configure( do {
content: (status.reblog ?? status).content, let content = MastodonContent(
emojiDict: (status.reblog ?? status).emojiDict content: (status.reblog ?? status).content,
) emojis: (status.reblog ?? status).emojiMeta
cell.statusView.activeTextLabel.accessibilityLanguage = (status.reblog ?? status).language )
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 // set visibility
if let visibility = (status.reblog ?? status).visibility { if let visibility = (status.reblog ?? status).visibilityEnum {
cell.statusView.updateVisibility(visibility: visibility) cell.statusView.updateVisibility(visibility: visibility)
cell.statusView.revealContentWarningButton.publisher(for: \.isHidden) cell.statusView.revealContentWarningButton.publisher(for: \.isHidden)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak cell] isHidden in .sink { [weak cell] isHidden in
@ -940,6 +952,18 @@ extension StatusSection {
guard status.reblogsCount.intValue > 0 else { return nil } guard status.reblogsCount.intValue > 0 else { return nil }
return L10n.Common.Controls.Timeline.Accessibility.countReblogs(status.reblogsCount.intValue) 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 // set like
let isLike = status.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false let isLike = status.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
let favoriteCountTitle: String = { let favoriteCountTitle: String = {

View File

@ -7,6 +7,7 @@
import Foundation import Foundation
import MastodonSDK import MastodonSDK
import MastodonMeta
protocol EmojiContainer { protocol EmojiContainer {
var emojisData: Data? { get } var emojisData: Data? { get }
@ -31,6 +32,14 @@ extension EmojiContainer {
} }
return dict return dict
} }
var emojiMeta: MastodonContent.Emojis {
var dict = MastodonContent.Emojis()
for emoji in emojis ?? [] {
dict[emoji.shortcode] = emoji.url
}
return dict
}
} }

View File

@ -89,3 +89,10 @@ extension Status {
} }
extension Status: EmojiContainer { } extension Status: EmojiContainer { }
extension Status {
var visibilityEnum: Mastodon.Entity.Status.Visibility? {
return visibility.flatMap { Mastodon.Entity.Status.Visibility(rawValue: $0) }
}
}

View File

@ -12,6 +12,8 @@ import CoreData
import CoreDataStack import CoreDataStack
import MastodonSDK import MastodonSDK
import ActiveLabel import ActiveLabel
import Meta
import MetaTextView
// MARK: - StatusViewDelegate // MARK: - StatusViewDelegate
extension StatusTableViewCellDelegate where Self: StatusProvider { 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) { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
StatusProviderFacade.responseToStatusActiveLabelAction(provider: self, cell: cell, activeLabel: activeLabel, didTapEntity: entity) 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) { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) {
StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell)

View File

@ -12,6 +12,8 @@ import CoreData
import CoreDataStack import CoreDataStack
import MastodonSDK import MastodonSDK
import ActiveLabel import ActiveLabel
import Meta
import MetaTextView
#if ASDK #if ASDK
import AsyncDisplayKit 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 #if ASDK
static func responseToStatusActiveLabelAction(provider: StatusProvider, node: ASCellNode, didSelectActiveEntityType type: ActiveEntityType) { static func responseToStatusActiveLabelAction(provider: StatusProvider, node: ASCellNode, didSelectActiveEntityType type: ActiveEntityType) {
switch type { switch type {

View File

@ -29,6 +29,23 @@ extension UserProviderFacade {
mastodonUser: provider.mastodonUser().eraseToAnyPublisher() mastodonUser: provider.mastodonUser().eraseToAnyPublisher()
) )
} }
static func toggleUserFollowRelationship(
provider: UserProvider,
mastodonUser: MastodonUser
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, 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( private static func _toggleUserFollowRelationship(
context: AppContext, context: AppContext,
@ -52,6 +69,22 @@ extension UserProviderFacade {
} }
extension UserProviderFacade { extension UserProviderFacade {
static func toggleUserBlockRelationship(
provider: UserProvider,
mastodonUser: MastodonUser
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, 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( static func toggleUserBlockRelationship(
provider: UserProvider, provider: UserProvider,
cell: UITableViewCell? cell: UITableViewCell?
@ -98,6 +131,23 @@ extension UserProviderFacade {
} }
extension UserProviderFacade { extension UserProviderFacade {
static func toggleUserMuteRelationship(
provider: UserProvider,
mastodonUser: MastodonUser
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, 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( static func toggleUserMuteRelationship(
provider: UserProvider, provider: UserProvider,
cell: UITableViewCell? cell: UITableViewCell?

View File

@ -38,7 +38,9 @@ final class AutoCompleteViewController: UIViewController {
tableView.backgroundColor = .clear tableView.backgroundColor = .clear
tableView.contentInset.top = AutoCompleteViewController.chevronViewHeight tableView.contentInset.top = AutoCompleteViewController.chevronViewHeight
tableView.verticalScrollIndicatorInsets.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 return tableView
}() }()
@ -50,6 +52,9 @@ extension AutoCompleteViewController {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = .clear view.backgroundColor = .clear
// we hack the view hierarchy. Do not preserve from superview
view.preservesSuperviewLayoutMargins = false
chevronView.translatesAutoresizingMaskIntoConstraints = false chevronView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(chevronView) view.addSubview(chevronView)

View File

@ -97,8 +97,8 @@ extension AutoCompleteTableViewCell {
contentView.addSubview(containerStackView) contentView.addSubview(containerStackView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor), containerStackView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor),
containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), containerStackView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), containerStackView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
]) ])
avatarImageView.translatesAutoresizingMaskIntoConstraints = false avatarImageView.translatesAutoresizingMaskIntoConstraints = false

View File

@ -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<AnyCancellable>()
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<String, Never>()
let contentWarningContent = PassthroughSubject<String, Never>()
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)
}
}

View File

@ -10,7 +10,7 @@ import UIKit
import Combine import Combine
protocol ComposeStatusPollExpiresOptionCollectionViewCellDelegate: AnyObject { 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 { final class ComposeStatusPollExpiresOptionCollectionViewCell: UICollectionViewCell {
@ -41,7 +41,7 @@ final class ComposeStatusPollExpiresOptionCollectionViewCell: UICollectionViewCe
extension ComposeStatusPollExpiresOptionCollectionViewCell { extension ComposeStatusPollExpiresOptionCollectionViewCell {
private typealias ExpiresOption = ComposeStatusItem.ComposePollExpiresOptionAttribute.ExpiresOption private typealias ExpiresOption = ComposeStatusPollItem.PollExpiresOptionAttribute.ExpiresOption
private func _init() { private func _init() {
durationButton.translatesAutoresizingMaskIntoConstraints = false durationButton.translatesAutoresizingMaskIntoConstraints = false

View File

@ -9,7 +9,7 @@ import UIKit
final class CustomEmojiPickerHeaderCollectionReusableView: UICollectionReusableView { final class CustomEmojiPickerHeaderCollectionReusableView: UICollectionReusableView {
let titlelabel: UILabel = { let titleLabel: UILabel = {
let label = UILabel() let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .bold)) label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .bold))
label.textColor = Asset.Colors.Label.secondary.color label.textColor = Asset.Colors.Label.secondary.color
@ -30,13 +30,13 @@ final class CustomEmojiPickerHeaderCollectionReusableView: UICollectionReusableV
extension CustomEmojiPickerHeaderCollectionReusableView { extension CustomEmojiPickerHeaderCollectionReusableView {
private func _init() { private func _init() {
titlelabel.translatesAutoresizingMaskIntoConstraints = false titleLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(titlelabel) addSubview(titleLabel)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
titlelabel.topAnchor.constraint(equalTo: topAnchor, constant: 20), titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 20),
titlelabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), titleLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
titlelabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), titleLabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
titlelabel.bottomAnchor.constraint(equalTo: bottomAnchor), titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
]) ])
} }
} }

View File

@ -6,10 +6,13 @@
// //
import UIKit import UIKit
import Nuke
final class CustomEmojiPickerItemCollectionViewCell: UICollectionViewCell { final class CustomEmojiPickerItemCollectionViewCell: UICollectionViewCell {
static let itemSize = CGSize(width: 44, height: 44) static let itemSize = CGSize(width: 44, height: 44)
var imageTask: ImageTask?
let emojiImageView: UIImageView = { let emojiImageView: UIImageView = {
let imageView = UIImageView() let imageView = UIImageView()
@ -23,6 +26,12 @@ final class CustomEmojiPickerItemCollectionViewCell: UICollectionViewCell {
emojiImageView.alpha = isHighlighted ? 0.5 : 1.0 emojiImageView.alpha = isHighlighted ? 0.5 : 1.0
} }
} }
override func prepareForReuse() {
super.prepareForReuse()
imageTask?.cancel()
imageTask = nil
}
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)

View File

@ -12,6 +12,9 @@ import PhotosUI
import Kingfisher import Kingfisher
import MastodonSDK import MastodonSDK
import TwitterTextEditor import TwitterTextEditor
import MetaTextView
import MastodonMeta
import Meta
final class ComposeViewController: UIViewController, NeedsDependency { final class ComposeViewController: UIViewController, NeedsDependency {
@ -22,7 +25,9 @@ final class ComposeViewController: UIViewController, NeedsDependency {
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
var viewModel: ComposeViewModel! var viewModel: ComposeViewModel!
let logger = Logger(subsystem: "ComposeViewController", category: "logic")
private var suffixedAttachmentViews: [UIView] = [] private var suffixedAttachmentViews: [UIView] = []
let publishButton: UIButton = { let publishButton: UIButton = {
@ -43,20 +48,17 @@ final class ComposeViewController: UIViewController, NeedsDependency {
let barButtonItem = UIBarButtonItem(customView: publishButton) let barButtonItem = UIBarButtonItem(customView: publishButton)
return barButtonItem return barButtonItem
}() }()
let collectionView: ComposeCollectionView = { let tableView: ComposeTableView = {
let collectionViewLayout = ComposeViewController.createLayout() let tableView = ComposeTableView()
let collectionView = ComposeCollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) tableView.register(ComposeRepliedToStatusContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self))
collectionView.register(ComposeRepliedToStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self)) tableView.register(ComposeStatusContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeStatusContentTableViewCell.self))
collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self)) tableView.register(ComposeStatusAttachmentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeStatusAttachmentTableViewCell.self))
collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self)) tableView.backgroundColor = Asset.Scene.Compose.background.color
collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self)) tableView.alwaysBounceVertical = true
collectionView.register(ComposeStatusPollOptionAppendEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self)) tableView.separatorStyle = .none
collectionView.register(ComposeStatusPollExpiresOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self)) tableView.tableFooterView = UIView()
collectionView.backgroundColor = Asset.Scene.Compose.background.color return tableView
collectionView.alwaysBounceVertical = true
collectionView.keyboardDismissMode = .onDrag
return collectionView
}() }()
var systemKeyboardHeight: CGFloat = .zero { var systemKeyboardHeight: CGFloat = .zero {
@ -148,14 +150,15 @@ extension ComposeViewController {
navigationItem.leftBarButtonItem = cancelBarButtonItem navigationItem.leftBarButtonItem = cancelBarButtonItem
navigationItem.rightBarButtonItem = publishBarButtonItem navigationItem.rightBarButtonItem = publishBarButtonItem
publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside)
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView) tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.topAnchor), tableView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
]) ])
composeToolbarView.translatesAutoresizingMaskIntoConstraints = false composeToolbarView.translatesAutoresizingMaskIntoConstraints = false
@ -178,21 +181,31 @@ extension ComposeViewController {
composeToolbarBackgroundView.trailingAnchor.constraint(equalTo: composeToolbarView.trailingAnchor), composeToolbarBackgroundView.trailingAnchor.constraint(equalTo: composeToolbarView.trailingAnchor),
view.bottomAnchor.constraint(equalTo: composeToolbarBackgroundView.bottomAnchor), view.bottomAnchor.constraint(equalTo: composeToolbarBackgroundView.bottomAnchor),
]) ])
collectionView.delegate = self tableView.delegate = self
viewModel.setupDiffableDataSource( viewModel.setupDiffableDataSource(
for: collectionView, tableView: tableView,
dependency: self, metaTextDelegate: self,
metaTextViewDelegate: self,
customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel, customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel,
textEditorViewTextAttributesDelegate: self, composeStatusAttachmentCollectionViewCellDelegate: self,
textEditorViewChangeObserver: self,
composeStatusAttachmentTableViewCellDelegate: self,
composeStatusPollOptionCollectionViewCellDelegate: self, composeStatusPollOptionCollectionViewCellDelegate: self,
composeStatusNewPollOptionCollectionViewCellDelegate: self, composeStatusPollOptionAppendEntryCollectionViewCellDelegate: self,
composeStatusPollExpiresOptionCollectionViewCellDelegate: 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 customEmojiPickerInputView.collectionView.delegate = self
viewModel.customEmojiPickerInputViewModel.customEmojiPickerInputView = customEmojiPickerInputView viewModel.customEmojiPickerInputViewModel.customEmojiPickerInputView = customEmojiPickerInputView
@ -202,6 +215,7 @@ extension ComposeViewController {
) )
// update layout when keyboard show/dismiss // update layout when keyboard show/dismiss
view.layoutIfNeeded()
let keyboardEventPublishers = Publishers.CombineLatest3( let keyboardEventPublishers = Publishers.CombineLatest3(
KeyboardResponderService.shared.isShow, KeyboardResponderService.shared.isShow,
KeyboardResponderService.shared.state, KeyboardResponderService.shared.state,
@ -227,8 +241,8 @@ extension ComposeViewController {
// update keyboard background color // update keyboard background color
guard isShow, state == .dock else { guard isShow, state == .dock else {
self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin self.tableView.contentInset.bottom = extraMargin
self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin self.tableView.verticalScrollIndicatorInsets.bottom = extraMargin
if let superView = self.autoCompleteViewController.tableView.superview { if let superView = self.autoCompleteViewController.tableView.superview {
let autoCompleteTableViewBottomInset: CGFloat = { let autoCompleteTableViewBottomInset: CGFloat = {
@ -263,18 +277,18 @@ extension ComposeViewController {
self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset
// adjust inset for collectionView // 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 let padding = contentFrame.maxY + extraMargin - endFrame.minY
guard padding > 0 else { guard padding > 0 else {
self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin self.tableView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin
self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin self.tableView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin
self.updateKeyboardBackground(isKeyboardDisplay: false) self.updateKeyboardBackground(isKeyboardDisplay: false)
return return
} }
self.collectionView.contentInset.bottom = padding self.tableView.contentInset.bottom = padding - self.view.safeAreaInsets.bottom
self.collectionView.verticalScrollIndicatorInsets.bottom = padding self.tableView.verticalScrollIndicatorInsets.bottom = padding - self.view.safeAreaInsets.bottom
UIView.animate(withDuration: 0.3) { UIView.animate(withDuration: 0.3) {
self.composeToolbarViewBottomLayoutConstraint.constant = endFrame.height self.composeToolbarViewBottomLayoutConstraint.constant = endFrame.height
self.view.layoutIfNeeded() self.view.layoutIfNeeded()
@ -292,15 +306,16 @@ extension ComposeViewController {
if self.autoCompleteViewController.view.superview == nil { if self.autoCompleteViewController.view.superview == nil {
self.autoCompleteViewController.view.frame = self.view.bounds self.autoCompleteViewController.view.frame = self.view.bounds
// add to container view. seealso: `viewDidLayoutSubviews()` // 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.addChild(self.autoCompleteViewController)
self.autoCompleteViewController.didMove(toParent: self) self.autoCompleteViewController.didMove(toParent: self)
self.autoCompleteViewController.view.isHidden = true self.autoCompleteViewController.view.isHidden = true
self.collectionView.autoCompleteViewController = self.autoCompleteViewController self.tableView.autoCompleteViewController = self.autoCompleteViewController
} }
self.updateAutoCompleteViewControllerLayout()
self.autoCompleteViewController.view.isHidden = info == nil self.autoCompleteViewController.view.isHidden = info == nil
guard let info = info else { return } 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.view.frame.origin.y = info.textBoundingRect.maxY
self.autoCompleteViewController.viewModel.symbolBoundingRect.value = symbolBoundingRectInContainer self.autoCompleteViewController.viewModel.symbolBoundingRect.value = symbolBoundingRectInContainer
self.autoCompleteViewController.viewModel.inputText.value = String(info.inputText) self.autoCompleteViewController.viewModel.inputText.value = String(info.inputText)
@ -414,8 +429,8 @@ extension ComposeViewController {
// setup snap behavior // setup snap behavior
Publishers.CombineLatest( Publishers.CombineLatest(
viewModel.repliedToCellFrame.removeDuplicates().eraseToAnyPublisher(), viewModel.repliedToCellFrame,
viewModel.collectionViewState.eraseToAnyPublisher() viewModel.collectionViewState
) )
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] repliedToCellFrame, collectionViewState in .sink { [weak self] repliedToCellFrame, collectionViewState in
@ -423,9 +438,11 @@ extension ComposeViewController {
guard repliedToCellFrame != .zero else { return } guard repliedToCellFrame != .zero else { return }
switch collectionViewState { switch collectionViewState {
case .fold: 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: case .expand:
self.collectionView.contentInset.top = 0 self.tableView.contentInset.top = 0
} }
} }
.store(in: &disposeBag) .store(in: &disposeBag)
@ -433,12 +450,21 @@ extension ComposeViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
// Fix AutoLayout conflict issue // using index to make table view layout
DispatchQueue.main.async { [weak self] in // otherwise, the content offset will be wrong
guard let self = self else { return } guard let indexPath = tableView.indexPath(for: viewModel.composeStatusContentTableViewCell),
self.markTextEditorViewBecomeFirstResponser() 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?) { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
@ -449,13 +475,17 @@ extension ComposeViewController {
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
updateAutoCompleteViewControllerLayout()
// pin autoCompleteViewController frame to window }
func updateAutoCompleteViewControllerLayout() {
// pin autoCompleteViewController frame to current view
if let containerView = autoCompleteViewController.view.superview { 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 { if viewFrameInWindow.origin.x != 0 {
autoCompleteViewController.view.frame.origin.x = -viewFrameInWindow.origin.x autoCompleteViewController.view.frame.origin.x = -viewFrameInWindow.origin.x
} }
autoCompleteViewController.view.frame.size.width = view.frame.width
} }
} }
@ -463,86 +493,56 @@ extension ComposeViewController {
extension ComposeViewController { extension ComposeViewController {
private func textEditorView() -> TextEditorView? { private func textEditorView() -> MetaText? {
guard let diffableDataSource = viewModel.diffableDataSource else { return nil } return viewModel.composeStatusContentTableViewCell.metaText
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 markTextEditorViewBecomeFirstResponser() { private func markTextEditorViewBecomeFirstResponser() {
textEditorView()?.isEditing = true textEditorView()?.textView.becomeFirstResponder()
} }
private func contentWarningEditorTextView() -> UITextView? { private func contentWarningEditorTextView() -> UITextView? {
guard let diffableDataSource = viewModel.diffableDataSource else { return nil } viewModel.composeStatusContentTableViewCell.statusContentWarningEditorView.textView
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
} }
private func pollOptionCollectionViewCell(of item: ComposeStatusItem) -> ComposeStatusPollOptionCollectionViewCell? { private func pollOptionCollectionViewCell(of item: ComposeStatusPollItem) -> ComposeStatusPollOptionCollectionViewCell? {
guard case .pollOption = item else { return nil } guard case .pollOption = item else { return nil }
guard let diffableDataSource = viewModel.diffableDataSource else { return nil } guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return nil }
guard let indexPath = diffableDataSource.indexPath(for: item), guard let indexPath = dataSource.indexPath(for: item),
let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusPollOptionCollectionViewCell else { let cell = viewModel.composeStatusPollTableViewCell.collectionView.cellForItem(at: indexPath) as? ComposeStatusPollOptionCollectionViewCell else {
return nil return nil
} }
return cell return cell
} }
private func firstPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { private func firstPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? {
guard let diffableDataSource = viewModel.diffableDataSource else { return nil } guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return nil }
let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) let items = dataSource.snapshot().itemIdentifiers(inSection: .main)
let firstPollItem = items.first { item -> Bool in let firstPollItem = items.first { item -> Bool in
guard case .pollOption = item else { return false } guard case .pollOption = item else { return false }
return true return true
} }
guard let item = firstPollItem else { guard let item = firstPollItem else {
return nil return nil
} }
return pollOptionCollectionViewCell(of: item) return pollOptionCollectionViewCell(of: item)
} }
private func lastPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { private func lastPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? {
guard let diffableDataSource = viewModel.diffableDataSource else { return nil } guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return nil }
let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) let items = dataSource.snapshot().itemIdentifiers(inSection: .main)
let lastPollItem = items.last { item -> Bool in let lastPollItem = items.last { item -> Bool in
guard case .pollOption = item else { return false } guard case .pollOption = item else { return false }
return true return true
} }
guard let item = lastPollItem else { guard let item = lastPollItem else {
return nil return nil
} }
return pollOptionCollectionViewCell(of: item) return pollOptionCollectionViewCell(of: item)
} }
@ -631,43 +631,158 @@ extension ComposeViewController {
dismiss(animated: true, completion: nil) 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) // MARK: - MetaTextDelegate
position.x = collectionView.frame.width * 0.5 extension ComposeViewController: MetaTextDelegate {
collectionView.updateInteractiveMovementTargetPosition(position) func metaText(_ metaText: MetaText, processEditing textStorage: MetaTextStorage) -> MetaContent? {
case .ended: let string = metaText.textStorage.string
collectionView.endInteractiveMovement() let content = MastodonContent(
collectionView.reloadData() content: string,
default: emojis: viewModel.customEmojiViewModel.value?.emojiMapping.value ?? [:]
collectionView.cancelInteractiveMovement() )
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<String.Index>
let symbolString: Substring
let toCursorRange: Range<String.Index>
let toCursorString: Substring
let toHighlightEndRange: Range<String.Index>
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..<text.endIndex, in: text)
guard let match = text.firstMatch(pattern: MastodonRegex.autoCompletePattern, options: [], range: scanRange) else { return nil }
guard let matchRange = Range(match.range(at: 0), in: text) else { return nil }
let matchStartIndex = matchRange.lowerBound
let matchEndIndex = matchRange.upperBound
guard matchStartIndex == highlightStartIndex, matchEndIndex >= cursorIndex else { return nil }
let symbolRange = highlightStartIndex..<text.index(after: highlightStartIndex)
let symbolString = text[symbolRange]
let toCursorRange = highlightStartIndex..<cursorIndex
let toCursorString = text[toCursorRange]
let toHighlightEndRange = matchStartIndex..<matchEndIndex
let toHighlightEndString = text[toHighlightEndRange]
let inputText = toHighlightEndString
let autoCompleteInfo = AutoCompleteInfo(
inputText: inputText,
symbolRange: symbolRange,
symbolString: symbolString,
toCursorRange: toCursorRange,
toCursorString: toCursorString,
toHighlightEndRange: toHighlightEndRange,
toHighlightEndString: toHighlightEndString
)
return autoCompleteInfo
}
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> 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 // MARK: - TextEditorViewTextAttributesDelegate
@ -700,7 +815,7 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
} }
self.suffixedAttachmentViews.removeAll() self.suffixedAttachmentViews.removeAll()
// set normal apperance // set normal appearance
let attributedString = NSMutableAttributedString(attributedString: attributedString) let attributedString = NSMutableAttributedString(attributedString: attributedString)
attributedString.removeAttribute(.suffixedAttachment, range: stringRange) attributedString.removeAttribute(.suffixedAttachment, range: stringRange)
attributedString.removeAttribute(.underlineStyle, 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<String.Index>
let symbolString: Substring
let toCursorRange: Range<String.Index>
let toCursorString: Substring
let toHighlightEndRange: Range<String.Index>
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..<text.endIndex, in: text)
guard let match = text.firstMatch(pattern: MastodonRegex.autoCompletePattern, options: [], range: scanRange) else { return nil }
guard let matchRange = Range(match.range(at: 0), in: text) else { return nil }
let matchStartIndex = matchRange.lowerBound
let matchEndIndex = matchRange.upperBound
guard matchStartIndex == highlightStartIndex, matchEndIndex >= cursorIndex else { return nil }
let symbolRange = highlightStartIndex..<text.index(after: highlightStartIndex)
let symbolString = text[symbolRange]
let toCursorRange = highlightStartIndex..<cursorIndex
let toCursorString = text[toCursorRange]
let toHighlightEndRange = matchStartIndex..<matchEndIndex
let toHighlightEndString = text[toHighlightEndRange]
let inputText = toHighlightEndString
let autoCompleteInfo = AutoCompleteInfo(
inputText: inputText,
symbolRange: symbolRange,
symbolString: symbolString,
toCursorRange: toCursorRange,
toCursorString: toCursorString,
toHighlightEndRange: toHighlightEndRange,
toHighlightEndString: toHighlightEndString
)
return autoCompleteInfo
}
}
// MARK: - ComposeToolbarViewDelegate // MARK: - ComposeToolbarViewDelegate
extension ComposeViewController: ComposeToolbarViewDelegate { extension ComposeViewController: ComposeToolbarViewDelegate {
@ -941,7 +945,7 @@ extension ComposeViewController: ComposeToolbarViewDelegate {
// setup initial poll option if needs // setup initial poll option if needs
if viewModel.isPollComposing.value, viewModel.pollOptionAttributes.value.isEmpty { if viewModel.isPollComposing.value, viewModel.pollOptionAttributes.value.isEmpty {
viewModel.pollOptionAttributes.value = [ComposeStatusItem.ComposePollOptionAttribute(), ComposeStatusItem.ComposePollOptionAttribute()] viewModel.pollOptionAttributes.value = [ComposeStatusPollItem.PollOptionAttribute(), ComposeStatusPollItem.PollOptionAttribute()]
} }
if viewModel.isPollComposing.value { if viewModel.isPollComposing.value {
@ -984,7 +988,7 @@ extension ComposeViewController: ComposeToolbarViewDelegate {
// MARK: - UIScrollViewDelegate // MARK: - UIScrollViewDelegate
extension ComposeViewController { extension ComposeViewController {
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) { func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
guard scrollView === collectionView else { return } guard scrollView === tableView else { return }
let repliedToCellFrame = viewModel.repliedToCellFrame.value let repliedToCellFrame = viewModel.repliedToCellFrame.value
guard repliedToCellFrame != .zero else { return } guard repliedToCellFrame != .zero else { return }
@ -1007,6 +1011,9 @@ extension ComposeViewController {
} }
} }
// MARK: - UITableViewDelegate
extension ComposeViewController: UITableViewDelegate { }
// MARK: - UICollectionViewDelegate // MARK: - UICollectionViewDelegate
extension ComposeViewController: UICollectionViewDelegate { extension ComposeViewController: UICollectionViewDelegate {
@ -1018,26 +1025,13 @@ extension ComposeViewController: UICollectionViewDelegate {
let item = diffableDataSource.itemIdentifier(for: indexPath) let item = diffableDataSource.itemIdentifier(for: indexPath)
guard case let .emoji(attribute) = item else { return } guard case let .emoji(attribute) = item else { return }
let emoji = attribute.emoji let emoji = attribute.emoji
let textEditorView = self.textEditorView()
// make click sound
UIDevice.current.playInputClick()
// retrieve active text input and insert emoji // retrieve active text input and insert emoji
// the leading and trailing space is REQUIRED to fix `UITextStorage` layout issue // the trailing space is REQUIRED to make regex happy
let reference = viewModel.customEmojiPickerInputViewModel.insertText(" :\(emoji.shortcode): ") _ = 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()
}
}
} else { } else {
// do nothing // do nothing
} }
@ -1124,17 +1118,17 @@ extension ComposeViewController: UIDocumentPickerDelegate {
extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelegate { extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelegate {
func composeStatusAttachmentCollectionViewCell(_ cell: ComposeStatusAttachmentCollectionViewCell, removeButtonDidPressed button: UIButton) { func composeStatusAttachmentCollectionViewCell(_ cell: ComposeStatusAttachmentCollectionViewCell, removeButtonDidPressed button: UIButton) {
guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let diffableDataSource = viewModel.composeStatusAttachmentTableViewCell.dataSource else { return }
guard let indexPath = collectionView.indexPath(for: cell) else { return } guard let indexPath = viewModel.composeStatusAttachmentTableViewCell.collectionView.indexPath(for: cell) else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
guard case let .attachment(attachmentService) = item else { return } guard case let .attachment(attachmentService) = item else { return }
var attachmentServices = viewModel.attachmentServices.value var attachmentServices = viewModel.attachmentServices.value
guard let index = attachmentServices.firstIndex(of: attachmentService) else { return } guard let index = attachmentServices.firstIndex(of: attachmentService) else { return }
let removedItem = attachmentServices[index] let removedItem = attachmentServices[index]
attachmentServices.remove(at: index) attachmentServices.remove(at: index)
viewModel.attachmentServices.value = attachmentServices viewModel.attachmentServices.value = attachmentServices
// cancel task // cancel task
removedItem.disposeBag.removeAll() removedItem.disposeBag.removeAll()
} }
@ -1155,16 +1149,16 @@ extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelega
// handle delete backward event for poll option input // handle delete backward event for poll option input
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) { func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) {
guard (text ?? "").isEmpty else { return } guard (text ?? "").isEmpty else { return }
guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return }
guard let indexPath = collectionView.indexPath(for: cell) else { return } guard let indexPath = viewModel.composeStatusPollTableViewCell.collectionView.indexPath(for: cell) else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
guard case let .pollOption(attribute) = item else { return } guard case let .pollOption(attribute) = item else { return }
var pollAttributes = viewModel.pollOptionAttributes.value var pollAttributes = viewModel.pollOptionAttributes.value
guard let index = pollAttributes.firstIndex(of: attribute) else { return } guard let index = pollAttributes.firstIndex(of: attribute) else { return }
// mark previous (fallback to next) item of removed middle poll option become first responder // 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 { if let indexOfItem = pollItems.firstIndex(of: item), index > 0 {
func cellBeforeRemoved() -> ComposeStatusPollOptionCollectionViewCell? { func cellBeforeRemoved() -> ComposeStatusPollOptionCollectionViewCell? {
guard index > 0 else { return nil } guard index > 0 else { return nil }
@ -1172,7 +1166,7 @@ extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelega
let itemBeforeRemoved = pollItems[indexBeforeRemoved] let itemBeforeRemoved = pollItems[indexBeforeRemoved]
return pollOptionCollectionViewCell(of: itemBeforeRemoved) return pollOptionCollectionViewCell(of: itemBeforeRemoved)
} }
func cellAfterRemoved() -> ComposeStatusPollOptionCollectionViewCell? { func cellAfterRemoved() -> ComposeStatusPollOptionCollectionViewCell? {
guard index < pollItems.count - 1 else { return nil } guard index < pollItems.count - 1 else { return nil }
let indexAfterRemoved = pollItems.index(after: index) let indexAfterRemoved = pollItems.index(after: index)
@ -1186,27 +1180,27 @@ extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelega
} }
cell?.pollOptionView.optionTextField.becomeFirstResponder() cell?.pollOptionView.optionTextField.becomeFirstResponder()
} }
guard pollAttributes.count > 2 else { guard pollAttributes.count > 2 else {
return return
} }
pollAttributes.remove(at: index) pollAttributes.remove(at: index)
// update data source // update data source
viewModel.pollOptionAttributes.value = pollAttributes viewModel.pollOptionAttributes.value = pollAttributes
} }
// handle keyboard return event for poll option input // handle keyboard return event for poll option input
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) { func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) {
guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return }
guard let indexPath = collectionView.indexPath(for: cell) else { return } guard let indexPath = viewModel.composeStatusPollTableViewCell.collectionView.indexPath(for: cell) else { return }
let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll).filter { item in let pollItems = dataSource.snapshot().itemIdentifiers(inSection: .main).filter { item in
guard case .pollOption = item else { return false } guard case .pollOption = item else { return false }
return true 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 } guard let index = pollItems.firstIndex(of: item) else { return }
if index == pollItems.count - 1 { if index == pollItems.count - 1 {
// is the last // is the last
viewModel.createNewPollOptionIfPossible() viewModel.createNewPollOptionIfPossible()
@ -1236,7 +1230,7 @@ extension ComposeViewController: ComposeStatusPollOptionAppendEntryCollectionVie
// MARK: - ComposeStatusPollExpiresOptionCollectionViewCellDelegate // MARK: - ComposeStatusPollExpiresOptionCollectionViewCellDelegate
extension ComposeViewController: 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 viewModel.pollExpiresOptionAttribute.expiresOption.value = expiresOption
} }
} }
@ -1264,14 +1258,22 @@ extension ComposeViewController: AutoCompleteViewControllerDelegate {
}() }()
guard let replacedText = _replacedText else { return } guard let replacedText = _replacedText else { return }
guard let textEditorView = textEditorView() else { return } guard let textEditorView = textEditorView(),
let text = textEditorView.text let text = textEditorView.textView.text else { return }
do {
try textEditorView.updateByReplacing(range: NSRange(info.toHighlightEndRange, in: text), with: replacedText) let range = NSRange(info.toHighlightEndRange, in: text)
viewModel.autoCompleteInfo.value = nil textEditorView.textStorage.replaceCharacters(in: range, with: replacedText)
} catch { viewModel.autoCompleteInfo.value = nil
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete fail %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
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
} }
} }
} }

View File

@ -5,73 +5,98 @@
// Created by MainasuK Cirno on 2021-3-11. // Created by MainasuK Cirno on 2021-3-11.
// //
import os.log
import UIKit import UIKit
import Combine import Combine
import CoreDataStack
import TwitterTextEditor import TwitterTextEditor
import MastodonSDK import MastodonSDK
import MastodonMeta
import MetaTextView
extension ComposeViewModel { extension ComposeViewModel {
func setupDiffableDataSource( func setupDiffableDataSource(
for collectionView: UICollectionView, tableView: UITableView,
dependency: NeedsDependency, metaTextDelegate: MetaTextDelegate,
metaTextViewDelegate: UITextViewDelegate,
customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel, customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel,
textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, composeStatusAttachmentCollectionViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
textEditorViewChangeObserver: TextEditorViewChangeObserver,
composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate,
composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, composeStatusPollOptionAppendEntryCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate,
composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate
) { ) {
let diffableDataSource = ComposeStatusSection.collectionViewDiffableDataSource( // content
for: collectionView, composeStatusContentTableViewCell.metaText.delegate = metaTextDelegate
dependency: dependency, composeStatusContentTableViewCell.metaText.textView.delegate = metaTextViewDelegate
managedObjectContext: context.managedObjectContext, // attachment
composeKind: composeKind, composeStatusAttachmentTableViewCell.composeStatusAttachmentCollectionViewCellDelegate = composeStatusAttachmentCollectionViewCellDelegate
repliedToCellFrameSubscriber: repliedToCellFrame, // poll
customEmojiPickerInputViewModel: customEmojiPickerInputViewModel, composeStatusPollTableViewCell.delegate = self
textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate, composeStatusPollTableViewCell.customEmojiPickerInputViewModel = customEmojiPickerInputViewModel
textEditorViewChangeObserver: textEditorViewChangeObserver, composeStatusPollTableViewCell.composeStatusPollOptionCollectionViewCellDelegate = composeStatusPollOptionCollectionViewCellDelegate
composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate, composeStatusPollTableViewCell.composeStatusPollOptionAppendEntryCollectionViewCellDelegate = composeStatusPollOptionAppendEntryCollectionViewCellDelegate
composeStatusPollOptionCollectionViewCellDelegate: composeStatusPollOptionCollectionViewCellDelegate, composeStatusPollTableViewCell.composeStatusPollExpiresOptionCollectionViewCellDelegate = composeStatusPollExpiresOptionCollectionViewCellDelegate
composeStatusNewPollOptionCollectionViewCellDelegate: composeStatusNewPollOptionCollectionViewCellDelegate,
composeStatusPollExpiresOptionCollectionViewCellDelegate: composeStatusPollExpiresOptionCollectionViewCellDelegate
)
diffableDataSource.reorderingHandlers.canReorderItem = { item in // setup data source
switch item { tableView.dataSource = self
case .pollOption: return true
default: return false 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<ComposeStatusAttachmentSection, ComposeStatusAttachmentItem>()
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
}
} }
} .store(in: &disposeBag)
// update reordered data source Publishers.CombineLatest(
diffableDataSource.reorderingHandlers.didReorder = { [weak self] transaction in isPollComposing,
pollOptionAttributes
)
.receive(on: DispatchQueue.main)
.sink { [weak self] isPollComposing, pollOptionAttributes in
guard let self = self else { return } guard let self = self else { return }
guard self.isViewAppeared else { return }
let items = transaction.finalSnapshot.itemIdentifiers
var pollOptionAttributes: [ComposeStatusItem.ComposePollOptionAttribute] = [] let cell = self.composeStatusPollTableViewCell
for item in items { guard let dataSource = cell.dataSource else { return }
guard case let .pollOption(attribute) = item else { continue }
pollOptionAttributes.append(attribute) var snapshot = NSDiffableDataSourceSnapshot<ComposeStatusPollSection, ComposeStatusPollItem>()
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
} }
.store(in: &disposeBag)
self.diffableDataSource = diffableDataSource
var snapshot = NSDiffableDataSourceSnapshot<ComposeStatusSection, ComposeStatusItem>()
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
} }
func setupCustomEmojiPickerDiffableDataSource( 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
}
}

View File

@ -13,7 +13,7 @@ import CoreDataStack
import GameplayKit import GameplayKit
import MastodonSDK import MastodonSDK
final class ComposeViewModel { final class ComposeViewModel: NSObject {
static let composeContentLimit: Int = 500 static let composeContentLimit: Int = 500
@ -33,9 +33,14 @@ final class ComposeViewModel {
let repliedToCellFrame = CurrentValueSubject<CGRect, Never>(.zero) let repliedToCellFrame = CurrentValueSubject<CGRect, Never>(.zero)
let autoCompleteRetryLayoutTimes = CurrentValueSubject<Int, Never>(0) let autoCompleteRetryLayoutTimes = CurrentValueSubject<Int, Never>(0)
let autoCompleteInfo = CurrentValueSubject<ComposeViewController.AutoCompleteInfo?, Never>(nil) let autoCompleteInfo = CurrentValueSubject<ComposeViewController.AutoCompleteInfo?, Never>(nil)
var isViewAppeared = false
// output // output
var diffableDataSource: UICollectionViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>! let composeStatusContentTableViewCell = ComposeStatusContentTableViewCell()
let composeStatusAttachmentTableViewCell = ComposeStatusAttachmentTableViewCell()
let composeStatusPollTableViewCell = ComposeStatusPollTableViewCell()
var dataSource: UITableViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>!
var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>! var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>!
private(set) lazy var publishStateMachine: GKStateMachine = { private(set) lazy var publishStateMachine: GKStateMachine = {
// exclude timeline middle fetcher state // exclude timeline middle fetcher state
@ -61,7 +66,7 @@ final class ComposeViewModel {
let characterCount = CurrentValueSubject<Int, Never>(0) let characterCount = CurrentValueSubject<Int, Never>(0)
let collectionViewState = CurrentValueSubject<CollectionViewState, Never>(.fold) let collectionViewState = CurrentValueSubject<CollectionViewState, Never>(.fold)
// for hashtag: "#<hashag> " // for hashtag: "#<hashtag> "
// for mention: "@<mention> " // for mention: "@<mention> "
private(set) var preInsertedContent: String? private(set) var preInsertedContent: String?
@ -75,8 +80,8 @@ final class ComposeViewModel {
let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([]) let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([])
// polls // polls
let pollOptionAttributes = CurrentValueSubject<[ComposeStatusItem.ComposePollOptionAttribute], Never>([]) let pollOptionAttributes = CurrentValueSubject<[ComposeStatusPollItem.PollOptionAttribute], Never>([])
let pollExpiresOptionAttribute = ComposeStatusItem.ComposePollExpiresOptionAttribute() let pollExpiresOptionAttribute = ComposeStatusPollItem.PollExpiresOptionAttribute()
init( init(
context: AppContext, context: AppContext,
@ -91,7 +96,9 @@ final class ComposeViewModel {
self.selectedStatusVisibility = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value?.user.locked == true ? .private : .public) self.selectedStatusVisibility = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value?.user.locked == true ? .private : .public)
self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value)
self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value)
super.init()
// end init // end init
switch composeKind { switch composeKind {
case .reply(let repliedToStatusObjectID): case .reply(let repliedToStatusObjectID):
context.managedObjectContext.performAndWait { context.managedObjectContext.performAndWait {
@ -143,7 +150,7 @@ final class ComposeViewModel {
case .post: case .post:
self.preInsertedContent = nil self.preInsertedContent = nil
} }
isCustomEmojiComposing isCustomEmojiComposing
.assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing) .assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing)
.store(in: &disposeBag) .store(in: &disposeBag)
@ -174,6 +181,7 @@ final class ComposeViewModel {
} }
return displayName return displayName
}() }()
self.composeStatusAttribute.emojiDict.value = mastodonUser?.emojiDict ?? [:]
self.composeStatusAttribute.username.value = username self.composeStatusAttribute.username.value = username
} }
.store(in: &disposeBag) .store(in: &disposeBag)
@ -282,67 +290,34 @@ final class ComposeViewModel {
self.customEmojiViewModel.value = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) self.customEmojiViewModel.value = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain)
} }
.store(in: &disposeBag) .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 } // setup attribute updater
guard let diffableDataSource = self.diffableDataSource else { return } attachmentServices
var snapshot = diffableDataSource.snapshot() .receive(on: DispatchQueue.main)
.debounce(for: 0.3, scheduler: DispatchQueue.main)
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .attachment)) .sink { attachmentServices in
var attachmentItems: [ComposeStatusItem] = [] // drive service upload state
for attachmentService in attachmentServices { // make image upload in the queue
let item = ComposeStatusItem.attachment(attachmentService: attachmentService) for attachmentService in attachmentServices {
attachmentItems.append(item) // skip when prefix N task when task finish OR fail OR uploading
} guard let currentState = attachmentService.uploadStateMachine.currentState else { break }
snapshot.appendItems(attachmentItems, toSection: .attachment) if currentState is MastodonAttachmentService.UploadState.Fail {
continue
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .poll)) }
if isPollComposing { if currentState is MastodonAttachmentService.UploadState.Finish {
var pollItems: [ComposeStatusItem] = [] continue
for pollAttribute in pollAttributes { }
let item = ComposeStatusItem.pollOption(attribute: pollAttribute) if currentState is MastodonAttachmentService.UploadState.Uploading {
pollItems.append(item) break
} }
snapshot.appendItems(pollItems, toSection: .poll) // trigger uploading one by one
if pollAttributes.count < 4 { if currentState is MastodonAttachmentService.UploadState.Initial {
snapshot.appendItems([ComposeStatusItem.pollOptionAppendEntry], toSection: .poll) attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self)
} break
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
} }
} }
} .store(in: &disposeBag)
.store(in: &disposeBag)
// bind delegate // bind delegate
attachmentServices attachmentServices
@ -393,7 +368,7 @@ extension ComposeViewModel {
func createNewPollOptionIfPossible() { func createNewPollOptionIfPossible() {
guard pollOptionAttributes.value.count < 4 else { return } guard pollOptionAttributes.value.count < 4 else { return }
let attribute = ComposeStatusItem.ComposePollOptionAttribute() let attribute = ComposeStatusPollItem.PollOptionAttribute()
pollOptionAttributes.value = pollOptionAttributes.value + [attribute] pollOptionAttributes.value = pollOptionAttributes.value + [attribute]
} }
@ -465,7 +440,7 @@ extension ComposeViewModel: MastodonAttachmentServiceDelegate {
// MARK: - ComposePollAttributeDelegate // MARK: - ComposePollAttributeDelegate
extension ComposeViewModel: ComposePollAttributeDelegate { extension ComposeViewModel: ComposePollAttributeDelegate {
func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?) { func composePollAttribute(_ attribute: ComposeStatusPollItem.PollOptionAttribute, pollOptionDidChange: String?) {
// trigger update // trigger update
pollOptionAttributes.value = pollOptionAttributes.value pollOptionAttributes.value = pollOptionAttributes.value
} }

View File

@ -1,62 +1,61 @@
// //
// ComposeRepliedToStatusContentCollectionViewCell.swift // ComposeRepliedToStatusContentTableViewCell.swift
// Mastodon // Mastodon
// //
// Created by MainasuK Cirno on 2021-3-11. // Created by MainasuK Cirno on 2021-6-28.
// //
import UIKit import UIKit
import Combine import Combine
final class ComposeRepliedToStatusContentCollectionViewCell: UICollectionViewCell { final class ComposeRepliedToStatusContentTableViewCell: UITableViewCell {
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
let statusView = StatusView() let statusView = ReplicaStatusView()
let framePublisher = PassthroughSubject<CGRect, Never>() let framePublisher = PassthroughSubject<CGRect, Never>()
override func prepareForReuse() { override func prepareForReuse() {
super.prepareForReuse() super.prepareForReuse()
statusView.updateContentWarningDisplay(isHidden: true, animated: false)
disposeBag.removeAll() disposeBag.removeAll()
} }
override init(frame: CGRect) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(frame: frame) super.init(style: style, reuseIdentifier: reuseIdentifier)
_init() _init()
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
super.init(coder: coder) super.init(coder: coder)
_init() _init()
} }
override func layoutSubviews() { override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
framePublisher.send(bounds) framePublisher.send(bounds)
} }
} }
extension ComposeRepliedToStatusContentCollectionViewCell { extension ComposeRepliedToStatusContentTableViewCell {
private func _init() { private func _init() {
selectionStyle = .none
backgroundColor = .clear backgroundColor = .clear
statusView.actionToolbarContainer.isHidden = true
statusView.revealContentWarningButton.isHidden = true
statusView.translatesAutoresizingMaskIntoConstraints = false statusView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(statusView) contentView.addSubview(statusView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20).identifier("statusView.top to ComposeRepliedToStatusContentCollectionViewCell.contentView.top"), statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20).identifier("statusView.top to ComposeRepliedToStatusContentCollectionViewCell.contentView.top"),
statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), statusView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10).identifier("ComposeRepliedToStatusContentCollectionViewCell.contentView.bottom to statusView.bottom"), contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10).identifier("ComposeRepliedToStatusContentCollectionViewCell.contentView.bottom to statusView.bottom"),
]) ])
statusView.headerContainerView.isHidden = true
} }
} }

View File

@ -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<ComposeStatusAttachmentSection, ComposeStatusAttachmentItem>!
weak var composeStatusAttachmentCollectionViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate?
var observations = Set<NSKeyValueObservation>()
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
}
}
}
}

View File

@ -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<AnyCancellable>()
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<String, Never>()
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)
}
}

View File

@ -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<ComposeStatusPollSection, ComposeStatusPollItem>!
var observations = Set<NSKeyValueObservation>()
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()
}
}
}

View File

@ -19,6 +19,8 @@ final class AttachmentContainerView: UIView {
let previewImageView: UIImageView = { let previewImageView: UIImageView = {
let imageView = UIImageView() let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius
imageView.layer.cornerCurve = .continuous
imageView.layer.masksToBounds = true imageView.layer.masksToBounds = true
return imageView return imageView
}() }()

View File

@ -1,22 +1,22 @@
// //
// ComposeCollectionView.swift // ComposeTableView.swift
// Mastodon // Mastodon
// //
// Created by MainasuK Cirno on 2021-5-17. // Created by MainasuK Cirno on 2021-6-28.
// //
import UIKit import UIKit
final class ComposeCollectionView: UICollectionView { final class ComposeTableView: UITableView {
weak var autoCompleteViewController: AutoCompleteViewController? weak var autoCompleteViewController: AutoCompleteViewController?
// adjust hitTest for auto-complete // adjust hitTest for auto-complete
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let autoCompleteViewController = autoCompleteViewController else { guard let autoCompleteViewController = autoCompleteViewController else {
return super.hitTest(point, with: event) return super.hitTest(point, with: event)
} }
let thePoint = convert(point, to: autoCompleteViewController.view) let thePoint = convert(point, to: autoCompleteViewController.view)
if let hitView = autoCompleteViewController.view.hitTest(thePoint, with: event) { if let hitView = autoCompleteViewController.view.hitTest(thePoint, with: event) {
return hitView return hitView
@ -24,5 +24,5 @@ final class ComposeCollectionView: UICollectionView {
return super.hitTest(point, with: event) return super.hitTest(point, with: event)
} }
} }
} }

View File

@ -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 { var visibility: Mastodon.Entity.Status.Visibility {
switch self { switch self {
case .public: return .public case .public: return .public

View File

@ -46,8 +46,23 @@ extension CustomEmojiPickerInputViewModel {
removeEmptyReferences() removeEmptyReferences()
for reference in customEmojiReplaceableTextInputReferences { for reference in customEmojiReplaceableTextInputReferences {
guard reference.value?.isFirstResponder == true else { continue } guard let textInput = reference.value else { continue }
reference.value?.insertText(text) 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 return reference
} }

View File

@ -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 }
}

View File

@ -8,19 +8,12 @@
import UIKit import UIKit
final class StatusContentWarningEditorView: UIView { final class StatusContentWarningEditorView: UIView {
let containerView: UIView = { // due to section following readable inset. We overlap the bleeding to make background fill
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
// default hidden // default hidden
let containerBackgroundView: UIView = { let containerBackgroundView: UIView = {
let view = UIView() let view = UIView()
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
view.isHidden = true
return view return view
}() }()
@ -55,44 +48,38 @@ final class StatusContentWarningEditorView: UIView {
extension StatusContentWarningEditorView { extension StatusContentWarningEditorView {
private func _init() { 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 containerBackgroundView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(containerBackgroundView) addSubview(containerBackgroundView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
containerBackgroundView.topAnchor.constraint(equalTo: containerView.topAnchor), containerBackgroundView.topAnchor.constraint(equalTo: topAnchor),
containerBackgroundView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: -1024), containerBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -1024),
containerBackgroundView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 1024), containerBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 1024),
containerBackgroundView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), containerBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor),
]) ])
iconImageView.translatesAutoresizingMaskIntoConstraints = false iconImageView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(iconImageView) addSubview(iconImageView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
iconImageView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), iconImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
iconImageView.leadingAnchor.constraint(equalTo: containerView.readableContentGuide.leadingAnchor), iconImageView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
iconImageView.widthAnchor.constraint(equalToConstant: StatusView.avatarImageSize.width).priority(.defaultHigh), // center alignment to avatar 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 textView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(textView) addSubview(textView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
textView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 6), textView.centerYAnchor.constraint(equalTo: centerYAnchor),
textView.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: StatusView.avatarToLabelSpacing - 4), // align to name label. minus magic 4pt to remove addtion inset textView.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: 6).priority(.required - 1),
textView.trailingAnchor.constraint(equalTo: containerView.readableContentGuide.trailingAnchor), textView.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: StatusView.avatarToLabelSpacing - 4), // align to name label. minus magic 4pt to remove addition inset
containerView.bottomAnchor.constraint(equalTo: textView.bottomAnchor, constant: 6), 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)
} }
} }

View File

@ -56,7 +56,7 @@ extension HashtagTimelineViewController {
super.viewDidLoad() super.viewDidLoad()
title = "#\(viewModel.hashtag)" title = "#\(viewModel.hashtag)"
titleView.update(title: viewModel.hashtag, subtitle: nil) titleView.update(title: viewModel.hashtag, subtitle: nil, emojiDict: [:])
navigationItem.titleView = titleView navigationItem.titleView = titleView
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
@ -143,7 +143,7 @@ extension HashtagTimelineViewController {
private func updatePromptTitle() { private func updatePromptTitle() {
var subtitle: String? var subtitle: String?
defer { defer {
titleView.update(title: "#" + viewModel.hashtag, subtitle: subtitle) titleView.update(title: "#" + viewModel.hashtag, subtitle: subtitle, emojiDict: [:])
} }
guard let histories = viewModel.hashtagEntity.value?.history else { guard let histories = viewModel.hashtagEntity.value?.history else {
return return

View File

@ -93,6 +93,7 @@ extension NotificationViewController {
.receive(on: RunLoop.main) .receive(on: RunLoop.main)
.sink { [weak self] in .sink { [weak self] in
guard let self = self else { return } guard let self = self else { return }
guard self.viewModel.needsScrollToTopAfterDataSourceUpdate else { return }
self.viewModel.needsScrollToTopAfterDataSourceUpdate = false self.viewModel.needsScrollToTopAfterDataSourceUpdate = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.33) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.33) {
self.scrollToTop(animated: true) self.scrollToTop(animated: true)
@ -106,6 +107,9 @@ extension NotificationViewController {
.sink { [weak self] segment in .sink { [weak self] segment in
guard let self = self else { return } guard let self = self else { return }
self.segmentControl.selectedSegmentIndex = segment.rawValue 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 { guard let domain = self.viewModel.activeMastodonAuthenticationBox.value?.domain, let userID = self.viewModel.activeMastodonAuthenticationBox.value?.userID else {
return return

View File

@ -9,6 +9,8 @@ import Combine
import Foundation import Foundation
import UIKit import UIKit
import ActiveLabel import ActiveLabel
import MetaTextView
import Meta
final class NotificationStatusTableViewCell: UITableViewCell, StatusCell { final class NotificationStatusTableViewCell: UITableViewCell, StatusCell {
static let actionImageBorderWidth: CGFloat = 2 static let actionImageBorderWidth: CGFloat = 2
@ -255,6 +257,10 @@ extension NotificationStatusTableViewCell: StatusViewDelegate {
func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
// do nothing // do nothing
} }
func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) {
// do nothing
}
} }

View File

@ -44,7 +44,6 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
tableView.backgroundColor = .clear tableView.backgroundColor = .clear
tableView.keyboardDismissMode = .onDrag tableView.keyboardDismissMode = .onDrag
tableView.translatesAutoresizingMaskIntoConstraints = false tableView.translatesAutoresizingMaskIntoConstraints = false
return tableView return tableView
}() }()
@ -319,7 +318,6 @@ extension MastodonPickServerViewController {
) )
self.mastodonAuthenticationController = authenticationController self.mastodonAuthenticationController = authenticationController
authenticationController.authenticationSession?.prefersEphemeralWebBrowserSession = true
authenticationController.authenticationSession?.presentationContextProvider = self authenticationController.authenticationSession?.presentationContextProvider = self
authenticationController.authenticationSession?.start() authenticationController.authenticationSession?.start()

View File

@ -49,7 +49,7 @@ extension FavoriteViewController {
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
navigationItem.titleView = titleView 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 tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView) view.addSubview(tableView)

View File

@ -13,6 +13,7 @@ import ActiveLabel
import AlamofireImage import AlamofireImage
import CropViewController import CropViewController
import TwitterTextEditor import TwitterTextEditor
import MastodonMeta
protocol ProfileHeaderViewControllerDelegate: AnyObject { protocol ProfileHeaderViewControllerDelegate: AnyObject {
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView)
@ -166,14 +167,27 @@ extension ProfileHeaderViewController {
) )
} }
.store(in: &disposeBag) .store(in: &disposeBag)
Publishers.CombineLatest3( Publishers.CombineLatest4(
viewModel.isEditing.eraseToAnyPublisher(), viewModel.isEditing,
viewModel.displayProfileInfo.name.removeDuplicates().eraseToAnyPublisher(), viewModel.displayProfileInfo.name.removeDuplicates(),
viewModel.editProfileInfo.name.removeDuplicates().eraseToAnyPublisher() viewModel.editProfileInfo.name.removeDuplicates(),
viewModel.emojiDict
) )
.receive(on: DispatchQueue.main) .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 } 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 self.profileHeaderView.nameTextField.text = isEditing ? editingName : name
} }
.store(in: &disposeBag) .store(in: &disposeBag)
@ -412,7 +426,7 @@ extension ProfileHeaderViewController {
profileHeaderView.avatarImageView.alpha = alpha profileHeaderView.avatarImageView.alpha = alpha
profileHeaderView.editAvatarBackgroundView.alpha = alpha profileHeaderView.editAvatarBackgroundView.alpha = alpha
profileHeaderView.nameTextFieldBackgroundView.alpha = alpha profileHeaderView.nameTextFieldBackgroundView.alpha = alpha
profileHeaderView.nameTextField.alpha = alpha profileHeaderView.displayNameStackView.alpha = alpha
profileHeaderView.usernameLabel.alpha = alpha profileHeaderView.usernameLabel.alpha = alpha
} }

View File

@ -10,6 +10,7 @@ import UIKit
import ActiveLabel import ActiveLabel
import TwitterTextEditor import TwitterTextEditor
import FLAnimatedImage import FLAnimatedImage
import MetaTextView
protocol ProfileHeaderViewDelegate: AnyObject { protocol ProfileHeaderViewDelegate: AnyObject {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView)
@ -111,7 +112,24 @@ final class ProfileHeaderView: UIView {
view.layer.cornerRadius = 10 view.layer.cornerRadius = 10
return view 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 nameTextField: UITextField = {
let textField = UITextField() let textField = UITextField()
textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold), maximumPointSize: 28) 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), nameContainerStackView.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor),
]) ])
let displayNameStackView = UIStackView()
displayNameStackView.axis = .horizontal displayNameStackView.axis = .horizontal
nameTextField.translatesAutoresizingMaskIntoConstraints = false nameTextField.translatesAutoresizingMaskIntoConstraints = false
displayNameStackView.addArrangedSubview(nameTextField) displayNameStackView.addArrangedSubview(nameTextField)
@ -321,6 +338,16 @@ extension ProfileHeaderView {
]) ])
displayNameStackView.bringSubviewToFront(nameTextField) displayNameStackView.bringSubviewToFront(nameTextField)
displayNameStackView.addArrangedSubview(UIView()) 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(displayNameStackView)
nameContainerStackView.addArrangedSubview(usernameLabel) nameContainerStackView.addArrangedSubview(usernameLabel)
@ -436,6 +463,8 @@ extension ProfileHeaderView {
switch state { switch state {
case .normal: case .normal:
nameMetaText.textView.alpha = 1
nameTextField.alpha = 0
nameTextField.isEnabled = false nameTextField.isEnabled = false
bioActiveLabelContainer.isHidden = false bioActiveLabelContainer.isHidden = false
bioTextEditorView.isHidden = true bioTextEditorView.isHidden = true
@ -449,7 +478,9 @@ extension ProfileHeaderView {
self.editAvatarBackgroundView.isHidden = true self.editAvatarBackgroundView.isHidden = true
} }
case .editing: case .editing:
nameMetaText.textView.alpha = 0
nameTextField.isEnabled = true nameTextField.isEnabled = true
nameTextField.alpha = 1
bioActiveLabelContainer.isHidden = true bioActiveLabelContainer.isHidden = true
bioTextEditorView.isHidden = false bioTextEditorView.isHidden = false

View File

@ -303,12 +303,13 @@ extension ProfileViewController {
profileSegmentedViewController.pagingViewController.pagingDelegate = self profileSegmentedViewController.pagingViewController.pagingDelegate = self
// bind view model // bind view model
Publishers.CombineLatest( Publishers.CombineLatest3(
viewModel.name.eraseToAnyPublisher(), viewModel.name,
viewModel.statusesCount.eraseToAnyPublisher() viewModel.emojiDict,
viewModel.statusesCount
) )
.receive(on: DispatchQueue.main) .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 self = self else { return }
guard let title = name, let statusesCount = statusesCount, guard let title = name, let statusesCount = statusesCount,
let formattedStatusCount = MastodonMetricFormatter().string(from: statusesCount) else { let formattedStatusCount = MastodonMetricFormatter().string(from: statusesCount) else {
@ -316,7 +317,7 @@ extension ProfileViewController {
return return
} }
let subtitle = L10n.Scene.Profile.subtitle(formattedStatusCount) 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 self.titleView.isHidden = false
} }
.store(in: &disposeBag) .store(in: &disposeBag)
@ -368,7 +369,7 @@ extension ProfileViewController {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.name) .assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.name)
.store(in: &disposeBag) .store(in: &disposeBag)
viewModel.fileds viewModel.fields
.removeDuplicates() .removeDuplicates()
.map { fields -> [ProfileFieldItem.FieldValue] in .map { fields -> [ProfileFieldItem.FieldValue] in
fields.map { ProfileFieldItem.FieldValue(name: $0.name, value: $0.value) } fields.map { ProfileFieldItem.FieldValue(name: $0.name, value: $0.value) }

View File

@ -39,7 +39,7 @@ class ProfileViewModel: NSObject {
let statusesCount: CurrentValueSubject<Int?, Never> let statusesCount: CurrentValueSubject<Int?, Never>
let followingCount: CurrentValueSubject<Int?, Never> let followingCount: CurrentValueSubject<Int?, Never>
let followersCount: CurrentValueSubject<Int?, Never> let followersCount: CurrentValueSubject<Int?, Never>
let fileds: CurrentValueSubject<[Mastodon.Entity.Field], Never> let fields: CurrentValueSubject<[Mastodon.Entity.Field], Never>
let emojiDict: CurrentValueSubject<MastodonStatusContent.EmojiDict, Never> let emojiDict: CurrentValueSubject<MastodonStatusContent.EmojiDict, Never>
// fulfill this before editing // fulfill this before editing
@ -82,7 +82,7 @@ class ProfileViewModel: NSObject {
self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) }) self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) })
self.protected = CurrentValueSubject(mastodonUser?.locked) self.protected = CurrentValueSubject(mastodonUser?.locked)
self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false) self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false)
self.fileds = CurrentValueSubject(mastodonUser?.fields ?? []) self.fields = CurrentValueSubject(mastodonUser?.fields ?? [])
self.emojiDict = CurrentValueSubject(mastodonUser?.emojiDict ?? [:]) self.emojiDict = CurrentValueSubject(mastodonUser?.emojiDict ?? [:])
super.init() super.init()
@ -257,7 +257,7 @@ extension ProfileViewModel {
self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) } self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) }
self.protected.value = mastodonUser?.locked self.protected.value = mastodonUser?.locked
self.suspended.value = mastodonUser?.suspended ?? false self.suspended.value = mastodonUser?.suspended ?? false
self.fileds.value = mastodonUser?.fields ?? [] self.fields.value = mastodonUser?.fields ?? []
self.emojiDict.value = mastodonUser?.emojiDict ?? [:] self.emojiDict.value = mastodonUser?.emojiDict ?? [:]
} }

View File

@ -12,6 +12,8 @@ import Combine
import CoreData import CoreData
import CoreDataStack import CoreDataStack
import ActiveLabel import ActiveLabel
import Meta
import MetaTextView
final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { 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, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
} }
func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) {
}
} }

View File

@ -10,6 +10,7 @@ import CoreDataStack
import Foundation import Foundation
import MastodonSDK import MastodonSDK
import UIKit import UIKit
import ActiveLabel
protocol SearchRecommendAccountsCollectionViewCellDelegate: NSObject { protocol SearchRecommendAccountsCollectionViewCellDelegate: NSObject {
func followButtonDidPressed(clickedUser: MastodonUser) func followButtonDidPressed(clickedUser: MastodonUser)
@ -42,8 +43,8 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell {
let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
let displayNameLabel: UILabel = { let displayNameLabel: ActiveLabel = {
let label = UILabel() let label = ActiveLabel(style: .statusName)
label.textColor = .white label.textColor = .white
label.textAlignment = .center label.textAlignment = .center
label.font = .systemFont(ofSize: 18, weight: .semibold) label.font = .systemFont(ofSize: 18, weight: .semibold)
@ -164,7 +165,7 @@ extension SearchRecommendAccountsCollectionViewCell {
} }
func config(with mastodonUser: MastodonUser) { 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 acctLabel.text = "@" + mastodonUser.acct
avatarImageView.af.setImage( avatarImageView.af.setImage(
withURL: URL(string: mastodonUser.avatar)!, withURL: URL(string: mastodonUser.avatar)!,

View File

@ -20,14 +20,13 @@ extension SearchViewController: UserProvider {
func mastodonUser() -> Future<MastodonUser?, Never> { func mastodonUser() -> Future<MastodonUser?, Never> {
Future { promise in Future { promise in
promise(.success(self.viewModel.mastodonUser.value)) promise(.success(nil))
} }
} }
} }
extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegate { extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegate {
func followButtonDidPressed(clickedUser: MastodonUser) { func followButtonDidPressed(clickedUser: MastodonUser) {
viewModel.mastodonUser.value = clickedUser
guard let currentMastodonUser = viewModel.currentMastodonUser.value else { guard let currentMastodonUser = viewModel.currentMastodonUser.value else {
return return
} }
@ -36,17 +35,17 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat
case .none: case .none:
break break
case .follow, .following: case .follow, .following:
UserProviderFacade.toggleUserFollowRelationship(provider: self) UserProviderFacade.toggleUserFollowRelationship(provider: self, mastodonUser: clickedUser)
.sink { _ in .sink { _ in
// error handling
} receiveValue: { _ in } receiveValue: { _ in
// success
} }
.store(in: &disposeBag) .store(in: &disposeBag)
case .pending: case .pending:
break break
case .muting: case .muting:
guard let mastodonUser = viewModel.mastodonUser.value else { return } let name = clickedUser.displayNameWithFallback
let name = mastodonUser.displayNameWithFallback
let alertController = UIAlertController( let alertController = UIAlertController(
title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title, title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title,
message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(name), 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 let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unmute, style: .default) { [weak self] _ in
guard let self = self else { return } guard let self = self else { return }
UserProviderFacade.toggleUserMuteRelationship(provider: self, cell: nil) UserProviderFacade.toggleUserMuteRelationship(provider: self, mastodonUser: clickedUser)
.sink { _ in .sink { _ in
// do nothing // do nothing
} receiveValue: { _ in } receiveValue: { _ in
@ -67,8 +66,7 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat
alertController.addAction(cancelAction) alertController.addAction(cancelAction)
present(alertController, animated: true, completion: nil) present(alertController, animated: true, completion: nil)
case .blocking: case .blocking:
guard let mastodonUser = viewModel.mastodonUser.value else { return } let name = clickedUser.displayNameWithFallback
let name = mastodonUser.displayNameWithFallback
let alertController = UIAlertController( let alertController = UIAlertController(
title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.title, title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.title,
message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.message(name), 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 let unblockAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unblock, style: .default) { [weak self] _ in
guard let self = self else { return } guard let self = self else { return }
UserProviderFacade.toggleUserBlockRelationship(provider: self, cell: nil) UserProviderFacade.toggleUserBlockRelationship(provider: self, mastodonUser: clickedUser)
.sink { _ in .sink { _ in
// do nothing // do nothing
} receiveValue: { _ in } receiveValue: { _ in

View File

@ -151,6 +151,12 @@ extension SearchViewController {
view.bringSubviewToFront(statusBar) view.bringSubviewToFront(statusBar)
} }
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.viewDidAppeared.send()
}
func setupSearchBar() { func setupSearchBar() {
searchBar.delegate = self searchBar.delegate = self
view.addSubview(searchBar) view.addSubview(searchBar)

View File

@ -21,8 +21,8 @@ final class SearchViewModel: NSObject {
let context: AppContext let context: AppContext
weak var coordinator: SceneCoordinator! weak var coordinator: SceneCoordinator!
let mastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil)
let currentMastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil) let currentMastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil)
let viewDidAppeared = PassthroughSubject<Void, Never>()
// output // output
let searchText = CurrentValueSubject<String, Never>("") let searchText = CurrentValueSubject<String, Never>("")
@ -32,7 +32,7 @@ final class SearchViewModel: NSObject {
let searchResult = CurrentValueSubject<Mastodon.Entity.SearchResult?, Never>(nil) let searchResult = CurrentValueSubject<Mastodon.Entity.SearchResult?, Never>(nil)
var recommendHashTags = [Mastodon.Entity.Tag]() // var recommendHashTags = [Mastodon.Entity.Tag]()
var recommendAccounts = [NSManagedObjectID]() var recommendAccounts = [NSManagedObjectID]()
var recommendAccountsFallback = PassthroughSubject<Void, Never>() var recommendAccountsFallback = PassthroughSubject<Void, Never>()
@ -60,11 +60,7 @@ final class SearchViewModel: NSObject {
self.coordinator = coordinator self.coordinator = coordinator
self.context = context self.context = context
super.init() super.init()
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
// bind active authentication // bind active authentication
context.authenticationService.activeMastodonAuthentication context.authenticationService.activeMastodonAuthentication
.sink { [weak self] activeMastodonAuthentication in .sink { [weak self] activeMastodonAuthentication in
@ -85,26 +81,43 @@ final class SearchViewModel: NSObject {
.filter { text, _ in .filter { text, _ in
!text.isEmpty !text.isEmpty
} }
.flatMap { (text, scope) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> in .compactMap { (text, scope) -> AnyPublisher<Result<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error>, Never>? in
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return nil }
let query = Mastodon.API.V2.Search.Query(q: text, let query = Mastodon.API.V2.Search.Query(
type: scope, q: text,
accountID: nil, type: scope,
maxID: nil, accountID: nil,
minID: nil, maxID: nil,
excludeUnreviewed: nil, minID: nil,
resolve: nil, excludeUnreviewed: nil,
limit: nil, resolve: nil,
offset: nil, limit: nil,
following: nil) offset: nil,
return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) 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<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> { response } }
.catch { error in Just(Result<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> { throw error }) }
.eraseToAnyPublisher()
} }
.sink { _ in .switchToLatest()
} receiveValue: { [weak self] result in .sink { [weak self] result in
self?.searchResult.value = result.value 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) .store(in: &disposeBag)
isSearching isSearching
.sink { [weak self] isSearching in .sink { [weak self] isSearching in
if !isSearching { if !isSearching {
@ -145,46 +158,72 @@ final class SearchViewModel: NSObject {
dataSource.apply(snapshot, animatingDifferences: false, completion: nil) dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
} }
.store(in: &disposeBag) .store(in: &disposeBag)
requestRecommendHashTags() Publishers.CombineLatest(
.receive(on: DispatchQueue.main) context.authenticationService.activeMastodonAuthenticationBox,
.sink { [weak self] _ in viewDidAppeared
guard let self = self else { return } )
if !self.recommendHashTags.isEmpty { .compactMap { activeMastodonAuthenticationBox, _ -> AuthenticationService.MastodonAuthenticationBox? in
guard let dataSource = self.hashtagDiffableDataSource else { return } return activeMastodonAuthenticationBox
var snapshot = NSDiffableDataSourceSnapshot<RecommendHashTagSection, Mastodon.Entity.Tag>() }
snapshot.appendSections([.main]) .throttle(for: 1, scheduler: DispatchQueue.main, latest: false)
snapshot.appendItems(self.recommendHashTags, toSection: .main) .flatMap { box in
dataSource.apply(snapshot, animatingDifferences: false, completion: nil) context.apiService.recommendTrends(domain: box.domain, query: nil)
} .map { response in Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { response } }
} receiveValue: { _ in .catch { error in Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, 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<RecommendHashTagSection, Mastodon.Entity.Tag>()
snapshot.appendSections([.main])
snapshot.appendItems(response.value, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
case .failure(let error):
break
} }
.store(in: &disposeBag) }
.store(in: &disposeBag)
requestRecommendAccountsV2()
.sink { [weak self] _ in Publishers.CombineLatest(
guard let self = self else { return } context.authenticationService.activeMastodonAuthenticationBox,
if !self.recommendAccounts.isEmpty { viewDidAppeared
self.applyDataSource() )
} .compactMap { activeMastodonAuthenticationBox, _ -> AuthenticationService.MastodonAuthenticationBox? in
} receiveValue: { _ in return activeMastodonAuthenticationBox
} }
.store(in: &disposeBag) .throttle(for: 1, scheduler: DispatchQueue.main, latest: false)
.flatMap { box -> AnyPublisher<Result<[Mastodon.Entity.Account.ID], Error>, Never> in
recommendAccountsFallback context.apiService.suggestionAccountV2(domain: box.domain, query: nil, mastodonAuthenticationBox: box)
.sink { [weak self] _ in .map { response in Result<[Mastodon.Entity.Account.ID], Error> { response.value.map { $0.account.id } } }
guard let self = self else { return } .catch { error -> AnyPublisher<Result<[Mastodon.Entity.Account.ID], Error>, Never> in
self.requestRecommendAccounts() if let apiError = error as? Mastodon.API.Error, apiError.httpResponseStatus == .notFound {
.sink { [weak self] _ in return context.apiService.suggestionAccount(domain: box.domain, query: nil, mastodonAuthenticationBox: box)
guard let self = self else { return } .map { response in Result<[Mastodon.Entity.Account.ID], Error> { response.value.map { $0.id } } }
if !self.recommendAccounts.isEmpty { .catch { error in Just(Result<[Mastodon.Entity.Account.ID], Error> { throw error }) }
self.applyDataSource() .eraseToAnyPublisher()
} } else {
} receiveValue: { _ in 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 searchResult
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
@ -213,96 +252,7 @@ final class SearchViewModel: NSObject {
.store(in: &disposeBag) .store(in: &disposeBag)
} }
func requestRecommendHashTags() -> Future<Void, Error> { func receiveAccounts(ids: [Mastodon.Entity.Account.ID]) {
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<Void, Error> {
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<Void, Error> {
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<RecommendAccountSection, NSManagedObjectID>()
snapshot.appendSections([.main])
snapshot.appendItems(self.recommendAccounts, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
}
func receiveAccounts(ids: [String]) {
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
return return
} }
@ -319,12 +269,23 @@ final class SearchViewModel: NSObject {
return nil return nil
} }
}() }()
if let users = mastodonUsers { guard let users = mastodonUsers else { return }
let sortedUsers = users.sorted { (user1, user2) -> Bool in let objectIDs: [NSManagedObjectID] = users
(ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0) .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<RecommendAccountSection, NSManagedObjectID>()
snapshot.appendSections([.main])
snapshot.appendItems(self.recommendAccounts, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
} }
func accountCollectionViewItemDidSelected(mastodonUser: MastodonUser, from: UIViewController) { func accountCollectionViewItemDidSelected(mastodonUser: MastodonUser, from: UIViewController) {

View File

@ -98,15 +98,15 @@ extension AudioContainerView {
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
playButton.centerXAnchor.constraint(equalTo: playButtonBackgroundView.centerXAnchor), playButton.centerXAnchor.constraint(equalTo: playButtonBackgroundView.centerXAnchor),
playButton.centerYAnchor.constraint(equalTo: playButtonBackgroundView.centerYAnchor), playButton.centerYAnchor.constraint(equalTo: playButtonBackgroundView.centerYAnchor),
playButtonBackgroundView.heightAnchor.constraint(equalToConstant: 32), playButtonBackgroundView.heightAnchor.constraint(equalToConstant: 32).priority(.required - 1),
playButtonBackgroundView.widthAnchor.constraint(equalToConstant: 32), playButtonBackgroundView.widthAnchor.constraint(equalToConstant: 32).priority(.required - 1),
]) ])
container.addArrangedSubview(slider) container.addArrangedSubview(slider)
container.addArrangedSubview(timeLabel) container.addArrangedSubview(timeLabel)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
timeLabel.widthAnchor.constraint(equalToConstant: 40), timeLabel.widthAnchor.constraint(equalToConstant: 40).priority(.required - 1),
]) ])
} }
} }

View File

@ -6,13 +6,14 @@
// //
import UIKit import UIKit
import ActiveLabel
final class DoubleTitleLabelNavigationBarTitleView: UIView { final class DoubleTitleLabelNavigationBarTitleView: UIView {
let containerView = UIStackView() let containerView = UIStackView()
let titleLabel: UILabel = { let titleLabel: ActiveLabel = {
let label = UILabel() let label = ActiveLabel(style: .default)
label.font = .systemFont(ofSize: 17, weight: .semibold) label.font = .systemFont(ofSize: 17, weight: .semibold)
label.textColor = Asset.Colors.Label.primary.color label.textColor = Asset.Colors.Label.primary.color
label.textAlignment = .center label.textAlignment = .center
@ -58,8 +59,8 @@ extension DoubleTitleLabelNavigationBarTitleView {
containerView.addArrangedSubview(subtitleLabel) containerView.addArrangedSubview(subtitleLabel)
} }
func update(title: String, subtitle: String?) { func update(title: String, subtitle: String?, emojiDict: MastodonStatusContent.EmojiDict) {
titleLabel.text = title titleLabel.configure(content: title, emojiDict: emojiDict)
if let subtitle = subtitle { if let subtitle = subtitle {
subtitleLabel.text = subtitle subtitleLabel.text = subtitle
subtitleLabel.isHidden = false subtitleLabel.isHidden = false

View File

@ -12,6 +12,9 @@ import AVKit
import ActiveLabel import ActiveLabel
import AlamofireImage import AlamofireImage
import FLAnimatedImage import FLAnimatedImage
import MetaTextView
import Meta
import MastodonSDK
// TODO: // TODO:
// import LinkPresentation // import LinkPresentation
@ -24,9 +27,12 @@ protocol StatusViewDelegate: AnyObject {
func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton)
func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity)
func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta)
} }
final class StatusView: UIView { final class StatusView: UIView {
let logger = Logger(subsystem: "StatusView", category: "logic")
var statusPollTableViewHeightObservation: NSKeyValueObservation? var statusPollTableViewHeightObservation: NSKeyValueObservation?
var pollCountdownSubscription: AnyCancellable? var pollCountdownSubscription: AnyCancellable?
@ -78,6 +84,7 @@ final class StatusView: UIView {
let headerInfoLabel: ActiveLabel = { let headerInfoLabel: ActiveLabel = {
let label = ActiveLabel(style: .statusHeader) let label = ActiveLabel(style: .statusHeader)
label.text = "Bob reblogged" label.text = "Bob reblogged"
label.layer.masksToBounds = false
return label return label
}() }()
@ -201,7 +208,32 @@ final class StatusView: UIView {
return actionToolbarContainer 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 private let headerInfoLabelTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
@ -261,6 +293,9 @@ extension StatusView {
headerContainerView.bottomAnchor.constraint(equalTo: headerContainerStackView.bottomAnchor, constant: StatusView.containerStackViewSpacing).priority(.defaultHigh), headerContainerView.bottomAnchor.constraint(equalTo: headerContainerStackView.bottomAnchor, constant: StatusView.containerStackViewSpacing).priority(.defaultHigh),
]) ])
containerStackView.addArrangedSubview(headerContainerView) containerStackView.addArrangedSubview(headerContainerView)
defer {
containerStackView.bringSubviewToFront(headerContainerView)
}
// author container: [avatar | author meta container | reveal button] // author container: [avatar | author meta container | reveal button]
let authorContainerStackView = UIStackView() let authorContainerStackView = UIStackView()
@ -318,7 +353,9 @@ extension StatusView {
nameTrialingDotLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal) nameTrialingDotLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal)
dateLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) dateLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
dateLabel.setContentCompressionResistancePriority(.required - 1, 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] // subtitle container: [username]
let subtitleContainerStackView = UIStackView() let subtitleContainerStackView = UIStackView()
@ -360,8 +397,8 @@ extension StatusView {
} }
// status // status
statusContainerStackView.addArrangedSubview(activeTextLabel) statusContainerStackView.addArrangedSubview(contentMetaText.textView)
activeTextLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) contentMetaText.textView.setContentCompressionResistancePriority(.required - 1, for: .vertical)
// TODO: // TODO:
// link preview // link preview
@ -423,8 +460,9 @@ extension StatusView {
avatarStackedContainerButton.isHidden = true avatarStackedContainerButton.isHidden = true
contentWarningOverlayView.isHidden = true contentWarningOverlayView.isHidden = true
activeTextLabel.delegate = self contentMetaText.textView.delegate = self
contentMetaText.textView.linkDelegate = self
playerContainerView.delegate = self playerContainerView.delegate = self
contentWarningOverlayView.delegate = self contentWarningOverlayView.delegate = self
@ -478,10 +516,20 @@ extension StatusView {
} }
// TODO: a11y // TODO: a11y
} }
func updateVisibility(visibility: String) { func updateVisibility(visibility: Mastodon.Entity.Status.Visibility) {
guard let visibility = ComposeToolbarView.VisibilitySelectionType(rawValue: visibility) else { return } switch visibility {
visibilityImageView.image = UIImage(systemName: visibility.imageNameForTimeline(), withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)) 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 // MARK: - ActiveLabelDelegate
extension StatusView: ActiveLabelDelegate { extension StatusView: ActiveLabelDelegate {
func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {

View File

@ -12,6 +12,8 @@ import Combine
import CoreData import CoreData
import CoreDataStack import CoreDataStack
import ActiveLabel import ActiveLabel
import Meta
import MetaTextView
protocol StatusTableViewCellDelegate: AnyObject { protocol StatusTableViewCellDelegate: AnyObject {
var context: AppContext! { get } 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, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) 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, 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, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int)
@ -71,6 +74,7 @@ final class StatusTableViewCell: UITableViewCell, StatusCell {
override func prepareForReuse() { override func prepareForReuse() {
super.prepareForReuse() super.prepareForReuse()
selectionStyle = .default selectionStyle = .default
statusView.contentMetaText.textView.isSelectable = false
statusView.updateContentWarningDisplay(isHidden: true, animated: false) statusView.updateContentWarningDisplay(isHidden: true, animated: false)
statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = true statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = true
statusView.pollTableView.dataSource = nil statusView.pollTableView.dataSource = nil
@ -301,6 +305,10 @@ extension StatusTableViewCell: StatusViewDelegate {
delegate?.statusTableViewCell(self, statusView: statusView, activeLabel: activeLabel, didSelectActiveEntity: entity) 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 // MARK: - MosaicImageViewDelegate

View File

@ -37,7 +37,8 @@ final class VideoPlayerViewModel {
private var timeControlStatusObservation: NSKeyValueObservation? private var timeControlStatusObservation: NSKeyValueObservation?
let timeControlStatus = CurrentValueSubject<AVPlayer.TimeControlStatus, Never>(.paused) let timeControlStatus = CurrentValueSubject<AVPlayer.TimeControlStatus, Never>(.paused)
let playbackState = CurrentValueSubject<PlaybackState, Never>(PlaybackState.unknown)
init(previewImageURL: URL?, videoURL: URL, videoSize: CGSize, videoKind: VideoPlayerViewModel.Kind) { init(previewImageURL: URL?, videoURL: URL, videoSize: CGSize, videoKind: VideoPlayerViewModel.Kind) {
self.previewImageURL = previewImageURL self.previewImageURL = previewImageURL
self.videoURL = videoURL 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) 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 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 timeControlStatus
.sink { [weak self] timeControlStatus in .sink { [weak self] timeControlStatus in
guard let _ = self else { return } guard let self = self else { return }
guard timeControlStatus == .playing else { return }
NotificationCenter.default.post(name: VideoPlayerViewModel.appWillPlayVideoNotification, object: nil) // emit playing event
switch videoKind { if timeControlStatus == .playing {
case .gif: NotificationCenter.default.post(name: VideoPlayerViewModel.appWillPlayVideoNotification, object: nil)
break }
case .video:
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) 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) .store(in: &disposeBag)
@ -81,6 +106,27 @@ final class VideoPlayerViewModel {
isPlay ? self.play() : self.pause() isPlay ? self.play() : self.pause()
} }
.store(in: &disposeBag) .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 { deinit {
@ -107,7 +153,8 @@ extension VideoPlayerViewModel {
case .gif: case .gif:
break break
case .video: case .video:
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) break
// try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default)
} }
player.play() player.play()

View File

@ -11,6 +11,7 @@ import CoreDataStack
import Foundation import Foundation
import MastodonSDK import MastodonSDK
import UIKit import UIKit
import ActiveLabel
protocol SuggestionAccountTableViewCellDelegate: AnyObject { protocol SuggestionAccountTableViewCellDelegate: AnyObject {
func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell)
@ -28,8 +29,8 @@ final class SuggestionAccountTableViewCell: UITableViewCell {
return imageView return imageView
}() }()
let titleLabel: UILabel = { let titleLabel: ActiveLabel = {
let label = UILabel() let label = ActiveLabel(style: .statusName)
label.textColor = Asset.Colors.brandBlue.color label.textColor = Asset.Colors.brandBlue.color
label.font = .systemFont(ofSize: 17, weight: .semibold) label.font = .systemFont(ofSize: 17, weight: .semibold)
label.lineBreakMode = .byTruncatingTail label.lineBreakMode = .byTruncatingTail
@ -153,7 +154,7 @@ extension SuggestionAccountTableViewCell {
imageTransition: .crossDissolve(0.2) 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 subTitleLabel.text = account.acct
button.isSelected = isSelected button.isSelected = isSelected
button.publisher(for: .touchUpInside) button.publisher(for: .touchUpInside)

View File

@ -80,9 +80,13 @@ extension ThreadViewController {
viewModel.navigationBarTitle viewModel.navigationBarTitle
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] title in .sink { [weak self] tuple in
guard let self = self else { return } 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) .store(in: &disposeBag)
} }

View File

@ -45,7 +45,7 @@ class ThreadViewModel {
let ancestorItems = CurrentValueSubject<[Item], Never>([]) let ancestorItems = CurrentValueSubject<[Item], Never>([])
let descendantNodes = CurrentValueSubject<[LeafNode], Never>([]) let descendantNodes = CurrentValueSubject<[LeafNode], Never>([])
let descendantItems = CurrentValueSubject<[Item], Never>([]) let descendantItems = CurrentValueSubject<[Item], Never>([])
let navigationBarTitle: CurrentValueSubject<String?, Never> let navigationBarTitle: CurrentValueSubject<(String, MastodonStatusContent.EmojiDict)?, Never>
init(context: AppContext, optionalStatus: Status?) { init(context: AppContext, optionalStatus: Status?) {
self.context = context self.context = context
@ -53,7 +53,7 @@ class ThreadViewModel {
self.rootItem = CurrentValueSubject(optionalStatus.flatMap { Item.root(statusObjectID: $0.objectID, attribute: Item.StatusAttribute()) }) self.rootItem = CurrentValueSubject(optionalStatus.flatMap { Item.root(statusObjectID: $0.objectID, attribute: Item.StatusAttribute()) })
self.existStatusFetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: nil, additionalTweetPredicate: nil) self.existStatusFetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: nil, additionalTweetPredicate: nil)
self.navigationBarTitle = CurrentValueSubject( 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 // bind fetcher domain
@ -85,7 +85,7 @@ class ThreadViewModel {
return return
} }
self.rootNode.value = RootNode(domain: status.domain, statusID: status.id, replyToID: status.inReplyToID) 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) .store(in: &disposeBag)
@ -239,7 +239,7 @@ extension ThreadViewModel {
nextID = object.inReplyToID nextID = object.inReplyToID
} }
} }
return nodes.reversed() return nodes
} }
} }

View File

@ -23,7 +23,6 @@ final class AudioPlaybackService: NSObject {
var statusObserver: Any? var statusObserver: Any?
var attachment: Attachment? var attachment: Attachment?
let session = AVAudioSession.sharedInstance()
let playbackState = CurrentValueSubject<PlaybackState, Never>(PlaybackState.unknown) let playbackState = CurrentValueSubject<PlaybackState, Never>(PlaybackState.unknown)
let currentTimeSubject = CurrentValueSubject<TimeInterval, Never>(0) let currentTimeSubject = CurrentValueSubject<TimeInterval, Never>(0)
@ -31,6 +30,23 @@ final class AudioPlaybackService: NSObject {
override init() { override init() {
super.init() super.init()
addObserver() 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 { guard let url = URL(string: audioAttachment.url) else {
return return
} }
do {
try session.setCategory(.playback)
} catch {
print(error)
return
}
notifyWillPlayAudioNotification() notifyWillPlayAudioNotification()
if audioAttachment == attachment { if audioAttachment == attachment {
@ -64,27 +74,6 @@ extension AudioPlaybackService {
} }
func addObserver() { 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) NotificationCenter.default.publisher(for: VideoPlayerViewModel.appWillPlayVideoNotification)
.sink { [weak self] _ in .sink { [weak self] _ in
guard let self = self else { return } guard let self = self else { return }
@ -96,7 +85,7 @@ extension AudioPlaybackService {
guard let self = self else { return } guard let self = self else { return }
self.currentTimeSubject.value = time.seconds self.currentTimeSubject.value = time.seconds
}) })
player.publisher(for: \.status, options: .new) player.publisher(for: \.status, options: [.initial, .new])
.sink(receiveValue: { [weak self] status in .sink(receiveValue: { [weak self] status in
guard let self = self else { return } guard let self = self else { return }
switch status { switch status {

View File

@ -33,6 +33,7 @@ extension EmojiService {
}() }()
let emojis = CurrentValueSubject<[Mastodon.Entity.Emoji], Never>([]) let emojis = CurrentValueSubject<[Mastodon.Entity.Emoji], Never>([])
let emojiDict = CurrentValueSubject<[String: [Mastodon.Entity.Emoji]], Never>([:]) let emojiDict = CurrentValueSubject<[String: [Mastodon.Entity.Emoji]], Never>([:])
let emojiMapping = CurrentValueSubject<[String: String], Never>([:])
let emojiTrie = CurrentValueSubject<Trie<Character>?, Never>(nil) let emojiTrie = CurrentValueSubject<Trie<Character>?, Never>(nil)
private var learnedEmoji: Set<String> = Set() private var learnedEmoji: Set<String> = Set()
@ -45,6 +46,18 @@ extension EmojiService {
.map { Dictionary(grouping: $0, by: { $0.shortcode }) } .map { Dictionary(grouping: $0, by: { $0.shortcode }) }
.assign(to: \.value, on: emojiDict) .assign(to: \.value, on: emojiDict)
.store(in: &disposeBag) .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 emojis
.map { emojis -> Trie<Character>? in .map { emojis -> Trie<Character>? in

View File

@ -23,3 +23,21 @@ public enum PlaybackState : Int {
case failed = 6 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 "<nil>"
}
}
}

View File

@ -40,7 +40,7 @@ extension VideoPlaybackService {
} else { } else {
if latestPlayingVideoPlayerViewModel === playerViewModel { if latestPlayingVideoPlayerViewModel === playerViewModel {
latestPlayingVideoPlayerViewModel = nil 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) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
// note: do not retain view controller // 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 { for viewModel in viewPlayerViewModelDict.values {
guard !viewModel.isTransitioning else { guard !viewModel.isTransitioning else {
viewModel.isTransitioning = false viewModel.isTransitioning = false

View File

@ -9,6 +9,7 @@ import os.log
import UIKit import UIKit
import UserNotifications import UserNotifications
import AppShared import AppShared
import AVFoundation
#if ASDK #if ASDK
import AsyncDisplayKit 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. // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
} }
} }
extension AppDelegate { 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 return UIDevice.current.userInterfaceIdiom == .phone ? .portrait : .all
#endif
} }
} }

View File

@ -83,6 +83,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// Called as the scene transitions from the foreground to the background. // 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 // 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. // to restore the scene back to its current state.
AppContext.shared.audioPlaybackService.pauseIfNeed()
} }