Merge branch 'develop' into feature/profile

# Conflicts:
#	CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
#	Localization/app.json
#	Mastodon.xcodeproj/project.pbxproj
This commit is contained in:
CMK 2021-04-01 14:53:24 +08:00
commit 499ddeb169
24 changed files with 1006 additions and 43 deletions

View File

@ -35,11 +35,11 @@
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="emojis" inverseEntity="Status"/>
</entity>
<entity name="History" representedClassName=".History" syncable="YES">
<attribute name="accounts" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="accounts" optional="YES" attributeType="String"/>
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
<attribute name="day" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="uses" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="uses" optional="YES" attributeType="String"/>
<relationship name="tag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag" inverseName="histories" inverseEntity="Tag"/>
</entity>
<entity name="HomeTimelineIndex" representedClassName=".HomeTimelineIndex" syncable="YES">
@ -205,4 +205,4 @@
<element name="Status" positionX="0" positionY="0" width="128" height="569"/>
<element name="PrivateNote" positionX="72" positionY="153" width="128" height="89"/>
</elements>
</model>
</model>

View File

@ -14,8 +14,8 @@ public final class History: NSManagedObject {
@NSManaged public private(set) var createAt: Date
@NSManaged public private(set) var day: Date
@NSManaged public private(set) var uses: Int
@NSManaged public private(set) var accounts: Int
@NSManaged public private(set) var uses: String
@NSManaged public private(set) var accounts: String
// many-to-one relationship
@NSManaged public private(set) var tag: Tag
@ -43,10 +43,10 @@ public extension History {
public extension History {
struct Property {
public let day: Date
public let uses: Int
public let accounts: Int
public let uses: String
public let accounts: String
public init(day: Date, uses: Int, accounts: Int) {
public init(day: Date, uses: String, accounts: String) {
self.day = day
self.uses = uses
self.accounts = accounts

View File

@ -118,6 +118,9 @@
"register": {
"title": "Tell us about you.",
"input": {
"avatar": {
"delete": "delete"
},
"username": {
"placeholder": "username",
"duplicate_prompt": "This username is taken."
@ -253,6 +256,12 @@
"replies": "Replies",
"media": "Media"
}
},
"search": {
"searchBar": {
"placeholder": "Search hashtags and users",
"cancel": "Cancel"
}
}
}
}
}

View File

@ -30,6 +30,10 @@
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; };
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; };
2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */; };
2D34D9CB261489930081BFC0 /* SearchViewController+recomendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9CA261489930081BFC0 /* SearchViewController+recomendView.swift */; };
2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */; };
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9DA261494120081BFC0 /* APIService+Search.swift */; };
2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */; };
2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D364F7125E66D7500204FDC /* MastodonResendEmailViewController.swift */; };
2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D364F7725E66D8300204FDC /* MastodonResendEmailViewModel.swift */; };
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */; };
@ -64,6 +68,7 @@
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */; };
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; };
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */; };
2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */; };
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; };
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */; };
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */; };
@ -95,6 +100,7 @@
2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */; };
2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */; };
2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; };
2DFF41892614A4DC00F776A4 /* UIView+Constraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */; };
5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; };
5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */; };
5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */; };
@ -104,6 +110,7 @@
5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */; };
5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; };
5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; };
87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; };
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; };
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */; };
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; };
@ -358,6 +365,10 @@
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = "<group>"; };
2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = "<group>"; };
2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = "<group>"; };
2D34D9CA261489930081BFC0 /* SearchViewController+recomendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+recomendView.swift"; sourceTree = "<group>"; };
2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Recommend.swift"; sourceTree = "<group>"; };
2D34D9DA261494120081BFC0 /* APIService+Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Search.swift"; sourceTree = "<group>"; };
2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendTagsCollectionViewCell.swift; sourceTree = "<group>"; };
2D364F7125E66D7500204FDC /* MastodonResendEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewController.swift; sourceTree = "<group>"; };
2D364F7725E66D8300204FDC /* MastodonResendEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModel.swift; sourceTree = "<group>"; };
2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentOffsetAdjustableTimelineViewControllerDelegate.swift; sourceTree = "<group>"; };
@ -389,6 +400,7 @@
2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningOverlayView.swift; sourceTree = "<group>"; };
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Status.swift"; sourceTree = "<group>"; };
2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = "<group>"; };
2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = "<group>"; };
2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewModel.swift; sourceTree = "<group>"; };
2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewController+StatusProvider.swift"; sourceTree = "<group>"; };
@ -420,6 +432,7 @@
2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Favorite.swift"; sourceTree = "<group>"; };
2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectObserver.swift; sourceTree = "<group>"; };
2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectContextObjectsDidChange.swift; sourceTree = "<group>"; };
2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Constraint.swift"; sourceTree = "<group>"; };
2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = "<group>"; };
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -636,6 +649,7 @@
DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */,
DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */,
2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */,
87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -750,6 +764,14 @@
path = Content;
sourceTree = "<group>";
};
2D34D9E026149C550081BFC0 /* CollectionViewCell */ = {
isa = PBXGroup;
children = (
2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */,
);
path = CollectionViewCell;
sourceTree = "<group>";
};
2D364F7025E66D5B00204FDC /* ResendEmail */ = {
isa = PBXGroup;
children = (
@ -1152,6 +1174,8 @@
DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */,
DB9A488326034BD7008B817C /* APIService+Status.swift */,
DB9A488F26035963008B817C /* APIService+Media.swift */,
2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */,
2D34D9DA261494120081BFC0 /* APIService+Search.swift */,
DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */,
);
path = APIService;
@ -1400,6 +1424,7 @@
5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */,
DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */,
2D32EAB925CB9B0500C9ED86 /* UIView.swift */,
2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */,
DB8AF55C25C138B7002E6C99 /* UIViewController.swift */,
2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */,
2D84350425FF858100EECE90 /* UIScrollView.swift */,
@ -1442,6 +1467,9 @@
isa = PBXGroup;
children = (
DB9D6BE825E4F5340051B173 /* SearchViewController.swift */,
2D34D9CA261489930081BFC0 /* SearchViewController+recomendView.swift */,
2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */,
2D34D9E026149C550081BFC0 /* CollectionViewCell */,
);
path = Search;
sourceTree = "<group>";
@ -1998,6 +2026,7 @@
DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */,
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */,
2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */,
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */,
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */,
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
@ -2065,6 +2094,7 @@
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */,
DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */,
DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */,
2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */,
DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */,
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,
DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */,
@ -2098,6 +2128,7 @@
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */,
2D34D9CB261489930081BFC0 /* SearchViewController+recomendView.swift in Sources */,
DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */,
2D206B8625F5FB0900143C56 /* Double.swift in Sources */,
DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */,
@ -2118,6 +2149,7 @@
DB98338725C945ED00AD9700 /* Strings.swift in Sources */,
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */,
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */,
2DFF41892614A4DC00F776A4 /* UIView+Constraint.swift in Sources */,
2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */,
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */,
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */,
@ -2127,6 +2159,7 @@
DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */,
DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */,
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */,
2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */,
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */,
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */,
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */,
@ -2163,6 +2196,7 @@
0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */,
5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */,
DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */,
2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */,
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */,
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */,
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */,

View File

@ -123,7 +123,7 @@
"repositoryURL": "https://github.com/MainasuK/TwitterTextEditor",
"state": {
"branch": "feature/input-view",
"revision": "03e7b7497d424d96268f5bcca1f8e9955bb80fea",
"revision": "1e565d13e3c26fc2bedeb418890df42f80d6e3d5",
"version": null
}
},

