Merge pull request #75 from tootsuite/feature/compose-misc

Implement general compose features
This commit is contained in:
CMK 2021-03-29 10:10:21 +08:00 committed by GitHub
commit b9d9233e80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1061 additions and 82 deletions

View File

@ -216,6 +216,15 @@
"one_day": "1 Day",
"three_days": "3 Days",
"seven_days": "7 Days"
},
"content_warning": {
"placeholder": "Write an accurate warning here..."
},
"visibility": {
"public": "Public",
"unlisted": "Unlisted",
"private": "Followers only",
"direct": "Only people I mention"
}
}
}

View File

@ -120,6 +120,7 @@
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */; };
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */; };
DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */; };
DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */; };
DB2B3ABC25E37E15007045F9 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB2B3ABE25E37E15007045F9 /* InfoPlist.strings */; };
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; };
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; };
@ -134,6 +135,11 @@
DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DEC25BAA00100D1B89D /* MastodonTests.swift */; };
DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DF725BAA00100D1B89D /* MastodonUITests.swift */; };
DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44384E25E8C1FA008912A2 /* CALayer.swift */; };
DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */; };
DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */; };
DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */; };
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */; };
DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */; };
DB4481AD25EE155900BEFB67 /* Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481AC25EE155900BEFB67 /* Poll.swift */; };
DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481B225EE16D000BEFB67 /* PollOption.swift */; };
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481B825EE289600BEFB67 /* UITableView.swift */; };
@ -166,8 +172,6 @@
DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */; };
DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729525F9F91600D60309 /* ComposeStatusSection.swift */; };
DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */; };
DB6672A325F9FDE500D60309 /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DB6672A225F9FDE500D60309 /* TwitterTextEditor */; };
DB6672A425F9FDE500D60309 /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DB6672A225F9FDE500D60309 /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; };
DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; };
DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; };
@ -241,10 +245,13 @@
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; };
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; };
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; };
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; };
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; };
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; };
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; };
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; };
DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; };
DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -292,7 +299,7 @@
dstPath = "";
dstSubfolderSpec = 10;
files = (
DB6672A425F9FDE500D60309 /* TwitterTextEditor in Embed Frameworks */,
DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */,
DB89BA0425C10FD0008580ED /* CoreDataStack.framework in Embed Frameworks */,
);
name = "Embed Frameworks";
@ -416,6 +423,7 @@
DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+Diffable.swift"; sourceTree = "<group>"; };
DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerItem.swift; sourceTree = "<group>"; };
DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = "<group>"; };
DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerInputViewModel.swift; sourceTree = "<group>"; };
DB2B3ABD25E37E15007045F9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = "<group>"; };
DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = "<group>"; };
@ -436,6 +444,11 @@
DB427DF725BAA00100D1B89D /* MastodonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUITests.swift; sourceTree = "<group>"; };
DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
DB44384E25E8C1FA008912A2 /* CALayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CALayer.swift; sourceTree = "<group>"; };
DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerInputView.swift; sourceTree = "<group>"; };
DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerSection.swift; sourceTree = "<group>"; };
DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerItem.swift; sourceTree = "<group>"; };
DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerItemCollectionViewCell.swift; sourceTree = "<group>"; };
DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerHeaderCollectionReusableView.swift; sourceTree = "<group>"; };
DB4481AC25EE155900BEFB67 /* Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Poll.swift; sourceTree = "<group>"; };
DB4481B225EE16D000BEFB67 /* PollOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOption.swift; sourceTree = "<group>"; };
DB4481B825EE289600BEFB67 /* UITableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UITableView.swift; sourceTree = "<group>"; };
@ -541,6 +554,7 @@
DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = "<group>"; };
DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = "<group>"; };
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = "<group>"; };
DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = "<group>"; };
DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = "<group>"; };
DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = "<group>"; };
@ -555,7 +569,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
DB6672A325F9FDE500D60309 /* TwitterTextEditor in Frameworks */,
DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */,
DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */,
2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */,
@ -565,6 +578,7 @@
DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */,
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */,
DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */,
DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */,
2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -763,6 +777,7 @@
isa = PBXGroup;
children = (
DB45FB0425CA87B4005A8AC7 /* APIService */,
DB49A61925FF327D00B98345 /* EmojiService */,
DB9A489B26036E19008B817C /* MastodonAttachmentService */,
DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */,
DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */,
@ -770,7 +785,6 @@
2DA6054625F716A2006356F9 /* PlaybackState.swift */,
5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */,
DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */,
DB49A61925FF327D00B98345 /* EmojiService */,
);
path = Service;
sourceTree = "<group>";
@ -830,6 +844,7 @@
DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */,
DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */,
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */,
DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */,
);
path = Section;
sourceTree = "<group>";
@ -880,6 +895,7 @@
DB1E347725F519300079D7DF /* PickServerItem.swift */,
DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */,
DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */,
DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */,
);
path = Item;
sourceTree = "<group>";
@ -1106,6 +1122,9 @@
DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */,
DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */,
DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */,
DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */,
DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */,
DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */,
);
path = View;
sourceTree = "<group>";
@ -1152,8 +1171,8 @@
DB789A2125F9F76D0071ACA0 /* CollectionViewCell */,
DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */,
DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */,
DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */,
DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */,
DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */,
);
path = Compose;
sourceTree = "<group>";
@ -1167,6 +1186,8 @@
DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */,
DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */,
DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */,
DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */,
DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */,
);
path = CollectionViewCell;
sourceTree = "<group>";
@ -1466,8 +1487,8 @@
DB5086B725CC0D6400C2C187 /* Kingfisher */,
2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */,
2D939AC725EE14620076FA61 /* CropViewController */,
DB6672A225F9FDE500D60309 /* TwitterTextEditor */,
DB9A487D2603456B008B817C /* UITextView+Placeholder */,
DBE64A8A260C49D200E6359A /* TwitterTextEditor */,
);
productName = Mastodon;
productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */;
@ -1597,8 +1618,8 @@
DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */,
2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */,
2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */,
DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */,
DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */,
DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */,
);
productRefGroup = DB427DD325BAA00100D1B89D /* Products */;
projectDirPath = "";
@ -1797,6 +1818,7 @@
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */,
DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */,
2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */,
DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */,
0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */,
DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */,
2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */,
@ -1878,14 +1900,18 @@
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */,
DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */,
DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */,
DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */,
DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */,
DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */,
DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */,
2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */,
5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */,
2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */,
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */,
DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */,
DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */,
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,
DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */,
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */,
DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */,
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
@ -1919,6 +1945,7 @@
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */,
DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */,
DB084B5725CBC56C00F898ED /* Toot.swift in Sources */,
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */,
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */,
@ -1936,6 +1963,7 @@
2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */,
DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */,
5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */,
DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */,
DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */,
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */,
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */,
@ -2557,14 +2585,6 @@
minimumVersion = 6.1.0;
};
};
DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/twitter/TwitterTextEditor.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.0.0;
};
};
DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/MainasuK/UITextView-Placeholder";
@ -2573,6 +2593,14 @@
minimumVersion = 1.4.1;
};
};
DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/MainasuK/TwitterTextEditor";
requirement = {
branch = "feature/input-view";
kind = branch;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@ -2615,16 +2643,16 @@
package = DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = Kingfisher;
};
DB6672A225F9FDE500D60309 /* TwitterTextEditor */ = {
isa = XCSwiftPackageProductDependency;
package = DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */;
productName = TwitterTextEditor;
};
DB9A487D2603456B008B817C /* UITextView+Placeholder */ = {
isa = XCSwiftPackageProductDependency;
package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */;
productName = "UITextView+Placeholder";
};
DBE64A8A260C49D200E6359A /* TwitterTextEditor */ = {
isa = XCSwiftPackageProductDependency;
package = DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */;
productName = TwitterTextEditor;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCVersionGroup section */

View File

@ -102,11 +102,11 @@
},
{
"package": "TwitterTextEditor",
"repositoryURL": "https://github.com/twitter/TwitterTextEditor.git",
"repositoryURL": "https://github.com/MainasuK/TwitterTextEditor",
"state": {
"branch": null,
"revision": "8aa914134c5b6aa46e862de63f239ec0e3b52a91",
"version": "1.0.0"
"branch": "feature/input-view",
"revision": "03e7b7497d424d96268f5bcca1f8e9955bb80fea",
"version": null
}
},
{

View File

@ -32,11 +32,16 @@ extension ComposeStatusItem {
let username = CurrentValueSubject<String?, Never>(nil)
let composeContent = CurrentValueSubject<String?, Never>(nil)
let isContentWarningComposing = CurrentValueSubject<Bool, Never>(false)
let contentWarningContent = CurrentValueSubject<String, Never>("")
static func == (lhs: ComposeStatusAttribute, rhs: ComposeStatusAttribute) -> Bool {
return lhs.avatarURL.value == rhs.avatarURL.value &&
lhs.displayName.value == rhs.displayName.value &&
lhs.username.value == rhs.username.value &&
lhs.composeContent.value == rhs.composeContent.value
lhs.username.value == rhs.username.value &&
lhs.composeContent.value == rhs.composeContent.value &&
lhs.isContentWarningComposing.value == rhs.isContentWarningComposing.value &&
lhs.contentWarningContent.value == rhs.contentWarningContent.value
}
func hash(into hasher: inout Hasher) {

View File

@ -0,0 +1,36 @@
//
// CustomEmojiPickerItem.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-24.
//
import Foundation
import MastodonSDK
enum CustomEmojiPickerItem {
case emoji(attribute: CustomEmojiAttribute)
}
extension CustomEmojiPickerItem: Equatable, Hashable { }
extension CustomEmojiPickerItem {
final class CustomEmojiAttribute: Equatable, Hashable {
let id = UUID()
let emoji: Mastodon.Entity.Emoji
init(emoji: Mastodon.Entity.Emoji) {
self.emoji = emoji
}
static func == (lhs: CustomEmojiPickerItem.CustomEmojiAttribute, rhs: CustomEmojiPickerItem.CustomEmojiAttribute) -> Bool {
return lhs.id == rhs.id &&
lhs.emoji.shortcode == rhs.emoji.shortcode
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
}

View File

@ -27,19 +27,26 @@ extension ComposeStatusSection {
}
extension ComposeStatusSection {
static func collectionViewDiffableDataSource(
for collectionView: UICollectionView,
dependency: NeedsDependency,
managedObjectContext: NSManagedObjectContext,
composeKind: ComposeKind,
customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel,
textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate,
composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate,
composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate,
composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate
) -> UICollectionViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem> {
UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in
UICollectionViewDiffableDataSource(collectionView: collectionView) { [
weak customEmojiPickerInputViewModel,
weak textEditorViewTextAttributesDelegate,
weak composeStatusAttachmentTableViewCellDelegate,
weak composeStatusPollOptionCollectionViewCellDelegate,
weak composeStatusNewPollOptionCollectionViewCellDelegate,
weak composeStatusPollExpiresOptionCollectionViewCellDelegate
] collectionView, indexPath, item -> UICollectionViewCell? in
switch item {
case .replyTo(let repliedToStatusObjectID):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToTootContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToTootContentCollectionViewCell
@ -68,6 +75,33 @@ extension ComposeStatusSection {
attribute.composeContent.value = text
}
.store(in: &cell.disposeBag)
attribute.isContentWarningComposing
.receive(on: DispatchQueue.main)
.sink { isContentWarningComposing in
// 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 { text in
// self size input cell
collectionView.collectionViewLayout.invalidateLayout()
// bind input data
attribute.contentWarningContent.value = text
}
.store(in: &cell.disposeBag)
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.textEditorView, disposeBag: &cell.disposeBag)
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: 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
@ -136,6 +170,7 @@ extension ComposeStatusSection {
.assign(to: \.value, on: attribute.option)
.store(in: &cell.disposeBag)
cell.delegate = composeStatusPollOptionCollectionViewCellDelegate
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.pollOptionView.optionTextField, disposeBag: &cell.disposeBag)
return cell
case .pollOptionAppendEntry:
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionAppendEntryCollectionViewCell
@ -158,6 +193,7 @@ extension ComposeStatusSection {
}
extension ComposeStatusSection {
static func configure(
cell: ComposeStatusContentCollectionViewCell,
attribute: ComposeStatusItem.ComposeStatusAttribute
@ -177,7 +213,7 @@ extension ComposeStatusSection {
.receive(on: DispatchQueue.main)
.sink { displayName, username in
cell.statusView.nameLabel.text = displayName
cell.statusView.usernameLabel.text = username
cell.statusView.usernameLabel.text = username.flatMap { "@" + $0 } ?? " "
}
.store(in: &cell.disposeBag)
@ -187,4 +223,57 @@ extension ComposeStatusSection {
.assign(to: \.value, on: attribute.composeContent)
.store(in: &cell.disposeBag)
}
}
protocol CustomEmojiReplacableTextInput: AnyObject {
var inputView: UIView? { get set }
func reloadInputViews()
// UIKeyInput
func insertText(_ text: String)
// UIResponder
var isFirstResponder: Bool { get }
}
class CustomEmojiReplacableTextInputReference {
weak var value: CustomEmojiReplacableTextInput?
init(value: CustomEmojiReplacableTextInput? = nil) {
self.value = value
}
}
extension TextEditorView: CustomEmojiReplacableTextInput {
func insertText(_ text: String) {
try? updateByReplacing(range: selectedRange, with: text, selectedRange: nil)
}
public override var isFirstResponder: Bool {
return isEditing
}
}
extension UITextField: CustomEmojiReplacableTextInput { }
extension UITextView: CustomEmojiReplacableTextInput { }
extension ComposeStatusSection {
static func configureCustomEmojiPicker(
viewModel: CustomEmojiPickerInputViewModel?,
customEmojiReplacableTextInput: CustomEmojiReplacableTextInput,
disposeBag: inout Set<AnyCancellable>
) {
guard let viewModel = viewModel else { return }
viewModel.isCustomEmojiComposing
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] isCustomEmojiComposing in
guard let viewModel = viewModel else { return }
customEmojiReplacableTextInput.inputView = isCustomEmojiComposing ? viewModel.customEmojiPickerInputView : nil
customEmojiReplacableTextInput.reloadInputViews()
viewModel.append(customEmojiReplacableTextInput: customEmojiReplacableTextInput)
}
.store(in: &disposeBag)
}
}

View File

@ -0,0 +1,60 @@
//
// CustomEmojiPickerSection.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-24.
//
import UIKit
import Kingfisher
enum CustomEmojiPickerSection: Equatable, Hashable {
case emoji(name: String)
}
extension CustomEmojiPickerSection {
static func collectionViewDiffableDataSource(
for collectionView: UICollectionView,
dependency: NeedsDependency
) -> UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem> {
let dataSource = UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in
switch item {
case .emoji(let attribute):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell
let placeholder = UIImage.placeholder(size: CustomEmojiPickerItemCollectionViewCell.itemSize, color: .systemFill)
.af.imageRounded(withCornerRadius: 4)
cell.emojiImageView.kf.setImage(
with: URL(string: attribute.emoji.url),
placeholder: placeholder,
options: [
.transition(.fade(0.2))
],
completionHandler: nil
)
return cell
}
}
dataSource.supplementaryViewProvider = { [weak dataSource] collectionView, kind, indexPath -> UICollectionReusableView? in
guard let dataSource = dataSource else { return nil }
let sections = dataSource.snapshot().sectionIdentifiers
guard indexPath.section < sections.count else { return nil }
let section = sections[indexPath.section]
switch kind {
case String(describing: CustomEmojiPickerHeaderCollectionReusableView.self):
let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), for: indexPath) as! CustomEmojiPickerHeaderCollectionReusableView
switch section {
case .emoji(let name):
header.titlelabel.text = name
}
return header
default:
assertionFailure()
return nil
}
}
return dataSource
}
}

View File

@ -75,10 +75,10 @@ internal enum Asset {
internal static let buttonDefault = ColorAsset(name: "Colors/buttonDefault")
internal static let buttonDisabled = ColorAsset(name: "Colors/buttonDisabled")
internal static let buttonInactive = ColorAsset(name: "Colors/buttonInactive")
internal static let danger = ColorAsset(name: "Colors/danger")
internal static let lightAlertYellow = ColorAsset(name: "Colors/lightAlertYellow")
internal static let lightBackground = ColorAsset(name: "Colors/lightBackground")
internal static let lightBrandBlue = ColorAsset(name: "Colors/lightBrandBlue")
internal static let lightDangerRed = ColorAsset(name: "Colors/lightDangerRed")
internal static let lightDarkGray = ColorAsset(name: "Colors/lightDarkGray")
internal static let lightDisabled = ColorAsset(name: "Colors/lightDisabled")
internal static let lightInactive = ColorAsset(name: "Colors/lightInactive")

View File

@ -162,6 +162,10 @@ internal enum L10n {
/// video
internal static let video = L10n.tr("Localizable", "Scene.Compose.Attachment.Video")
}
internal enum ContentWarning {
/// Write an accurate warning here...
internal static let placeholder = L10n.tr("Localizable", "Scene.Compose.ContentWarning.Placeholder")
}
internal enum MediaSelection {
/// Browse
internal static let browse = L10n.tr("Localizable", "Scene.Compose.MediaSelection.Browse")
@ -194,6 +198,16 @@ internal enum L10n {
/// New Reply
internal static let newReply = L10n.tr("Localizable", "Scene.Compose.Title.NewReply")
}
internal enum Visibility {
/// Only people I mention
internal static let direct = L10n.tr("Localizable", "Scene.Compose.Visibility.Direct")
/// Followers only
internal static let `private` = L10n.tr("Localizable", "Scene.Compose.Visibility.Private")
/// Public
internal static let `public` = L10n.tr("Localizable", "Scene.Compose.Visibility.Public")
/// Unlisted
internal static let unlisted = L10n.tr("Localizable", "Scene.Compose.Visibility.Unlisted")
}
}
internal enum ConfirmEmail {
/// We just sent an email to %@,\ntap the link to confirm your account.

View File

@ -47,6 +47,7 @@ uploaded to Mastodon.";
"Scene.Compose.Attachment.Video" = "video";
"Scene.Compose.ComposeAction" = "Publish";
"Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind";
"Scene.Compose.ContentWarning.Placeholder" = "Write an accurate warning here...";
"Scene.Compose.MediaSelection.Browse" = "Browse";
"Scene.Compose.MediaSelection.Camera" = "Take Photo";
"Scene.Compose.MediaSelection.PhotoLibrary" = "Photo Library";
@ -59,6 +60,10 @@ uploaded to Mastodon.";
"Scene.Compose.Poll.ThreeDays" = "3 Days";
"Scene.Compose.Title.NewPost" = "New Post";
"Scene.Compose.Title.NewReply" = "New Reply";
"Scene.Compose.Visibility.Direct" = "Only people I mention";
"Scene.Compose.Visibility.Private" = "Followers only";
"Scene.Compose.Visibility.Public" = "Public";
"Scene.Compose.Visibility.Unlisted" = "Unlisted";
"Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email";
"Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App";
"Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you havent.";

View File

@ -5,6 +5,7 @@
// Created by MainasuK Cirno on 2021-3-11.
//
import os.log
import UIKit
import Combine
import TwitterTextEditor
@ -15,6 +16,8 @@ final class ComposeStatusContentCollectionViewCell: UICollectionViewCell {
let statusView = StatusView()
let statusContentWarningEditorView = StatusContentWarningEditorView()
let textEditorView: TextEditorView = {
let textEditorView = TextEditorView()
textEditorView.font = .preferredFont(forTextStyle: .body)
@ -25,7 +28,9 @@ final class ComposeStatusContentCollectionViewCell: UICollectionViewCell {
return textEditorView
}()
// output
let composeContent = PassthroughSubject<String, Never>()
let contentWarningContent = PassthroughSubject<String, Never>()
override init(frame: CGRect) {
super.init(frame: frame)
@ -45,10 +50,20 @@ extension ComposeStatusContentCollectionViewCell {
// selectionStyle = .none
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: contentView.topAnchor, constant: 20),
statusView.topAnchor.constraint(equalTo: statusContentWarningEditorView.bottomAnchor, constant: 20),
statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
statusView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
])
@ -70,23 +85,39 @@ extension ComposeStatusContentCollectionViewCell {
textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
])
textEditorView.setContentCompressionResistancePriority(.required - 2, for: .vertical)
// TODO:
statusContentWarningEditorView.textView.delegate = self
textEditorView.changeObserver = self
}
override func didMoveToWindow() {
super.didMoveToWindow()
statusContentWarningEditorView.containerView.isHidden = true
}
}
// MARK: - UITextViewDelegate
// MARK: - TextEditorViewChangeObserver
extension ComposeStatusContentCollectionViewCell: TextEditorViewChangeObserver {
func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) {
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,6 +10,7 @@ import UIKit
import Combine
protocol ComposeStatusPollOptionCollectionViewCellDelegate: class {
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField)
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?)
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField)
}
@ -77,6 +78,7 @@ extension ComposeStatusPollOptionCollectionViewCell {
reorderBarImageView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
reorderBarImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
reorderBarImageView.setContentCompressionResistancePriority(.defaultHigh + 10, for: .horizontal)
pollOptionView.checkmarkImageView.isHidden = true
pollOptionView.optionPercentageLabel.isHidden = true
@ -131,6 +133,12 @@ extension ComposeStatusPollOptionCollectionViewCell: DeleteBackwardResponseTextF
// MARK: - UITextFieldDelegate
extension ComposeStatusPollOptionCollectionViewCell: UITextFieldDelegate {
func textFieldDidBeginEditing(_ textField: UITextField) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.composeStatusPollOptionCollectionViewCell(self, textFieldDidBeginEditing: textField)
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
if textField === pollOptionView.optionTextField {

View File

@ -0,0 +1,42 @@
//
// CustomEmojiPickerHeaderCollectionReusableView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-24.
//
import UIKit
final class CustomEmojiPickerHeaderCollectionReusableView: UICollectionReusableView {
let titlelabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .bold))
label.textColor = Asset.Colors.Label.secondary.color
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension CustomEmojiPickerHeaderCollectionReusableView {
private func _init() {
titlelabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(titlelabel)
NSLayoutConstraint.activate([
titlelabel.topAnchor.constraint(equalTo: topAnchor, constant: 20),
titlelabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
titlelabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
titlelabel.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
}

View File

@ -0,0 +1,52 @@
//
// CustomEmojiPickerItemCollectionViewCell.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-24.
//
import UIKit
final class CustomEmojiPickerItemCollectionViewCell: UICollectionViewCell {
static let itemSize = CGSize(width: 44, height: 44)
let emojiImageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.layer.masksToBounds = true
return imageView
}()
override var isHighlighted: Bool {
didSet {
emojiImageView.alpha = isHighlighted ? 0.5 : 1.0
}
}
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension CustomEmojiPickerItemCollectionViewCell {
private func _init() {
emojiImageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(emojiImageView)
NSLayoutConstraint.activate([
emojiImageView.topAnchor.constraint(equalTo: contentView.topAnchor),
emojiImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
emojiImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
emojiImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
}
}

View File

@ -10,6 +10,7 @@ import UIKit
import Combine
import PhotosUI
import Kingfisher
import MastodonSDK
import TwitterTextEditor
final class ComposeViewController: UIViewController, NeedsDependency {
@ -52,9 +53,25 @@ final class ComposeViewController: UIViewController, NeedsDependency {
return collectionView
}()
var systemKeyboardHeight: CGFloat = .zero {
didSet {
// note: some system AutoLayout warning here
customEmojiPickerInputView.frame.size.height = systemKeyboardHeight != .zero ? systemKeyboardHeight : 300
}
}
// CustomEmojiPickerView
let customEmojiPickerInputView: CustomEmojiPickerInputView = {
let view = CustomEmojiPickerInputView(frame: CGRect(x: 0, y: 0, width: 0, height: 300), inputViewStyle: .keyboard)
return view
}()
let composeToolbarView: ComposeToolbarView = {
let composeToolbarView = ComposeToolbarView()
composeToolbarView.backgroundColor = .secondarySystemBackground
let text = UITextView()
let inputView = UIInputView(frame: .init(x: 0, y: 0, width: 40, height: 40), inputViewStyle: .keyboard)
text.inputAccessoryView = inputView
composeToolbarView.backgroundColor = inputView.backgroundColor
return composeToolbarView
}()
var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint!
@ -86,14 +103,18 @@ final class ComposeViewController: UIViewController, NeedsDependency {
return documentPickerController
}()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ComposeViewController {
private static func createLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(100))
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(100))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
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
// section.interGroupSpacing = 10
@ -153,6 +174,7 @@ extension ComposeViewController {
viewModel.setupDiffableDataSource(
for: collectionView,
dependency: self,
customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel,
textEditorViewTextAttributesDelegate: self,
composeStatusAttachmentTableViewCellDelegate: self,
composeStatusPollOptionCollectionViewCellDelegate: self,
@ -162,15 +184,23 @@ extension ComposeViewController {
let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeViewController.longPressReorderGestureHandler(_:)))
collectionView.addGestureRecognizer(longPressReorderGesture)
customEmojiPickerInputView.collectionView.delegate = self
viewModel.customEmojiPickerInputViewModel.customEmojiPickerInputView = customEmojiPickerInputView
viewModel.setupCustomEmojiPickerDiffableDataSource(
for: customEmojiPickerInputView.collectionView,
dependency: self
)
// respond scrollView overlap change
view.layoutIfNeeded()
// update layout when keyboard show/dismiss
Publishers.CombineLatest3(
Publishers.CombineLatest4(
KeyboardResponderService.shared.isShow.eraseToAnyPublisher(),
KeyboardResponderService.shared.state.eraseToAnyPublisher(),
KeyboardResponderService.shared.endFrame.eraseToAnyPublisher()
KeyboardResponderService.shared.endFrame.eraseToAnyPublisher(),
viewModel.isCustomEmojiComposing.eraseToAnyPublisher()
)
.sink(receiveValue: { [weak self] isShow, state, endFrame in
.sink(receiveValue: { [weak self] isShow, state, endFrame, isCustomEmojiComposing in
guard let self = self else { return }
guard isShow, state == .dock else {
@ -182,8 +212,9 @@ extension ComposeViewController {
}
return
}
// isShow AND dock state
self.systemKeyboardHeight = endFrame.height
let contentFrame = self.view.convert(self.collectionView.frame, to: nil)
let padding = contentFrame.maxY - endFrame.minY
guard padding > 0 else {
@ -206,22 +237,61 @@ extension ComposeViewController {
})
.store(in: &disposeBag)
// bind publish bar button state
viewModel.isPublishBarButtonItemEnabled
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: publishBarButtonItem)
.store(in: &disposeBag)
// bind media button toolbar state
viewModel.isMediaToolbarButtonEnabled
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: composeToolbarView.mediaButton)
.store(in: &disposeBag)
// bind poll button toolbar state
viewModel.isPollToolbarButtonEnabled
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: composeToolbarView.pollButton)
.store(in: &disposeBag)
// bind custom emojis
// bind image picker toolbar state
viewModel.attachmentServices
.receive(on: DispatchQueue.main)
.sink { [weak self] attachmentServices in
guard let self = self else { return }
self.composeToolbarView.mediaButton.isEnabled = attachmentServices.count < 4
self.resetImagePicker()
}
.store(in: &disposeBag)
// bind visibility toolbar UI
viewModel.selectedStatusVisibility
.receive(on: DispatchQueue.main)
.sink { [weak self] type in
guard let self = self else { return }
self.composeToolbarView.visibilityButton.setImage(type.image, for: .normal)
}
.store(in: &disposeBag)
viewModel.characterCount
.receive(on: DispatchQueue.main)
.sink { [weak self] characterCount in
guard let self = self else { return }
let count = ComposeViewModel.composeContentLimit - characterCount
self.composeToolbarView.characterCountLabel.text = "\(count)"
switch count {
case _ where count < 0:
self.composeToolbarView.characterCountLabel.font = .systemFont(ofSize: 24, weight: .bold)
self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.danger.color
default:
self.composeToolbarView.characterCountLabel.font = .systemFont(ofSize: 15, weight: .regular)
self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.Label.secondary.color
}
}
.store(in: &disposeBag)
// bind text editor for custom emojis update event
viewModel.customEmojiViewModel
.compactMap { $0?.emojis }
.switchToLatest()
@ -235,14 +305,24 @@ extension ComposeViewController {
})
.store(in: &disposeBag)
// bind image picker toolbar state
viewModel.attachmentServices
// bind custom emoji picker UI
viewModel.customEmojiViewModel
.receive(on: DispatchQueue.main)
.sink { [weak self] attachmentServices in
guard let self = self else { return }
self.composeToolbarView.mediaButton.isEnabled = attachmentServices.count < 4
self.resetImagePicker()
.map { viewModel -> AnyPublisher<[Mastodon.Entity.Emoji], Never> in
guard let viewModel = viewModel else {
return Just([]).eraseToAnyPublisher()
}
return viewModel.emojis.eraseToAnyPublisher()
}
.switchToLatest()
.sink(receiveValue: { [weak self] emojis in
guard let self = self else { return }
if emojis.isEmpty {
self.customEmojiPickerInputView.activityIndicatorView.startAnimating()
} else {
self.customEmojiPickerInputView.activityIndicatorView.stopAnimating()
}
})
.store(in: &disposeBag)
}
@ -283,6 +363,25 @@ extension ComposeViewController {
textEditorView()?.isEditing = true
}
private func contentWarningEditorTextView() -> UITextView? {
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
let items = diffableDataSource.snapshot().itemIdentifiers
for item in items {
switch item {
case .input:
guard let indexPath = diffableDataSource.indexPath(for: item),
let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusContentCollectionViewCell else {
continue
}
return cell.statusContentWarningEditorView.textView
default:
continue
}
}
return nil
}
private func pollOptionCollectionViewCell(of item: ComposeStatusItem) -> ComposeStatusPollOptionCollectionViewCell? {
guard case .pollOption = item else { return nil }
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
@ -364,7 +463,7 @@ extension ComposeViewController {
imagePicker.delegate = self
return imagePicker
}
}
extension ComposeViewController {
@ -553,6 +652,15 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
attributedString.addAttributes(attributes, range: match.range)
}
if string.count > ComposeViewModel.composeContentLimit {
var attributes = [NSAttributedString.Key: Any]()
attributes[.foregroundColor] = Asset.Colors.danger.color
let boundStart = string.index(string.startIndex, offsetBy: ComposeViewModel.composeContentLimit)
let boundEnd = string.endIndex
let range = boundStart..<boundEnd
attributedString.addAttributes(attributes, range: NSRange(range, in: string))
}
completion(attributedString)
}
}
@ -563,8 +671,8 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
// MARK: - ComposeToolbarViewDelegate
extension ComposeViewController: ComposeToolbarViewDelegate {
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType: ComposeToolbarView.MediaSelectionType) {
switch mediaSelectionType {
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) {
switch type {
case .photoLibrary:
present(imagePicker, animated: true, completion: nil)
case .camera:
@ -593,12 +701,28 @@ extension ComposeViewController: ComposeToolbarViewDelegate {
}
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: UIButton) {
viewModel.isCustomEmojiComposing.value.toggle()
}
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) {
// restore first responder for text editor when content warning dismiss
if viewModel.isContentWarningComposing.value {
if contentWarningEditorTextView()?.isFirstResponder == true {
markTextEditorViewBecomeFirstResponser()
}
}
// toggle composing status
viewModel.isContentWarningComposing.value.toggle()
// active content warning after toggled
if viewModel.isContentWarningComposing.value {
contentWarningEditorTextView()?.becomeFirstResponder()
}
}
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton) {
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType) {
viewModel.selectedStatusVisibility.value = type
}
}
@ -606,6 +730,35 @@ extension ComposeViewController: ComposeToolbarViewDelegate {
// MARK: - UITableViewDelegate
extension ComposeViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription)
if collectionView === customEmojiPickerInputView.collectionView {
guard let diffableDataSource = viewModel.customEmojiPickerDiffableDataSource else { return }
let item = diffableDataSource.itemIdentifier(for: indexPath)
guard case let .emoji(attribute) = item else { return }
let emoji = attribute.emoji
let textEditorView = self.textEditorView()
// retrive active text input and insert emoji
// the leading and trailing space is REQUIRED to fix `UITextStorage` layout issue
let reference = viewModel.customEmojiPickerInputViewModel.insertText(" :\(emoji.shortcode): ")
// workaround: non-user interactive change do not trigger value update event
if reference?.value === textEditorView {
viewModel.composeStatusAttribute.composeContent.value = textEditorView?.text
// update text storage
textEditorView?.setNeedsUpdateTextAttributes()
// collection self-size
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.collectionView.collectionViewLayout.invalidateLayout()
}
}
} else {
// do nothing
}
}
}
// MARK: - UIAdaptivePresentationControllerDelegate
@ -707,6 +860,14 @@ extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelega
// MARK: - ComposeStatusPollOptionCollectionViewCellDelegate
extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelegate {
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField) {
// FIXME: make poll section visible
// DispatchQueue.main.async {
// self.collectionView.scroll(to: .bottom, animated: true)
// }
}
// handle delete backward event for poll option input
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) {
guard (text ?? "").isEmpty else { return }

View File

@ -6,13 +6,16 @@
//
import UIKit
import Combine
import TwitterTextEditor
import MastodonSDK
extension ComposeViewModel {
func setupDiffableDataSource(
for collectionView: UICollectionView,
dependency: NeedsDependency,
customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel,
textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate,
composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate,
@ -24,6 +27,7 @@ extension ComposeViewModel {
dependency: dependency,
managedObjectContext: context.managedObjectContext,
composeKind: composeKind,
customEmojiPickerInputViewModel: customEmojiPickerInputViewModel,
textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate,
composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate,
composeStatusPollOptionCollectionViewCellDelegate: composeStatusPollOptionCollectionViewCellDelegate,
@ -51,7 +55,6 @@ extension ComposeViewModel {
self.pollOptionAttributes.value = pollOptionAttributes
}
self.diffableDataSource = diffableDataSource
var snapshot = NSDiffableDataSourceSnapshot<ComposeStatusSection, ComposeStatusItem>()
snapshot.appendSections([.repliedTo, .status, .attachment, .poll])
@ -65,4 +68,49 @@ extension ComposeViewModel {
diffableDataSource.apply(snapshot, animatingDifferences: false)
}
func setupCustomEmojiPickerDiffableDataSource(
for collectionView: UICollectionView,
dependency: NeedsDependency
) {
let diffableDataSource = CustomEmojiPickerSection.collectionViewDiffableDataSource(
for: collectionView,
dependency: dependency
)
self.customEmojiPickerDiffableDataSource = diffableDataSource
customEmojiViewModel
.sink { [weak self, weak diffableDataSource] customEmojiViewModel in
guard let self = self else { return }
guard let diffableDataSource = diffableDataSource else { return }
guard let customEmojiViewModel = customEmojiViewModel else {
self.customEmojiViewModelSubscription = nil
let snapshot = NSDiffableDataSourceSnapshot<CustomEmojiPickerSection, CustomEmojiPickerItem>()
diffableDataSource.apply(snapshot)
return
}
self.customEmojiViewModelSubscription = customEmojiViewModel.emojis
.receive(on: DispatchQueue.main)
.sink { [weak self, weak diffableDataSource] emojis in
guard let _ = self else { return }
guard let diffableDataSource = diffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<CustomEmojiPickerSection, CustomEmojiPickerItem>()
let customEmojiSection = CustomEmojiPickerSection.emoji(name: customEmojiViewModel.domain.uppercased())
snapshot.appendSections([customEmojiSection])
let items: [CustomEmojiPickerItem] = {
var items = [CustomEmojiPickerItem]()
for emoji in emojis where emoji.visibleInPicker {
let attribute = CustomEmojiPickerItem.CustomEmojiAttribute(emoji: emoji)
let item = CustomEmojiPickerItem.emoji(attribute: attribute)
items.append(item)
}
return items
}()
snapshot.appendItems(items, toSection: customEmojiSection)
diffableDataSource.apply(snapshot)
}
}
.store(in: &disposeBag)
}
}

View File

@ -61,6 +61,15 @@ extension ComposeViewModel.PublishState {
guard viewModel.isPollComposing.value else { return nil }
return viewModel.pollExpiresOptionAttribute.expiresOption.value.seconds
}()
let sensitive: Bool = viewModel.isContentWarningComposing.value
let spoilerText: String? = {
let text = viewModel.composeStatusAttribute.contentWarningContent.value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
return nil
}
return text
}()
let visibility = viewModel.selectedStatusVisibility.value.visibility
let updateMediaQuerySubscriptions: [AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error>] = {
var subscriptions: [AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error>] = []
@ -92,7 +101,10 @@ extension ComposeViewModel.PublishState {
status: viewModel.composeStatusAttribute.composeContent.value,
mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs,
pollOptions: pollOptions,
pollExpiresIn: pollExpiresIn
pollExpiresIn: pollExpiresIn,
sensitive: sensitive,
spoilerText: spoilerText,
visibility: visibility
)
return viewModel.context.apiService.publishStatus(
domain: domain,

View File

@ -5,14 +5,18 @@
// Created by MainasuK Cirno on 2021-3-11.
//
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
import GameplayKit
import MastodonSDK
final class ComposeViewModel {
static let composeContentLimit: Int = 500
var disposeBag = Set<AnyCancellable>()
// input
@ -20,12 +24,15 @@ final class ComposeViewModel {
let composeKind: ComposeStatusSection.ComposeKind
let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute()
let isPollComposing = CurrentValueSubject<Bool, Never>(false)
let isCustomEmojiComposing = CurrentValueSubject<Bool, Never>(false)
let isContentWarningComposing = CurrentValueSubject<Bool, Never>(false)
let selectedStatusVisibility = CurrentValueSubject<ComposeToolbarView.VisibilitySelectionType, Never>(.public)
let activeAuthentication: CurrentValueSubject<MastodonAuthentication?, Never>
let activeAuthenticationBox: CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>
// output
//var diffableDataSource: UITableViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>!
var diffableDataSource: UICollectionViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>!
var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>!
private(set) lazy var publishStateMachine: GKStateMachine = {
// exclude timeline middle fetcher state
let stateMachine = GKStateMachine(states: [
@ -44,9 +51,13 @@ final class ComposeViewModel {
let isPublishBarButtonItemEnabled = CurrentValueSubject<Bool, Never>(false)
let isMediaToolbarButtonEnabled = CurrentValueSubject<Bool, Never>(true)
let isPollToolbarButtonEnabled = CurrentValueSubject<Bool, Never>(true)
let characterCount = CurrentValueSubject<Int, Never>(0)
// custom emojis
var customEmojiViewModelSubscription: AnyCancellable?
let customEmojiViewModel = CurrentValueSubject<EmojiService.CustomEmojiViewModel?, Never>(nil)
let customEmojiPickerInputViewModel = CustomEmojiPickerInputViewModel()
let isLoadingCustomEmoji = CurrentValueSubject<Bool, Never>(false)
// attachment
let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([])
@ -69,6 +80,14 @@ final class ComposeViewModel {
self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value)
// end init
isCustomEmojiComposing
.assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing)
.store(in: &disposeBag)
isContentWarningComposing
.assign(to: \.value, on: composeStatusAttribute.isContentWarningComposing)
.store(in: &disposeBag)
// bind active authentication
context.authenticationService.activeMastodonAuthentication
.assign(to: \.value, on: activeAuthentication)
@ -95,10 +114,30 @@ final class ComposeViewModel {
}
.store(in: &disposeBag)
// bind character count
Publishers.CombineLatest3(
composeStatusAttribute.composeContent.eraseToAnyPublisher(),
composeStatusAttribute.isContentWarningComposing.eraseToAnyPublisher(),
composeStatusAttribute.contentWarningContent.eraseToAnyPublisher()
)
.map { composeContent, isContentWarningComposing, contentWarningContent -> Int in
let composeContent = composeContent ?? ""
var count = composeContent.count
if isContentWarningComposing {
count += contentWarningContent.count
}
return count
}
.assign(to: \.value, on: characterCount)
.store(in: &disposeBag)
// bind compose bar button item UI state
let isComposeContentEmpty = composeStatusAttribute.composeContent
.map { ($0 ?? "").isEmpty }
let isComposeContentValid = Just(true).eraseToAnyPublisher()
let isComposeContentValid = composeStatusAttribute.composeContent
.map { composeContent -> Bool in
let composeContent = composeContent ?? ""
return composeContent.count <= ComposeViewModel.composeContentLimit
}
let isMediaEmpty = attachmentServices
.map { $0.isEmpty }
let isMediaUploadAllSuccess = attachmentServices
@ -264,6 +303,10 @@ final class ComposeViewModel {
.store(in: &disposeBag)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ComposeViewModel {
@ -287,6 +330,6 @@ extension ComposeViewModel: MastodonAttachmentServiceDelegate {
extension ComposeViewModel: ComposePollAttributeDelegate {
func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?) {
// trigger update
pollOptionAttributes.value = pollOptionAttributes.value
// pollOptionAttributes.value = pollOptionAttributes.value
}
}

View File

@ -7,13 +7,14 @@
import os.log
import UIKit
import MastodonSDK
protocol ComposeToolbarViewDelegate: class {
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType: ComposeToolbarView.MediaSelectionType)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType type: ComposeToolbarView.MediaSelectionType)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: UIButton)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: UIButton)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton)
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType)
}
final class ComposeToolbarView: UIView {
@ -58,6 +59,14 @@ final class ComposeToolbarView: UIView {
return button
}()
let characterCountLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 15, weight: .regular)
label.text = "500"
label.textColor = Asset.Colors.Label.secondary.color
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
@ -101,12 +110,23 @@ extension ComposeToolbarView {
])
}
characterCountLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(characterCountLabel)
NSLayoutConstraint.activate([
characterCountLabel.topAnchor.constraint(equalTo: topAnchor),
characterCountLabel.leadingAnchor.constraint(greaterThanOrEqualTo: stackView.trailingAnchor, constant: 8),
characterCountLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
characterCountLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
])
characterCountLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
mediaButton.menu = createMediaContextMenu()
mediaButton.showsMenuAsPrimaryAction = true
pollButton.addTarget(self, action: #selector(ComposeToolbarView.pollButtonDidPressed(_:)), for: .touchUpInside)
emojiButton.addTarget(self, action: #selector(ComposeToolbarView.emojiButtonDidPressed(_:)), for: .touchUpInside)
contentWarningButton.addTarget(self, action: #selector(ComposeToolbarView.contentWarningButtonDidPressed(_:)), for: .touchUpInside)
visibilityButton.addTarget(self, action: #selector(ComposeToolbarView.visibilityButtonDidPressed(_:)), for: .touchUpInside)
visibilityButton.menu = createVisibilityContextMenu()
visibilityButton.showsMenuAsPrimaryAction = true
}
}
@ -116,6 +136,40 @@ extension ComposeToolbarView {
case photoLibrary
case browse
}
enum VisibilitySelectionType: String, CaseIterable {
case `public`
case unlisted
case `private`
case direct
var title: String {
switch self {
case .public: return L10n.Scene.Compose.Visibility.public
case .unlisted: return L10n.Scene.Compose.Visibility.unlisted
case .private: return L10n.Scene.Compose.Visibility.private
case .direct: return L10n.Scene.Compose.Visibility.direct
}
}
var image: UIImage {
switch self {
case .public: return UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))!
case .unlisted: return UIImage(systemName: "eye.slash", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))!
case .private: return UIImage(systemName: "person.crop.circle.badge.plus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))!
case .direct: return UIImage(systemName: "at", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))!
}
}
var visibility: Mastodon.Entity.Status.Visibility {
switch self {
case .public: return .public
case .unlisted: return .unlisted
case .private: return .private
case .direct: return .direct
}
}
}
}
extension ComposeToolbarView {
@ -154,9 +208,19 @@ extension ComposeToolbarView {
return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
}
private func createVisibilityContextMenu() -> UIMenu {
let children: [UIMenuElement] = VisibilitySelectionType.allCases.map { type in
UIAction(title: type.title, image: type.image, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] action in
guard let self = self else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: visibilitySelectionType: %s", ((#file as NSString).lastPathComponent), #line, #function, type.rawValue)
self.delegate?.composeToolbarView(self, visibilityButtonDidPressed: self.visibilityButton, visibilitySelectionType: type)
}
}
return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
}
}
extension ComposeToolbarView {
@objc private func pollButtonDidPressed(_ sender: UIButton) {
@ -174,11 +238,6 @@ extension ComposeToolbarView {
delegate?.composeToolbarView(self, contentWarningButtonDidPressed: sender)
}
@objc private func visibilityButtonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.composeToolbarView(self, visibilityButtonDidPressed: sender)
}
}
#if canImport(SwiftUI) && DEBUG

View File

@ -0,0 +1,86 @@
//
// CustomEmojiPickerInputView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-24.
//
import UIKit
final class CustomEmojiPickerInputView: UIInputView {
private(set) lazy var collectionView: UICollectionView = {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout())
collectionView.register(CustomEmojiPickerItemCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self))
collectionView.register(CustomEmojiPickerHeaderCollectionReusableView.self, forSupplementaryViewOfKind: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), withReuseIdentifier: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self))
collectionView.backgroundColor = .clear
return collectionView
}()
let activityIndicatorView = UIActivityIndicatorView(style: .large)
override init(frame: CGRect, inputViewStyle: UIInputView.Style) {
super.init(frame: frame, inputViewStyle: inputViewStyle)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension CustomEmojiPickerInputView {
private func _init() {
allowsSelfSizing = true
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
addSubview(activityIndicatorView)
NSLayoutConstraint.activate([
activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor),
activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor),
])
collectionView.translatesAutoresizingMaskIntoConstraints = false
addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: topAnchor),
collectionView.leadingAnchor.constraint(equalTo: leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
activityIndicatorView.hidesWhenStopped = true
activityIndicatorView.startAnimating()
}
}
extension CustomEmojiPickerInputView {
func createLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(CustomEmojiPickerItemCollectionViewCell.itemSize.width),
heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(4), top: .flexible(4), trailing: .flexible(0), bottom: .flexible(0))
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(CustomEmojiPickerItemCollectionViewCell.itemSize.height))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 5
section.contentInsetsReference = .readableContent
section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 0, bottom: 10, trailing: 0)
let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(44)),
elementKind: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self),
alignment: .top)
// sectionHeader.pinToVisibleBounds = true
sectionHeader.zIndex = 2
section.boundarySupplementaryItems = [sectionHeader]
let layout = UICollectionViewCompositionalLayout(section: section)
return layout
}
}