View File

@ -0,0 +1,271 @@
//
// UIView+Constraint.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/31.
//
import UIKit
enum Dimension {
case width
case height
var layoutAttribute: NSLayoutConstraint.Attribute {
switch self {
case .width:
return .width
case .height:
return .height
}
}
}
extension UIView {
func constrain(toSuperviewEdges: UIEdgeInsets?) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return}
translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
NSLayoutConstraint(item: self,
attribute: .leading,
relatedBy: .equal,
toItem: view,
attribute: .leading,
multiplier: 1.0,
constant: toSuperviewEdges?.left ?? 0.0),
NSLayoutConstraint(item: self,
attribute: .top,
relatedBy: .equal,
toItem: view,
attribute: .top,
multiplier: 1.0,
constant: toSuperviewEdges?.top ?? 0.0),
NSLayoutConstraint(item: self,
attribute: .trailing,
relatedBy: .equal,
toItem: view,
attribute: .trailing,
multiplier: 1.0,
constant: toSuperviewEdges?.right ?? 0.0),
NSLayoutConstraint(item: self,
attribute: .bottom,
relatedBy: .equal,
toItem: view,
attribute: .bottom,
multiplier: 1.0,
constant: toSuperviewEdges?.bottom ?? 0.0)
])
}
func constrain(_ constraints: [NSLayoutConstraint?]) {
guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate(constraints.compactMap { $0 })
}
func constraint(_ attribute: NSLayoutConstraint.Attribute, toView: UIView, constant: CGFloat?) -> NSLayoutConstraint? {
guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil}
translatesAutoresizingMaskIntoConstraints = false
return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: .equal, toItem: toView, attribute: attribute, multiplier: 1.0, constant: constant ?? 0.0)
}
func constraint(_ attribute: NSLayoutConstraint.Attribute, toView: UIView) -> NSLayoutConstraint? {
guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil}
translatesAutoresizingMaskIntoConstraints = false
return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: .equal, toItem: toView, attribute: attribute, multiplier: 1.0, constant: 0.0)
}
func constraint(_ dimension: Dimension, constant: CGFloat) -> NSLayoutConstraint? {
guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil }
translatesAutoresizingMaskIntoConstraints = false
return NSLayoutConstraint(item: self,
attribute: dimension.layoutAttribute,
relatedBy: .equal,
toItem: nil,
attribute: .notAnAttribute,
multiplier: 1.0,
constant: constant)
}
func constraint(toBottom: UIView, constant: CGFloat) -> NSLayoutConstraint? {
guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil }
translatesAutoresizingMaskIntoConstraints = false
return NSLayoutConstraint(item: self, attribute: .top, relatedBy: .equal, toItem: toBottom, attribute: .bottom, multiplier: 1.0, constant: constant)
}
func pinToBottom(to: UIView, height: CGFloat) {
guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
constraint(.width, toView: to),
constraint(toBottom: to, constant: 0.0),
constraint(.height, constant: height)
])
}
func constraint(toTop: UIView, constant: CGFloat) -> NSLayoutConstraint? {
guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil }
translatesAutoresizingMaskIntoConstraints = false
return NSLayoutConstraint(item: self, attribute: .bottom, relatedBy: .equal, toItem: toTop, attribute: .top, multiplier: 1.0, constant: constant)
}
func constraint(toTrailing: UIView, constant: CGFloat) -> NSLayoutConstraint? {
guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil }
translatesAutoresizingMaskIntoConstraints = false
return NSLayoutConstraint(item: self, attribute: .leading, relatedBy: .equal, toItem: toTrailing, attribute: .trailing, multiplier: 1.0, constant: constant)
}
func constraint(toLeading: UIView, constant: CGFloat) -> NSLayoutConstraint? {
guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil }
translatesAutoresizingMaskIntoConstraints = false
return NSLayoutConstraint(item: self, attribute: .trailing, relatedBy: .equal, toItem: toLeading, attribute: .leading, multiplier: 1.0, constant: constant)
}
func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat, topLayoutGuide: UILayoutSupport) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
constraint(.leading, toView: view, constant: sidePadding),
NSLayoutConstraint(item: self, attribute: .top, relatedBy: .equal, toItem: topLayoutGuide, attribute: .bottom, multiplier: 1.0, constant: topPadding),
constraint(.trailing, toView: view, constant: -sidePadding)
])
}
func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
constraint(.leading, toView: view, constant: sidePadding),
constraint(.top, toView: view, constant: topPadding),
constraint(.trailing, toView: view, constant: -sidePadding)
])
}
func constrainTopCorners(height: CGFloat) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
constraint(.leading, toView: view),
constraint(.top, toView: view),
constraint(.trailing, toView: view),
constraint(.height, constant: height)
])
}
func constrainBottomCorners(sidePadding: CGFloat, bottomPadding: CGFloat) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
constraint(.leading, toView: view, constant: sidePadding),
constraint(.bottom, toView: view, constant: -bottomPadding),
constraint(.trailing, toView: view, constant: -sidePadding)
])
}
func constrainBottomCorners(height: CGFloat) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
constraint(.leading, toView: view),
constraint(.bottom, toView: view),
constraint(.trailing, toView: view),
constraint(.height, constant: height)
])
}
func constrainLeadingCorners() {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
constraint(.top, toView: view),
constraint(.leading, toView: view),
constraint(.bottom, toView: view)
])
}
func constrainTrailingCorners() {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
constraint(.top, toView: view),
constraint(.trailing, toView: view),
constraint(.bottom, toView: view)
])
}
func constrainToCenter() {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
constraint(.centerX, toView: view),
constraint(.centerY, toView: view)
])
}
func pinTo(viewAbove: UIView, padding: CGFloat = 0.0, height: CGFloat? = nil) {
guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
constraint(.width, toView: viewAbove),
constraint(toBottom: viewAbove, constant: padding),
self.centerXAnchor.constraint(equalTo: viewAbove.centerXAnchor),
height != nil ? constraint(.height, constant: height!) : nil
])
}
func pin(toSize: CGSize) {
guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
widthAnchor.constraint(equalToConstant: toSize.width),
heightAnchor.constraint(equalToConstant: toSize.height)])
}
func pinTopLeft(padding: CGFloat) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: padding),
topAnchor.constraint(equalTo: view.topAnchor, constant: padding)])
}
func pinTopRight(padding: CGFloat) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: padding),
topAnchor.constraint(equalTo: view.topAnchor, constant: padding)])
}
func pinTopLeft(toView: UIView, topPadding: CGFloat) {
guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
leadingAnchor.constraint(equalTo: toView.leadingAnchor),
topAnchor.constraint(equalTo: toView.bottomAnchor, constant: topPadding)])
}
/// Cross-fades between two views by animating their alpha then setting one or the other hidden.
/// - parameters:
/// - lhs: left view
/// - rhs: right view
/// - toRight: fade to the right view if true, fade to the left view if false
/// - duration: animation duration
///
static func crossfade(_ lhs: UIView, _ rhs: UIView, toRight: Bool, duration: TimeInterval) {
lhs.alpha = toRight ? 1.0 : 0.0
rhs.alpha = toRight ? 0.0 : 1.0
lhs.isHidden = false
rhs.isHidden = false
UIView.animate(withDuration: duration, animations: {
lhs.alpha = toRight ? 0.0 : 1.0
rhs.alpha = toRight ? 1.0 : 0.0
}, completion: { _ in
lhs.isHidden = toRight
rhs.isHidden = !toRight
})
}
}