View File

@ -0,0 +1,58 @@
//
// CustomEmojiPickerInputViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-25.
//
import UIKit
import Combine
final class CustomEmojiPickerInputViewModel {
var disposeBag = Set<AnyCancellable>()
private var customEmojiReplacableTextInputReferences: [CustomEmojiReplacableTextInputReference] = []
// input
weak var customEmojiPickerInputView: CustomEmojiPickerInputView?
// output
let isCustomEmojiComposing = CurrentValueSubject<Bool, Never>(false)
}
extension CustomEmojiPickerInputViewModel {
private func removeEmptyReferences() {
customEmojiReplacableTextInputReferences.removeAll(where: { element in
element.value == nil
})
}
func append(customEmojiReplacableTextInput textInput: CustomEmojiReplacableTextInput) {
removeEmptyReferences()
let isContains = customEmojiReplacableTextInputReferences.contains(where: { element in
element.value === textInput
})
guard !isContains else {
return
}
customEmojiReplacableTextInputReferences.append(CustomEmojiReplacableTextInputReference(value: textInput))
}
func insertText(_ text: String) -> CustomEmojiReplacableTextInputReference? {
removeEmptyReferences()
for reference in customEmojiReplacableTextInputReferences {
guard reference.value?.isFirstResponder == true else { continue }
reference.value?.insertText(text)
return reference
}
return nil
}
}

View File

@ -0,0 +1,114 @@
//
// StatusContentWarningEditorView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-25.
//
import UIKit
final class StatusContentWarningEditorView: UIView {
let containerView: UIView = {
let view = UIView()
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
return view
}()
// due to section following readable inset. We overlap the bleeding to make backgorund fill
// default hidden
let containerBackgroundView: UIView = {
let view = UIView()
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
view.isHidden = true
return view
}()
let iconImageView: UIImageView = {
let imageView = UIImageView()
imageView.image = UIImage(systemName: "exclamationmark.shield")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 30, weight: .regular)).withRenderingMode(.alwaysTemplate)
imageView.tintColor = Asset.Colors.Label.primary.color
imageView.contentMode = .center
return imageView
}()
let textView: UITextView = {
let textView = UITextView()
textView.font = .preferredFont(forTextStyle: .body)
textView.isScrollEnabled = false
textView.placeholder = L10n.Scene.Compose.ContentWarning.placeholder
textView.backgroundColor = .clear
return textView
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension StatusContentWarningEditorView {
private func _init() {
let contentWarningStackView = UIStackView()
contentWarningStackView.axis = .horizontal
contentWarningStackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(contentWarningStackView)
NSLayoutConstraint.activate([
contentWarningStackView.topAnchor.constraint(equalTo: topAnchor),
contentWarningStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
contentWarningStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
contentWarningStackView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
contentWarningStackView.addArrangedSubview(containerView)
containerBackgroundView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(containerBackgroundView)
NSLayoutConstraint.activate([
containerBackgroundView.topAnchor.constraint(equalTo: containerView.topAnchor),
containerBackgroundView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: -1024),
containerBackgroundView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 1024),
containerBackgroundView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])
iconImageView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(iconImageView)
NSLayoutConstraint.activate([
iconImageView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
iconImageView.leadingAnchor.constraint(equalTo: containerView.readableContentGuide.leadingAnchor),
iconImageView.widthAnchor.constraint(equalToConstant: StatusView.avatarImageSize.width).priority(.defaultHigh), // center alignment to avatar
])
iconImageView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
textView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(textView)
NSLayoutConstraint.activate([
textView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 6),
textView.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: StatusView.avatarToLabelSpacing - 4), // align to name label. minus magic 4pt to remove addtion inset
textView.trailingAnchor.constraint(equalTo: containerView.readableContentGuide.trailingAnchor),
containerView.bottomAnchor.constraint(equalTo: textView.bottomAnchor, constant: 6),
])
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct StatusContentWarningEditorView_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview(width: 375) {
StatusContentWarningEditorView()
}
.previewLayout(.fixed(width: 375, height: 100))
}
}
#endif