View File

@ -375,6 +375,10 @@ internal enum L10n {
}
}
internal enum Input {
internal enum Avatar {
/// delete
internal static let delete = L10n.tr("Localizable", "Scene.Register.Input.Avatar.Delete")
}
internal enum DisplayName {
/// display name
internal static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.DisplayName.Placeholder")
@ -401,6 +405,14 @@ internal enum L10n {
}
}
}
internal enum Search {
internal enum Searchbar {
/// Cancel
internal static let cancel = L10n.tr("Localizable", "Scene.Search.Searchbar.Cancel")
/// Search hashtags and users
internal static let placeholder = L10n.tr("Localizable", "Scene.Search.Searchbar.Placeholder")
}
}
internal enum ServerPicker {
/// Pick a Server,\nany server.
internal static let title = L10n.tr("Localizable", "Scene.ServerPicker.Title")

View File

@ -120,6 +120,7 @@ tap the link to confirm your account.";
"Scene.Register.Error.Special.PasswordTooShort" = "Password is too short (must be at least 8 characters)";
"Scene.Register.Error.Special.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores";
"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can't be longer than 30 characters)";
"Scene.Register.Input.Avatar.Delete" = "delete";
"Scene.Register.Input.DisplayName.Placeholder" = "display name";
"Scene.Register.Input.Email.Placeholder" = "email";
"Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Why do you want to join?";
@ -128,6 +129,8 @@ tap the link to confirm your account.";
"Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken.";
"Scene.Register.Input.Username.Placeholder" = "username";
"Scene.Register.Title" = "Tell us about you.";
"Scene.Search.Searchbar.Cancel" = "Cancel";
"Scene.Search.Searchbar.Placeholder" = "Search hashtags and users";
"Scene.ServerPicker.Button.Category.All" = "All";
"Scene.ServerPicker.Button.SeeLess" = "See Less";
"Scene.ServerPicker.Button.SeeMore" = "See More";

View File

@ -98,7 +98,7 @@ final class ComposeViewController: UIViewController, NeedsDependency {
}()
private(set) lazy var documentPickerController: UIDocumentPickerViewController = {
let documentPickerController = UIDocumentPickerViewController(documentTypes: ["public.image"], in: .open)
let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image])
documentPickerController.delegate = self
return documentPickerController
}()

View File

@ -21,7 +21,8 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
private var expandServerDomainSet = Set<String>()
let emptyStateView = PickServerEmptyStateView()
private let emptyStateView = PickServerEmptyStateView()
private let emptyStateViewHPadding: CGFloat = 4 // UIView's readableContentGuide is 4pt smaller then UITableViewCell's
let tableViewTopPaddingView = UIView() // fix empty state view background display when tableView bounce scrolling
var tableViewTopPaddingViewHeightLayoutConstraint: NSLayoutConstraint!
@ -78,8 +79,8 @@ extension MastodonPickServerViewController {
view.addSubview(emptyStateView)
NSLayoutConstraint.activate([
emptyStateView.topAnchor.constraint(equalTo: view.topAnchor),
emptyStateView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
emptyStateView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
emptyStateView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: emptyStateViewHPadding),
emptyStateView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor, constant: -emptyStateViewHPadding),
nextStepButton.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 21),
])

View File

@ -7,10 +7,57 @@
import CropViewController
import Foundation
import OSLog
import PhotosUI
import UIKit
extension MastodonRegisterViewController {
func createMediaContextMenu() -> UIMenu {
var children: [UIMenuElement] = []
let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
guard let self = self else { return }
self.present(self.imagePicker, animated: true, completion: nil)
}
children.append(photoLibraryAction)
if UIImagePickerController.isSourceTypeAvailable(.camera) {
let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in
guard let self = self else { return }
self.present(self.imagePickerController, animated: true, completion: nil)
})
children.append(cameraAction)
}
let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
guard let self = self else { return }
self.present(self.documentPickerController, animated: true, completion: nil)
}
children.append(browseAction)
if self.viewModel.avatarImage.value != nil {
let deleteAction = UIAction(title: L10n.Scene.Register.Input.Avatar.delete, image: UIImage(systemName: "delete.left"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
guard let self = self else { return }
self.viewModel.avatarImage.value = nil
}
children.append(deleteAction)
}
return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
}
private func cropImage(image:UIImage,pickerViewController:UIViewController) {
DispatchQueue.main.async {
let cropController = CropViewController(croppingStyle: .default, image: image)
cropController.delegate = self
cropController.setAspectRatioPreset(.presetSquare, animated: true)
cropController.aspectRatioPickerButtonHidden = true
cropController.aspectRatioLockEnabled = true
pickerViewController.dismiss(animated: true, completion: {
self.present(cropController, animated: true, completion: nil)
})
}
}
}
// MARK: - PHPickerViewControllerDelegate
extension MastodonRegisterViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard let itemProvider = results.first?.itemProvider, itemProvider.canLoadObject(ofClass: UIImage.self) else {
@ -20,11 +67,11 @@ extension MastodonRegisterViewController: PHPickerViewControllerDelegate {
itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in
guard let self = self else { return }
guard let image = image as? UIImage else {
guard let error = error else { return }
let alertController = UIAlertController(for: error, title: "", preferredStyle: .alert)
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
alertController.addAction(okAction)
DispatchQueue.main.async {
guard let error = error else { return }
let alertController = UIAlertController(for: error, title: "", preferredStyle: .alert)
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
alertController.addAction(okAction)
self.coordinator.present(
scene: .alertController(alertController: alertController),
from: nil,
@ -33,31 +80,52 @@ extension MastodonRegisterViewController: PHPickerViewControllerDelegate {
}
return
}
DispatchQueue.main.async {
let cropController = CropViewController(croppingStyle: .default, image: image)
cropController.delegate = self
cropController.setAspectRatioPreset(.presetSquare, animated: true)
cropController.aspectRatioPickerButtonHidden = true
cropController.aspectRatioLockEnabled = true
picker.dismiss(animated: true, completion: {
self.present(cropController, animated: true, completion: nil)
})
}
self.cropImage(image: image, pickerViewController: picker)
}
}
}
// MARK: - UIImagePickerControllerDelegate
extension MastodonRegisterViewController: UIImagePickerControllerDelegate & UINavigationControllerDelegate {
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
picker.dismiss(animated: true, completion: nil)
guard let image = info[.originalImage] as? UIImage else { return }
cropImage(image: image, pickerViewController: picker)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
picker.dismiss(animated: true, completion: nil)
}
}
// MARK: - UIDocumentPickerDelegate
extension MastodonRegisterViewController: UIDocumentPickerDelegate {
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
do {
guard url.startAccessingSecurityScopedResource() else { return }
defer { url.stopAccessingSecurityScopedResource() }
let imageData = try Data(contentsOf: url)
guard let image = UIImage(data: imageData) else { return }
cropImage(image: image, pickerViewController: controller)
} catch {
os_log("%{public}s[%{public}ld], %{public}s: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
}
}
}
// MARK: - CropViewControllerDelegate
extension MastodonRegisterViewController: CropViewControllerDelegate {
public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) {
self.viewModel.avatarImage.value = image
self.avatarButton.setImage(image, for: .normal)
cropViewController.dismiss(animated: true, completion: nil)
}
}
extension MastodonRegisterViewController {
@objc func avatarButtonPressed(_ sender: UIButton) {
self.present(imagePicker, animated: true, completion: nil)
}
}