View File

@ -15,7 +15,7 @@ final class HomeTimelineNavigationBarView {
}()
static let offlineView: UIView = {
let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.lightDangerRed.color)
let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.danger.color)
let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.offline)
HomeTimelineNavigationBarView.addLabelToView(label: label, view: view)
return view

View File

@ -106,7 +106,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
let usernameErrorPromptLabel: UILabel = {
let label = UILabel()
let color = Asset.Colors.lightDangerRed.color
let color = Asset.Colors.danger.color
let font = UIFont.preferredFont(forTextStyle: .caption1)
return label
}()
@ -146,7 +146,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
let emailErrorPromptLabel: UILabel = {
let label = UILabel()
let color = Asset.Colors.lightDangerRed.color
let color = Asset.Colors.danger.color
let font = UIFont.preferredFont(forTextStyle: .caption1)
return label
}()
@ -177,7 +177,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
let passwordErrorPromptLabel: UILabel = {
let label = UILabel()
let color = Asset.Colors.lightDangerRed.color
let color = Asset.Colors.danger.color
let font = UIFont.preferredFont(forTextStyle: .caption1)
return label
}()
@ -201,7 +201,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
let reasonErrorPromptLabel: UILabel = {
let label = UILabel()
let color = Asset.Colors.lightDangerRed.color
let color = Asset.Colors.danger.color
let font = UIFont.preferredFont(forTextStyle: .caption1)
return label
}()