View File

@ -20,14 +20,28 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
var viewModel: MastodonRegisterViewModel!
lazy var imagePicker: PHPickerViewController = {
// picker
private(set) lazy var imagePicker: PHPickerViewController = {
var configuration = PHPickerConfiguration()
configuration.filter = .images
configuration.selectionLimit = 1
let imagePicker = PHPickerViewController(configuration: configuration)
imagePicker.delegate = self
return imagePicker
}()
private(set) lazy var imagePickerController: UIImagePickerController = {
let imagePickerController = UIImagePickerController()
imagePickerController.sourceType = .camera
imagePickerController.delegate = self
return imagePickerController
}()
private(set) lazy var documentPickerController: UIDocumentPickerViewController = {
let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image])
documentPickerController.delegate = self
return documentPickerController
}()
let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
@ -56,7 +70,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
}()
let avatarButton: UIButton = {
let button = UIButton(type: .custom)
let button = HighlightDimmableButton()
let boldFont = UIFont.systemFont(ofSize: 42)
let configuration = UIImage.SymbolConfiguration(font: boldFont)
let image = UIImage(systemName: "person.fill.viewfinder", withConfiguration: configuration)
@ -231,6 +245,9 @@ extension MastodonRegisterViewController {
setupOnboardingAppearance()
defer { setupNavigationBarBackgroundView() }
avatarButton.menu = createMediaContextMenu()
avatarButton.showsMenuAsPrimaryAction = true
domainLabel.text = "@" + viewModel.domain + " "
domainLabel.sizeToFit()
passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(validateState: .empty)
@ -392,9 +409,8 @@ extension MastodonRegisterViewController {
.receive(on: DispatchQueue.main)
.sink { [weak self] isHighlighted in
guard let self = self else { return }
let alpha: CGFloat = isHighlighted ? 0.8 : 1
let alpha: CGFloat = isHighlighted ? 0.6 : 1
self.plusIconImageView.alpha = alpha
self.avatarButton.alpha = alpha
}
.store(in: &disposeBag)
@ -488,6 +504,21 @@ extension MastodonRegisterViewController {
}
.store(in: &disposeBag)
viewModel.avatarImage
.receive(on: DispatchQueue.main)
.sink{ [weak self] image in
guard let self = self else { return }
self.avatarButton.menu = self.createMediaContextMenu()
if let avatar = image {
self.avatarButton.setImage(avatar, for: .normal)
} else {
let boldFont = UIFont.systemFont(ofSize: 42)
let configuration = UIImage.SymbolConfiguration(font: boldFont)
let image = UIImage(systemName: "person.fill.viewfinder", withConfiguration: configuration)
self.avatarButton.setImage(image?.withRenderingMode(UIImage.RenderingMode.alwaysTemplate), for: UIControl.State.normal)
}
}
.store(in: &disposeBag)
NotificationCenter.default
.publisher(for: UITextField.textDidChangeNotification, object: usernameTextField)
.receive(on: DispatchQueue.main)
@ -554,7 +585,6 @@ extension MastodonRegisterViewController {
.store(in: &disposeBag)
}
avatarButton.addTarget(self, action: #selector(MastodonRegisterViewController.avatarButtonPressed(_:)), for: .touchUpInside)
signUpButton.addTarget(self, action: #selector(MastodonRegisterViewController.signUpButtonPressed(_:)), for: .touchUpInside)
}

View File

@ -0,0 +1,76 @@
//
// SearchRecommendTagsCollectionViewCell.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/31.
//
import Foundation
import UIKit
class SearchRecommendTagsCollectionViewCell: UICollectionViewCell {
let backgroundImageView: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
let hashTagTitleLabel: UILabel = {
let label = UILabel()
label.textColor = .white
label.font = .preferredFont(forTextStyle: .caption1)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let peopleLabel: UILabel = {
let label = UILabel()
label.textColor = .white
label.font = .preferredFont(forTextStyle: .body)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let flameIconView: UIImageView = {
let imageView = UIImageView()
let image = UIImage(systemName: "flame.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold))!.withRenderingMode(.alwaysTemplate)
imageView.image = image
imageView.tintColor = .white
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
override func prepareForReuse() {
super.prepareForReuse()
}
override init(frame: CGRect) {
super.init(frame: .zero)
configure()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configure()
}
}
extension SearchRecommendTagsCollectionViewCell {
private func configure() {
contentView.addSubview(backgroundImageView)
backgroundImageView.constrain(toSuperviewEdges: nil)
contentView.addSubview(hashTagTitleLabel)
hashTagTitleLabel.pinTopLeft(padding: 16)
contentView.addSubview(peopleLabel)
peopleLabel.constrain([
peopleLabel.constraint(toTop: contentView, constant: 46),
peopleLabel.constraint(toLeading: contentView, constant: 16)
])
contentView.addSubview(flameIconView)
flameIconView.pinTopRight(padding: 16)
}
}

View File

@ -0,0 +1,49 @@
//
// SearchViewController+recommendView.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/31.
//
import Foundation
import UIKit
extension SearchViewController {
func setuprecommendView() {
recommendView.register(SearchRecommendTagsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self))
recommendView.dataSource = self
recommendView.delegate = self
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
recommendView.collectionViewLayout.invalidateLayout()
}
}
extension SearchViewController: UICollectionViewDelegate {
}
extension SearchViewController: UICollectionViewDataSource {
func numberOfSections(in collectionView: UICollectionView) -> Int {
return (self.viewModel.recommendAccounts.isEmpty ? 0 : 1) + (self.viewModel.recommendHashTags.isEmpty ? 0 : 1)
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
switch section {
case 0:
return viewModel.recommendHashTags.count
case 1:
return viewModel.recommendAccounts.count
default:
return 0
}
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
return UICollectionViewCell()
}
}

View File

@ -2,23 +2,80 @@
// SearchViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-2-23.
// Created by sxiaojian on 2021/3/31.
//
import UIKit
import Combine
final class SearchViewController: UIViewController, NeedsDependency {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
private(set) lazy var viewModel = SearchViewModel(context: context)
let searchBar: UISearchBar = {
let searchBar = UISearchBar()
searchBar.placeholder = L10n.Scene.Search.Searchbar.placeholder
searchBar.tintColor = Asset.Colors.buttonDefault.color
searchBar.translatesAutoresizingMaskIntoConstraints = false
let micImage = UIImage(systemName: "mic.fill")
searchBar.setImage(micImage, for: .bookmark, state: .normal)
searchBar.showsBookmarkButton = true
return searchBar
}()
let recommendView: UICollectionView = {
let flowLayout = UICollectionViewFlowLayout()
flowLayout.scrollDirection = .horizontal
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
view.backgroundColor = .clear
view.showsHorizontalScrollIndicator = false
view.showsVerticalScrollIndicator = false
view.layer.masksToBounds = false
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
}
extension SearchViewController {
override func viewDidLoad() {
super.viewDidLoad()
searchBar.delegate = self
navigationItem.titleView = searchBar
navigationItem.hidesBackButton = true
viewModel.requestRecommendData()
}
}
extension SearchViewController: UISearchBarDelegate {
func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
searchBar.setShowsCancelButton(true, animated: true)
}
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
searchBar.setShowsCancelButton(false, animated: true)
}
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
searchBar.setShowsCancelButton(false, animated: true)
searchBar.text = ""
searchBar.resignFirstResponder()
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
viewModel.searchText.send(searchText)
}
func searchBarBookmarkButtonClicked(_ searchBar: UISearchBar) {
}
}
extension SearchViewController {
}

View File

@ -0,0 +1,54 @@
//
// SearchViewModel.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/31.
//
import Foundation
import Combine
import MastodonSDK
import UIKit
import OSLog
final class SearchViewModel {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
// output
let searchText = CurrentValueSubject<String, Never>("")
var recommendHashTags = [Mastodon.Entity.Tag]()
var recommendAccounts = [Mastodon.Entity.Account]()
init(context: AppContext) {
self.context = context
}
func requestRecommendData() {
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
let trendsAPI = context.apiService.recommendTrends(domain: activeMastodonAuthenticationBox.domain, query: Mastodon.API.Trends.Query(limit: 5))
let accountsAPI = context.apiService.recommendAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
Publishers.Zip(trendsAPI,accountsAPI)
.sink { completion in
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: zip request fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: zip request success", ((#file as NSString).lastPathComponent), #line, #function)
break
}
} receiveValue: { [weak self] (tags, accounts) in
guard let self = self else { return }
self.recommendAccounts = accounts.value
self.recommendHashTags = tags.value
}
.store(in: &disposeBag)
}
}

View File

@ -0,0 +1,30 @@
//
// APIService+Recommend.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/31.
//
import Foundation
import MastodonSDK
import Combine
extension APIService {
func recommendAccount(
domain: String,
query: Mastodon.API.Suggestions.Query?,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
return Mastodon.API.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization)
}
func recommendTrends(
domain: String,
query: Mastodon.API.Trends.Query?
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> {
return Mastodon.API.Trends.get(session: session, domain: domain, query: query)
}
}

View File

@ -0,0 +1,23 @@
//
// APIService+Search.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/31.
//
import Foundation
import MastodonSDK
import Combine
extension APIService {
func search(
domain: String,
query: Mastodon.API.Search.Query,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
return Mastodon.API.Search.search(session: session, domain: domain, query: query, authorization: authorization)
}
}

View File

@ -0,0 +1,95 @@
//
// Mastodon+API+Search.swift
//
//
// Created by sxiaojian on 2021/3/31.
//
import Combine
import Foundation
extension Mastodon.API.Search {
static func searchURL(domain: String) -> URL {
Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("search")
}
/// Search results
///
/// Search for content in accounts, statuses and hashtags.
///
/// Version history:
/// 2.4.1 - added, limit hardcoded to 5
/// 2.8.0 - add type, limit, offset, min_id, max_id, account_id
/// 3.0.0 - add exclude_unreviewed param
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/search/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - query: search query
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `Accounts,Hashtags,Status` nested in the response
public static func search(
session: URLSession,
domain: String,
query: Mastodon.API.Search.Query,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> {
let request = Mastodon.API.get(
url: searchURL(domain: domain),
query: query,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.SearchResult.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}
extension Mastodon.API.Search {
public struct Query: Codable, GetQuery {
public init(accountID: Mastodon.Entity.Account.ID?, maxID: Mastodon.Entity.Status.ID?, minID: Mastodon.Entity.Status.ID?, type: String?, excludeUnreviewed: Bool?, q: String, resolve: Bool?, limit: Int?, offset: Int?, following: Bool?) {
self.accountID = accountID
self.maxID = maxID
self.minID = minID
self.type = type
self.excludeUnreviewed = excludeUnreviewed
self.q = q
self.resolve = resolve
self.limit = limit
self.offset = offset
self.following = following
}
public let accountID: Mastodon.Entity.Account.ID?
public let maxID: Mastodon.Entity.Status.ID?
public let minID: Mastodon.Entity.Status.ID?
public let type: String?
public let excludeUnreviewed: Bool? // Filter out unreviewed tags? Defaults to false. Use true when trying to find trending tags.
public let q: String
public let resolve: Bool? // Attempt WebFinger lookup. Defaults to false.
public let limit: Int? // Maximum number of results to load, per type. Defaults to 20. Max 40.
public let offset: Int? // Offset in search results. Used for pagination. Defaults to 0.
public let following: Bool? // Only include accounts that the user is following. Defaults to false.
var queryItems: [URLQueryItem]? {
var items: [URLQueryItem] = []
accountID.flatMap { items.append(URLQueryItem(name: "account_id", value: $0)) }
maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) }
minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) }
type.flatMap { items.append(URLQueryItem(name: "type", value: $0)) }
excludeUnreviewed.flatMap { items.append(URLQueryItem(name: "exclude_unreviewed", value: $0.queryItemValue)) }
items.append(URLQueryItem(name: "q", value: q))
resolve.flatMap { items.append(URLQueryItem(name: "resolve", value: $0.queryItemValue)) }
limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) }
offset.flatMap { items.append(URLQueryItem(name: "offset", value: String($0))) }
following.flatMap { items.append(URLQueryItem(name: "following", value: $0.queryItemValue)) }
guard !items.isEmpty else { return nil }
return items
}
}
}

View File

@ -0,0 +1,65 @@
//
// Mastodon+API+Suggestions.swift
//
//
// Created by sxiaojian on 2021/3/31.
//
import Combine
import Foundation
extension Mastodon.API.Suggestions {
static func suggestionsURL(domain: String) -> URL {
Mastodon.API.endpointURL(domain: domain).appendingPathComponent("suggestions")
}
/// Follow suggestions
///
/// Server-generated suggestions on who to follow, based on previous positive interactions.
///
/// Version history:
/// 2.4.3 - added
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/accounts/suggestions/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - query: query
/// - authorization: User token.
/// - Returns: `AnyPublisher` contains `Accounts` nested in the response
public static func get(
session: URLSession,
domain: String,
query: Mastodon.API.Suggestions.Query?,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Account]>, Error> {
let request = Mastodon.API.get(
url: suggestionsURL(domain: domain),
query: query,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}
extension Mastodon.API.Suggestions {
public struct Query: Codable, GetQuery {
public init(limit: Int?) {
self.limit = limit
}
public let limit: Int? // Maximum number of results to return. Defaults to 40.
var queryItems: [URLQueryItem]? {
var items: [URLQueryItem] = []
limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) }
guard !items.isEmpty else { return nil }
return items
}
}
}

View File

@ -0,0 +1,64 @@
//
// Mastodon+API+Trends.swift
//
//
// Created by sxiaojian on 2021/3/31.
//
import Combine
import Foundation
extension Mastodon.API.Trends {
static func trendsURL(domain: String) -> URL {
Mastodon.API.endpointURL(domain: domain).appendingPathComponent("trends")
}
/// Trending tags
///
/// Tags that are being used more frequently within the past week.
///
/// Version history:
/// 3.0.0 - added
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/instance/trends/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - query: query
/// - Returns: `AnyPublisher` contains `Hashtags` nested in the response
public static func get(
session: URLSession,
domain: String,
query: Mastodon.API.Trends.Query?
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> {
let request = Mastodon.API.get(
url: trendsURL(domain: domain),
query: query,
authorization: nil
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: [Mastodon.Entity.Tag].self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
}
extension Mastodon.API.Trends {
public struct Query: Codable, GetQuery {
public init(limit: Int?) {
self.limit = limit
}
public let limit: Int? // Maximum number of results to return. Defaults to 10.
var queryItems: [URLQueryItem]? {
var items: [URLQueryItem] = []
limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) }
guard !items.isEmpty else { return nil }
return items
}
}
}

View File

@ -50,6 +50,9 @@ extension Mastodon.API {
if let date = fullDatePreciseISO8601Formatter.date(from: string) {
return date
}
if let timestamp = TimeInterval(string) {
return Date(timeIntervalSince1970: timestamp)
}
} catch {
// do nothing
}
@ -101,6 +104,9 @@ extension Mastodon.API {
public enum Reblog { }
public enum Statuses { }
public enum Timeline { }
public enum Search { }
public enum Trends { }
public enum Suggestions { }
}
extension Mastodon.API {

View File

@ -19,7 +19,7 @@ extension Mastodon.Entity {
public struct History: Codable {
/// UNIX timestamp on midnight of the given day
public let day: Date
public let uses: Int
public let accounts: Int
public let uses: String
public let accounts: String
}
}

View File

@ -0,0 +1,16 @@
//
// File.swift
//
//
// Created by sxiaojian on 2021/3/31.
//
import Foundation
extension Mastodon.Entity {
public struct SearchResult: Codable {
let accounts: [Mastodon.Entity.Account]
let statuses: [Mastodon.Entity.Status]
let hashtags: [Mastodon.Entity.Tag]
}
}