View File

@ -198,10 +198,10 @@ extension MastodonRegisterViewModel {
let attributeString = NSMutableAttributedString()
let image = MastodonRegisterViewModel.xmarkImage(font: font)
attributeString.append(attributedStringImage(with: image, tintColor: Asset.Colors.lightDangerRed.color))
attributeString.append(attributedStringImage(with: image, tintColor: Asset.Colors.danger.color))
attributeString.append(NSAttributedString(string: " "))
let promptAttributedString = NSAttributedString(string: prompt, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.lightDangerRed.color])
let promptAttributedString = NSAttributedString(string: prompt, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.danger.color])
attributeString.append(promptAttributedString)
return attributeString

View File

@ -23,6 +23,7 @@ final class StatusView: 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 boostIconImage: UIImage = {
@ -249,7 +250,7 @@ extension StatusView {
let authorContainerStackView = UIStackView()
containerStackView.addArrangedSubview(authorContainerStackView)
authorContainerStackView.axis = .horizontal
authorContainerStackView.spacing = 5
authorContainerStackView.spacing = StatusView.avatarToLabelSpacing
// avatar
avatarView.translatesAutoresizingMaskIntoConstraints = false

View File

@ -98,12 +98,27 @@ extension Mastodon.API.Statuses {
public let mediaIDs: [String]?
public let pollOptions: [String]?
public let pollExpiresIn: Int?
public let sensitive: Bool?
public let spoilerText: String?
public let visibility: Mastodon.Entity.Status.Visibility?
public init(status: String?, mediaIDs: [String]?, pollOptions: [String]?, pollExpiresIn: Int?) {
public init(
status: String?,
mediaIDs: [String]?,
pollOptions: [String]?,
pollExpiresIn: Int?,
sensitive: Bool?,
spoilerText: String?,
visibility: Mastodon.Entity.Status.Visibility?
) {
self.status = status
self.mediaIDs = mediaIDs
self.pollOptions = pollOptions
self.pollExpiresIn = pollExpiresIn
self.sensitive = sensitive
self.spoilerText = spoilerText
self.visibility = visibility
}
var contentType: String? {
@ -121,6 +136,9 @@ extension Mastodon.API.Statuses {
data.append(Data.multipart(key: "poll[options][]", value: pollOption))
}
pollExpiresIn.flatMap { data.append(Data.multipart(key: "poll[expires_in]", value: $0)) }
sensitive.flatMap { data.append(Data.multipart(key: "sensitive", value: $0)) }
spoilerText.flatMap { data.append(Data.multipart(key: "spoiler_text", value: $0)) }
visibility.flatMap { data.append(Data.multipart(key: "visibility", value: $0.rawValue)) }
data.append(Data.multipartEnd())
return data