Merge commit /develop into feature/reblog
This commit is contained in:
commit
2ac2eb7c77
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D74" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D5029f" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Application" representedClassName=".Application" syncable="YES">
|
||||
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="name" attributeType="String"/>
|
||||
|
@ -16,7 +16,7 @@
|
|||
<attribute name="index" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="metaData" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="previewRemoteURL" optional="YES" attributeType="String"/>
|
||||
<attribute name="previewURL" attributeType="String"/>
|
||||
<attribute name="previewURL" optional="YES" attributeType="String"/>
|
||||
<attribute name="remoteURL" optional="YES" attributeType="String"/>
|
||||
<attribute name="textURL" optional="YES" attributeType="String"/>
|
||||
<attribute name="typeRaw" attributeType="String"/>
|
||||
|
@ -163,7 +163,7 @@
|
|||
</entity>
|
||||
<elements>
|
||||
<element name="Application" positionX="160" positionY="192" width="128" height="104"/>
|
||||
<element name="Attachment" positionX="72" positionY="162" width="128" height="14"/>
|
||||
<element name="Attachment" positionX="72" positionY="162" width="128" height="254"/>
|
||||
<element name="Emoji" positionX="45" positionY="135" width="128" height="149"/>
|
||||
<element name="History" positionX="27" positionY="126" width="128" height="119"/>
|
||||
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
|
||||
|
|
|
@ -15,7 +15,7 @@ public final class Attachment: NSManagedObject {
|
|||
@NSManaged public private(set) var domain: String
|
||||
@NSManaged public private(set) var typeRaw: String
|
||||
@NSManaged public private(set) var url: String
|
||||
@NSManaged public private(set) var previewURL: String
|
||||
@NSManaged public private(set) var previewURL: String?
|
||||
|
||||
@NSManaged public private(set) var remoteURL: String?
|
||||
@NSManaged public private(set) var metaData: Data?
|
||||
|
@ -80,7 +80,7 @@ public extension Attachment {
|
|||
public let typeRaw: String
|
||||
public let url: String
|
||||
|
||||
public let previewURL: String
|
||||
public let previewURL: String?
|
||||
public let remoteURL: String?
|
||||
public let metaData: Data?
|
||||
public let textURL: String?
|
||||
|
@ -95,7 +95,7 @@ public extension Attachment {
|
|||
id: Attachment.ID,
|
||||
typeRaw: String,
|
||||
url: String,
|
||||
previewURL: String,
|
||||
previewURL: String?,
|
||||
remoteURL: String?,
|
||||
metaData: Data?,
|
||||
textURL: String?,
|
||||
|
|
|
@ -22,6 +22,11 @@
|
|||
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; };
|
||||
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; };
|
||||
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; };
|
||||
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; };
|
||||
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; };
|
||||
2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; };
|
||||
2D206B8C25F6015000143C56 /* AudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8B25F6015000143C56 /* AudioPlayer.swift */; };
|
||||
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B9125F60EA700143C56 /* UIControl.swift */; };
|
||||
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 */; };
|
||||
|
@ -44,7 +49,6 @@
|
|||
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; };
|
||||
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */; };
|
||||
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; };
|
||||
2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46976325C2A71500CF4AA9 /* UIIamge.swift */; };
|
||||
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */; };
|
||||
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */; };
|
||||
2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */ = {isa = PBXBuildFile; productRef = 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */; };
|
||||
|
@ -74,6 +78,8 @@
|
|||
2D939AB525EDD8A90076FA61 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; };
|
||||
2D939AC825EE14620076FA61 /* CropViewController in Frameworks */ = {isa = PBXBuildFile; productRef = 2D939AC725EE14620076FA61 /* CropViewController */; };
|
||||
2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */; };
|
||||
2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6054625F716A2006356F9 /* PlaybackState.swift */; };
|
||||
2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */; };
|
||||
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; };
|
||||
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; };
|
||||
2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; };
|
||||
|
@ -256,6 +262,11 @@
|
|||
2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = "<group>"; };
|
||||
2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = "<group>"; };
|
||||
2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
|
||||
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = "<group>"; };
|
||||
2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = "<group>"; };
|
||||
2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = "<group>"; };
|
||||
2D206B8B25F6015000143C56 /* AudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = "<group>"; };
|
||||
2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
|
@ -277,7 +288,6 @@
|
|||
2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = "<group>"; };
|
||||
2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = "<group>"; };
|
||||
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = "<group>"; };
|
||||
2D46976325C2A71500CF4AA9 /* UIIamge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIIamge.swift; sourceTree = "<group>"; };
|
||||
2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewController.swift; sourceTree = "<group>"; };
|
||||
2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewModel.swift; sourceTree = "<group>"; };
|
||||
2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlContainableScrollViews.swift; sourceTree = "<group>"; };
|
||||
|
@ -304,6 +314,8 @@
|
|||
2D927F1325C7EDD9004F19B8 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = "<group>"; };
|
||||
2D939AB425EDD8A90076FA61 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = "<group>"; };
|
||||
2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonRegisterViewController+Avatar.swift"; sourceTree = "<group>"; };
|
||||
2DA6054625F716A2006356F9 /* PlaybackState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackState.swift; sourceTree = "<group>"; };
|
||||
2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerViewModel.swift; sourceTree = "<group>"; };
|
||||
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLoaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
|
||||
|
@ -639,6 +651,8 @@
|
|||
DB45FB0425CA87B4005A8AC7 /* APIService */,
|
||||
DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */,
|
||||
DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */,
|
||||
2D206B8B25F6015000143C56 /* AudioPlayer.swift */,
|
||||
2DA6054625F716A2006356F9 /* PlaybackState.swift */,
|
||||
);
|
||||
path = Service;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1088,7 +1102,6 @@
|
|||
2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */,
|
||||
DB8AF55C25C138B7002E6C99 /* UIViewController.swift */,
|
||||
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */,
|
||||
2D46976325C2A71500CF4AA9 /* UIIamge.swift */,
|
||||
2DF123A625C3B0210020F248 /* ActiveLabel.swift */,
|
||||
DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */,
|
||||
2D42FF6A25C817D2004A627A /* MastodonContent.swift */,
|
||||
|
@ -1100,6 +1113,9 @@
|
|||
2D32EAB925CB9B0500C9ED86 /* UIView.swift */,
|
||||
0FAA101B25E10E760017CCDE /* UIFont.swift */,
|
||||
2D939AB425EDD8A90076FA61 /* String.swift */,
|
||||
2D206B7F25F5F45E00143C56 /* UIImage.swift */,
|
||||
2D206B8525F5FB0900143C56 /* Double.swift */,
|
||||
2D206B9125F60EA700143C56 /* UIControl.swift */,
|
||||
);
|
||||
path = Extension;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1151,6 +1167,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */,
|
||||
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */,
|
||||
);
|
||||
path = Container;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1159,6 +1176,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */,
|
||||
2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */,
|
||||
);
|
||||
path = ViewModel;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1537,6 +1555,7 @@
|
|||
files = (
|
||||
DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */,
|
||||
0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */,
|
||||
2D206B8C25F6015000143C56 /* AudioPlayer.swift in Sources */,
|
||||
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */,
|
||||
DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */,
|
||||
2D7631B325C159F700929FB9 /* Item.swift in Sources */,
|
||||
|
@ -1574,6 +1593,7 @@
|
|||
DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
|
||||
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */,
|
||||
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
|
||||
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */,
|
||||
DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */,
|
||||
2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */,
|
||||
2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */,
|
||||
|
@ -1588,17 +1608,19 @@
|
|||
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
|
||||
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
|
||||
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */,
|
||||
2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */,
|
||||
0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */,
|
||||
2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */,
|
||||
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */,
|
||||
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */,
|
||||
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */,
|
||||
2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */,
|
||||
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */,
|
||||
2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */,
|
||||
DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */,
|
||||
DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */,
|
||||
DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */,
|
||||
2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */,
|
||||
2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */,
|
||||
DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */,
|
||||
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,
|
||||
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */,
|
||||
|
@ -1624,6 +1646,7 @@
|
|||
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
|
||||
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
|
||||
DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */,
|
||||
2D206B8625F5FB0900143C56 /* Double.swift in Sources */,
|
||||
2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */,
|
||||
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */,
|
||||
DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */,
|
||||
|
@ -1645,6 +1668,7 @@
|
|||
DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */,
|
||||
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */,
|
||||
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */,
|
||||
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */,
|
||||
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */,
|
||||
DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */,
|
||||
2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */,
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>7</integer>
|
||||
<integer>10</integer>
|
||||
</dict>
|
||||
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -22,7 +22,7 @@
|
|||
<key>Mastodon.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>8</integer>
|
||||
<integer>7</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
|
|
@ -127,8 +127,8 @@ extension StatusSection {
|
|||
}()
|
||||
let scale: CGFloat = {
|
||||
switch mosiacImageViewModel.metas.count {
|
||||
case 1: return 1.3
|
||||
default: return 0.7
|
||||
case 1: return 1.3
|
||||
default: return 0.7
|
||||
}
|
||||
}()
|
||||
return CGSize(width: maxWidth, height: maxWidth * scale)
|
||||
|
@ -157,6 +157,14 @@ extension StatusSection {
|
|||
cell.statusView.statusMosaicImageViewContainer.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil
|
||||
cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0
|
||||
|
||||
// set audio
|
||||
if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first {
|
||||
cell.statusView.audioView.isHidden = false
|
||||
AudioContainerViewModel.configure(cell: cell, audioAttachment: audioAttachment)
|
||||
} else {
|
||||
cell.statusView.audioView.isHidden = true
|
||||
}
|
||||
|
||||
// set poll
|
||||
let poll = (toot.reblog ?? toot).poll
|
||||
StatusSection.configurePoll(
|
||||
|
@ -171,7 +179,7 @@ extension StatusSection {
|
|||
.sink { _ in
|
||||
// do nothing
|
||||
} receiveValue: { change in
|
||||
guard case let .update(object) = change.changeType,
|
||||
guard case .update(let object) = change.changeType,
|
||||
let newPoll = object as? Poll else { return }
|
||||
StatusSection.configurePoll(
|
||||
cell: cell,
|
||||
|
@ -351,7 +359,6 @@ extension StatusSection {
|
|||
snapshot.appendItems(pollItems, toSection: .main)
|
||||
cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension StatusSection {
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
//
|
||||
// Double.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/8.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Double {
|
||||
func asString(style: DateComponentsFormatter.UnitsStyle) -> String {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.allowedUnits = [.minute, .second]
|
||||
formatter.unitsStyle = style
|
||||
formatter.zeroFormattingBehavior = .pad
|
||||
guard let formattedString = formatter.string(from: self) else { return "" }
|
||||
return formattedString
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
//
|
||||
// UIControl.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/8.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
/// A custom subscription to capture UIControl target events.
|
||||
final class UIControlSubscription<SubscriberType: Subscriber, Control: UIControl>: Subscription where SubscriberType.Input == Control {
|
||||
private var subscriber: SubscriberType?
|
||||
private let control: Control
|
||||
|
||||
init(subscriber: SubscriberType, control: Control, event: UIControl.Event) {
|
||||
self.subscriber = subscriber
|
||||
self.control = control
|
||||
control.addTarget(self, action: #selector(eventHandler), for: event)
|
||||
}
|
||||
|
||||
func request(_ demand: Subscribers.Demand) {
|
||||
// We do nothing here as we only want to send events when they occur.
|
||||
// See, for more info: https://developer.apple.com/documentation/combine/subscribers/demand
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
subscriber = nil
|
||||
}
|
||||
|
||||
@objc private func eventHandler() {
|
||||
_ = subscriber?.receive(control)
|
||||
}
|
||||
}
|
||||
|
||||
/// A custom `Publisher` to work with our custom `UIControlSubscription`.
|
||||
struct UIControlPublisher<Control: UIControl>: Publisher {
|
||||
|
||||
typealias Output = Control
|
||||
typealias Failure = Never
|
||||
|
||||
let control: Control
|
||||
let controlEvents: UIControl.Event
|
||||
|
||||
init(control: Control, events: UIControl.Event) {
|
||||
self.control = control
|
||||
self.controlEvents = events
|
||||
}
|
||||
|
||||
func receive<S>(subscriber: S) where S : Subscriber, S.Failure == UIControlPublisher.Failure, S.Input == UIControlPublisher.Output {
|
||||
let subscription = UIControlSubscription(subscriber: subscriber, control: control, event: controlEvents)
|
||||
subscriber.receive(subscription: subscription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extending the `UIControl` types to be able to produce a `UIControl.Event` publisher.
|
||||
protocol CombineCompatible { }
|
||||
extension UIControl: CombineCompatible { }
|
||||
extension CombineCompatible where Self: UIControl {
|
||||
func publisher(for events: UIControl.Event) -> UIControlPublisher<UIControl> {
|
||||
return UIControlPublisher(control: self, events: events)
|
||||
}
|
||||
}
|
|
@ -1,25 +1,23 @@
|
|||
//
|
||||
// UIIamge.swift
|
||||
// UIImage.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/1/28.
|
||||
// Created by sxiaojian on 2021/3/8.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import CoreImage
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import UIKit
|
||||
|
||||
extension UIImage {
|
||||
|
||||
static func placeholder(size: CGSize = CGSize(width: 1, height: 1), color: UIColor) -> UIImage {
|
||||
let render = UIGraphicsImageRenderer(size: size)
|
||||
|
||||
|
||||
return render.image { (context: UIGraphicsImageRendererContext) in
|
||||
context.cgContext.setFillColor(color.cgColor)
|
||||
context.fill(CGRect(origin: .zero, size: size))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// refs: https://www.hackingwithswift.com/example-code/media/how-to-read-the-average-color-of-a-uiimage-using-ciareaaverage
|
||||
|
@ -27,16 +25,16 @@ extension UIImage {
|
|||
@available(iOS 14.0, *)
|
||||
var dominantColor: UIColor? {
|
||||
guard let inputImage = CIImage(image: self) else { return nil }
|
||||
|
||||
|
||||
let filter = CIFilter.areaAverage()
|
||||
filter.inputImage = inputImage
|
||||
filter.extent = inputImage.extent
|
||||
guard let outputImage = filter.outputImage else { return nil }
|
||||
|
||||
|
||||
var bitmap = [UInt8](repeating: 0, count: 4)
|
||||
let context = CIContext(options: [.workingColorSpace: kCFNull])
|
||||
context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)
|
||||
|
||||
|
||||
return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255)
|
||||
}
|
||||
}
|
||||
|
@ -53,3 +51,20 @@ extension UIImage {
|
|||
return image
|
||||
}
|
||||
}
|
||||
|
||||
public extension UIImage {
|
||||
func withRoundedCorners(radius: CGFloat? = nil) -> UIImage? {
|
||||
let maxRadius = min(size.width, size.height) / 2
|
||||
let cornerRadius: CGFloat = {
|
||||
guard let radius = radius, radius > 0 else { return maxRadius }
|
||||
return min(radius, maxRadius)
|
||||
}()
|
||||
|
||||
let render = UIGraphicsImageRenderer(size: size)
|
||||
return render.image { (_: UIGraphicsImageRendererContext) in
|
||||
let rect = CGRect(origin: .zero, size: size)
|
||||
UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).addClip()
|
||||
draw(in: rect)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -58,6 +58,9 @@ internal enum Asset {
|
|||
internal static let primary = ColorAsset(name: "Colors/Label/primary")
|
||||
internal static let secondary = ColorAsset(name: "Colors/Label/secondary")
|
||||
}
|
||||
internal enum Slider {
|
||||
internal static let bar = ColorAsset(name: "Colors/Slider/bar")
|
||||
}
|
||||
internal enum TextField {
|
||||
internal static let highlight = ColorAsset(name: "Colors/TextField/highlight")
|
||||
internal static let invalid = ColorAsset(name: "Colors/TextField/invalid")
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"provides-namespace" : true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "147",
|
||||
"green" : "106",
|
||||
"red" : "51"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -111,7 +111,24 @@ extension MastodonConfirmEmailViewController {
|
|||
case .failure(let error):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: swap user access token swap fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
|
||||
case .finished:
|
||||
break
|
||||
// upload avatar and set display name in the background
|
||||
self.context.apiService.accountUpdateCredentials(
|
||||
domain: self.viewModel.authenticateInfo.domain,
|
||||
query: self.viewModel.updateCredentialQuery,
|
||||
authorization: Mastodon.API.OAuth.Authorization(accessToken: self.viewModel.userToken.accessToken)
|
||||
)
|
||||
.retry(3)
|
||||
.sink { completion in
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setup avatar & display name fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
case .finished:
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setup avatar & display name success", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
} receiveValue: { _ in
|
||||
// do nothing
|
||||
}
|
||||
.store(in: &self.context.disposeBag) // execute in the background
|
||||
}
|
||||
} receiveValue: { response in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: user %s's email confirmed", ((#file as NSString).lastPathComponent), #line, #function, response.value.username)
|
||||
|
|
|
@ -12,20 +12,29 @@ import MastodonSDK
|
|||
final class MastodonConfirmEmailViewModel {
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
var email: String
|
||||
let authenticateInfo: AuthenticationViewModel.AuthenticateInfo
|
||||
let userToken: Mastodon.Entity.Token
|
||||
let updateCredentialQuery: Mastodon.API.Account.UpdateCredentialQuery
|
||||
|
||||
let timestampUpdatePublisher = Timer.publish(every: 4.0, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.share()
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
init(context: AppContext, email: String, authenticateInfo: AuthenticationViewModel.AuthenticateInfo, userToken: Mastodon.Entity.Token) {
|
||||
init(
|
||||
context: AppContext,
|
||||
email: String,
|
||||
authenticateInfo: AuthenticationViewModel.AuthenticateInfo,
|
||||
userToken: Mastodon.Entity.Token,
|
||||
updateCredentialQuery: Mastodon.API.Account.UpdateCredentialQuery
|
||||
) {
|
||||
self.context = context
|
||||
self.email = email
|
||||
self.authenticateInfo = authenticateInfo
|
||||
self.userToken = userToken
|
||||
self.updateCredentialQuery = updateCredentialQuery
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,12 +5,12 @@
|
|||
// Created by MainasuK Cirno on 2021-2-5.
|
||||
//
|
||||
|
||||
import AlamofireImage
|
||||
import Combine
|
||||
import MastodonSDK
|
||||
import os.log
|
||||
import PhotosUI
|
||||
import UIKit
|
||||
import UITextField_Shake
|
||||
|
||||
final class MastodonRegisterViewController: UIViewController, NeedsDependency, OnboardingViewControllerAppearance {
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
@ -623,10 +623,10 @@ extension MastodonRegisterViewController {
|
|||
username: username,
|
||||
email: email,
|
||||
password: password,
|
||||
agreement: true, // TODO:
|
||||
locale: "en" // TODO:
|
||||
agreement: true, // user confirmed in the server rules scene
|
||||
locale: Locale.current.languageCode ?? "en"
|
||||
)
|
||||
|
||||
|
||||
// register without show server rules
|
||||
context.apiService.accountRegister(
|
||||
domain: viewModel.domain,
|
||||
|
@ -646,7 +646,21 @@ extension MastodonRegisterViewController {
|
|||
} receiveValue: { [weak self] response in
|
||||
guard let self = self else { return }
|
||||
let userToken = response.value
|
||||
let viewModel = MastodonConfirmEmailViewModel(context: self.context, email: email, authenticateInfo: self.viewModel.authenticateInfo, userToken: userToken)
|
||||
let updateCredentialQuery: Mastodon.API.Account.UpdateCredentialQuery = {
|
||||
let displayName: String? = self.viewModel.displayName.value.isEmpty ? nil : self.viewModel.displayName.value
|
||||
let avatar: Mastodon.Query.MediaAttachment? = {
|
||||
guard let avatarImage = self.viewModel.avatarImage.value else { return nil }
|
||||
guard avatarImage.size.width <= 400 else {
|
||||
return .jpeg(avatarImage.af.imageScaled(to: CGSize(width: 400, height: 400)).jpegData(compressionQuality: 0.8))
|
||||
}
|
||||
return .jpeg(avatarImage.jpegData(compressionQuality: 0.8))
|
||||
}()
|
||||
return Mastodon.API.Account.UpdateCredentialQuery(
|
||||
displayName: displayName,
|
||||
avatar: avatar
|
||||
)
|
||||
}()
|
||||
let viewModel = MastodonConfirmEmailViewModel(context: self.context, email: email, authenticateInfo: self.viewModel.authenticateInfo, userToken: userToken, updateCredentialQuery: updateCredentialQuery)
|
||||
self.coordinator.present(scene: .mastodonConfirmEmail(viewModel: viewModel), from: self, transition: .show)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
//
|
||||
// AudioViewContainer.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/8.
|
||||
//
|
||||
|
||||
import CoreDataStack
|
||||
import os.log
|
||||
import UIKit
|
||||
|
||||
final class AudioContainerView: UIView {
|
||||
static let cornerRadius: CGFloat = 22
|
||||
|
||||
let container: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .fill
|
||||
stackView.alignment = .center
|
||||
stackView.spacing = 11
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
stackView.layer.cornerRadius = AudioContainerView.cornerRadius
|
||||
stackView.clipsToBounds = true
|
||||
stackView.backgroundColor = Asset.Colors.Button.highlight.color
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
return stackView
|
||||
}()
|
||||
|
||||
let playButtonBackgroundView: UIView = {
|
||||
let view = UIView()
|
||||
view.layer.cornerRadius = 16
|
||||
view.clipsToBounds = true
|
||||
view.backgroundColor = Asset.Colors.Button.highlight.color
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
let playButton: UIButton = {
|
||||
let button = HighlightDimmableButton(type: .custom)
|
||||
let image = UIImage(systemName: "play.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))!
|
||||
button.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
|
||||
let pauseImage = UIImage(systemName: "pause.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))!
|
||||
button.setImage(pauseImage.withRenderingMode(.alwaysTemplate), for: .selected)
|
||||
|
||||
button.tintColor = .white
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.isEnabled = true
|
||||
return button
|
||||
}()
|
||||
|
||||
let slider: UISlider = {
|
||||
let slider = UISlider()
|
||||
slider.translatesAutoresizingMaskIntoConstraints = false
|
||||
slider.minimumTrackTintColor = Asset.Colors.Slider.bar.color
|
||||
slider.maximumTrackTintColor = Asset.Colors.Slider.bar.color
|
||||
if let image = UIImage.placeholder(size: CGSize(width: 22, height: 22), color: .white).withRoundedCorners(radius: 11) {
|
||||
slider.setThumbImage(image, for: .normal)
|
||||
}
|
||||
return slider
|
||||
}()
|
||||
|
||||
let timeLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.font = .systemFont(ofSize: 13, weight: .regular)
|
||||
label.textColor = .white
|
||||
label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .right : .left
|
||||
return label
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioContainerView {
|
||||
private func _init() {
|
||||
addSubview(container)
|
||||
NSLayoutConstraint.activate([
|
||||
container.topAnchor.constraint(equalTo: topAnchor),
|
||||
container.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
|
||||
// checkmark
|
||||
playButtonBackgroundView.addSubview(playButton)
|
||||
container.addArrangedSubview(playButtonBackgroundView)
|
||||
NSLayoutConstraint.activate([
|
||||
playButton.centerXAnchor.constraint(equalTo: playButtonBackgroundView.centerXAnchor),
|
||||
playButton.centerYAnchor.constraint(equalTo: playButtonBackgroundView.centerYAnchor),
|
||||
playButtonBackgroundView.heightAnchor.constraint(equalToConstant: 32),
|
||||
playButtonBackgroundView.widthAnchor.constraint(equalToConstant: 32),
|
||||
])
|
||||
|
||||
container.addArrangedSubview(slider)
|
||||
|
||||
container.addArrangedSubview(timeLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
timeLabel.widthAnchor.constraint(equalToConstant: 40),
|
||||
])
|
||||
}
|
||||
}
|
|
@ -156,6 +156,10 @@ final class StatusView: UIView {
|
|||
return imageView
|
||||
}()
|
||||
|
||||
let audioView: AudioContainerView = {
|
||||
let audioView = AudioContainerView()
|
||||
return audioView
|
||||
}()
|
||||
let actionToolbarContainer: ActionToolbarContainer = {
|
||||
let actionToolbarContainer = ActionToolbarContainer()
|
||||
actionToolbarContainer.configure(for: .inline)
|
||||
|
@ -338,6 +342,14 @@ extension StatusView {
|
|||
pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal)
|
||||
|
||||
audioView.translatesAutoresizingMaskIntoConstraints = false
|
||||
statusContainerStackView.addArrangedSubview(audioView)
|
||||
NSLayoutConstraint.activate([
|
||||
audioView.leadingAnchor.constraint(equalTo: statusTextContainerView.leadingAnchor),
|
||||
audioView.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor),
|
||||
audioView.heightAnchor.constraint(equalToConstant: 44)
|
||||
])
|
||||
|
||||
// action toolbar container
|
||||
containerStackView.addArrangedSubview(actionToolbarContainer)
|
||||
actionToolbarContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||
|
@ -346,6 +358,7 @@ extension StatusView {
|
|||
statusMosaicImageViewContainer.isHidden = true
|
||||
pollTableView.isHidden = true
|
||||
pollStatusStackView.isHidden = true
|
||||
audioView.isHidden = true
|
||||
|
||||
contentWarningBlurContentImageView.isHidden = true
|
||||
statusContentWarningContainerStackView.isHidden = true
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
//
|
||||
// AudioContainerViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/9.
|
||||
//
|
||||
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
class AudioContainerViewModel {
|
||||
static func configure(
|
||||
cell: StatusTableViewCell,
|
||||
audioAttachment: Attachment
|
||||
) {
|
||||
guard let duration = audioAttachment.meta?.original?.duration else { return }
|
||||
let audioView = cell.statusView.audioView
|
||||
audioView.timeLabel.text = duration.asString(style: .positional)
|
||||
|
||||
audioView.playButton.publisher(for: .touchUpInside)
|
||||
.sink { _ in
|
||||
if audioAttachment === AudioPlayer.shared.attachment {
|
||||
if AudioPlayer.shared.isPlaying() {
|
||||
AudioPlayer.shared.pause()
|
||||
} else {
|
||||
AudioPlayer.shared.resume()
|
||||
}
|
||||
if AudioPlayer.shared.currentTimeSubject.value == 0 {
|
||||
AudioPlayer.shared.playAudio(audioAttachment: audioAttachment)
|
||||
}
|
||||
} else {
|
||||
AudioPlayer.shared.playAudio(audioAttachment: audioAttachment)
|
||||
}
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
audioView.slider.publisher(for: .valueChanged)
|
||||
.sink { slider in
|
||||
let slider = slider as! UISlider
|
||||
let time = Double(slider.value) * duration
|
||||
AudioPlayer.shared.seekToTime(time: time)
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
self.observePlayer(cell: cell, audioAttachment: audioAttachment)
|
||||
if audioAttachment != AudioPlayer.shared.attachment {
|
||||
configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped)
|
||||
}
|
||||
}
|
||||
|
||||
static func observePlayer(
|
||||
cell: StatusTableViewCell,
|
||||
audioAttachment: Attachment
|
||||
) {
|
||||
let audioView = cell.statusView.audioView
|
||||
var lastCurrentTimeSubject: TimeInterval?
|
||||
AudioPlayer.shared.currentTimeSubject
|
||||
.throttle(for: 0.33, scheduler: DispatchQueue.main, latest: true)
|
||||
.compactMap { time -> (TimeInterval, Float)? in
|
||||
defer {
|
||||
lastCurrentTimeSubject = time
|
||||
}
|
||||
guard audioAttachment === AudioPlayer.shared.attachment else { return nil }
|
||||
guard let duration = audioAttachment.meta?.original?.duration else { return nil }
|
||||
|
||||
if let lastCurrentTimeSubject = lastCurrentTimeSubject, time != 0.0 {
|
||||
guard abs(time - lastCurrentTimeSubject) < 0.5 else { return nil } // debounce
|
||||
}
|
||||
|
||||
guard !audioView.slider.isTracking else { return nil }
|
||||
return (time, Float(time / duration))
|
||||
}
|
||||
.sink(receiveValue: { time, progress in
|
||||
audioView.timeLabel.text = time.asString(style: .positional)
|
||||
audioView.slider.setValue(progress, animated: true)
|
||||
})
|
||||
.store(in: &cell.disposeBag)
|
||||
AudioPlayer.shared.playbackState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { playbackState in
|
||||
if audioAttachment === AudioPlayer.shared.attachment {
|
||||
configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: playbackState)
|
||||
} else {
|
||||
configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped)
|
||||
}
|
||||
})
|
||||
.store(in: &cell.disposeBag)
|
||||
}
|
||||
|
||||
static func configureAudioView(
|
||||
audioView: AudioContainerView,
|
||||
audioAttachment: Attachment,
|
||||
playbackState: PlaybackState
|
||||
) {
|
||||
switch playbackState {
|
||||
case .stopped:
|
||||
audioView.playButton.isSelected = false
|
||||
audioView.slider.isEnabled = false
|
||||
audioView.slider.setValue(0, animated: false)
|
||||
case .paused:
|
||||
audioView.playButton.isSelected = false
|
||||
audioView.slider.isEnabled = true
|
||||
case .playing, .readyToPlay:
|
||||
audioView.playButton.isSelected = true
|
||||
audioView.slider.isEnabled = true
|
||||
default:
|
||||
assertionFailure()
|
||||
}
|
||||
guard let duration = audioAttachment.meta?.original?.duration else { return }
|
||||
audioView.timeLabel.text = duration.asString(style: .positional)
|
||||
}
|
||||
}
|
|
@ -16,7 +16,8 @@ struct MosaicImageViewModel {
|
|||
var metas: [MosaicMeta] = []
|
||||
for element in mediaAttachments where element.type == .image {
|
||||
// Display original on the iPad/Mac
|
||||
let urlString = UIDevice.current.userInterfaceIdiom == .phone ? element.previewURL : element.url
|
||||
guard let previewURL = element.previewURL else { continue }
|
||||
let urlString = UIDevice.current.userInterfaceIdiom == .phone ? previewURL : element.url
|
||||
guard let meta = element.meta,
|
||||
let width = meta.original?.width,
|
||||
let height = meta.original?.height,
|
||||
|
|
|
@ -43,6 +43,39 @@ extension APIService {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func accountUpdateCredentials(
|
||||
domain: String,
|
||||
query: Mastodon.API.Account.UpdateCredentialQuery,
|
||||
authorization: Mastodon.API.OAuth.Authorization
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
|
||||
return Mastodon.API.Account.updateCredentials(
|
||||
session: session,
|
||||
domain: domain,
|
||||
query: query,
|
||||
authorization: authorization
|
||||
)
|
||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
|
||||
let log = OSLog.api
|
||||
let account = response.value
|
||||
|
||||
return self.backgroundManagedObjectContext.performChanges {
|
||||
let (mastodonUser, isCreated) = APIService.CoreData.createOrMergeMastodonUser(
|
||||
into: self.backgroundManagedObjectContext,
|
||||
for: nil,
|
||||
in: domain,
|
||||
entity: account,
|
||||
networkDate: response.networkDate,
|
||||
log: log)
|
||||
let flag = isCreated ? "+" : "-"
|
||||
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user [%s](%s)%s verifed", ((#file as NSString).lastPathComponent), #line, #function, flag, mastodonUser.id, mastodonUser.username)
|
||||
}
|
||||
.setFailureType(to: Error.self)
|
||||
.map { _ in return response }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func accountRegister(
|
||||
domain: String,
|
||||
query: Mastodon.API.Account.RegisterQuery,
|
||||
|
|
|
@ -0,0 +1,132 @@
|
|||
//
|
||||
// AudioPlayer.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/8.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
final class AudioPlayer: NSObject {
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
var player = AVPlayer()
|
||||
var timeObserver: Any?
|
||||
var statusObserver: Any?
|
||||
var attachment: Attachment?
|
||||
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let playbackState = CurrentValueSubject<PlaybackState, Never>(PlaybackState.unknown)
|
||||
|
||||
// MARK: - singleton
|
||||
public static let shared = AudioPlayer()
|
||||
|
||||
let currentTimeSubject = CurrentValueSubject<TimeInterval, Never>(0)
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
addObserver()
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioPlayer {
|
||||
func playAudio(audioAttachment: Attachment) {
|
||||
guard let url = URL(string: audioAttachment.url) else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try session.setCategory(.playback)
|
||||
} catch {
|
||||
print(error)
|
||||
return
|
||||
}
|
||||
|
||||
if audioAttachment == attachment {
|
||||
if self.playbackState.value == .stopped {
|
||||
self.seekToTime(time: .zero)
|
||||
}
|
||||
player.play()
|
||||
self.playbackState.value = .playing
|
||||
return
|
||||
}
|
||||
player.pause()
|
||||
let playerItem = AVPlayerItem(url: url)
|
||||
player.replaceCurrentItem(with: playerItem)
|
||||
attachment = audioAttachment
|
||||
player.play()
|
||||
playbackState.value = .playing
|
||||
}
|
||||
|
||||
func addObserver() {
|
||||
UIDevice.current.isProximityMonitoringEnabled = true
|
||||
NotificationCenter.default.publisher(for: UIDevice.proximityStateDidChangeNotification, object: nil)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
if UIDevice.current.proximityState == true {
|
||||
do {
|
||||
try self.session.setCategory(.playAndRecord)
|
||||
} catch {
|
||||
print(error)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
try self.session.setCategory(.playback)
|
||||
} catch {
|
||||
print(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
timeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMake(value: 1, timescale: CMTimeScale(NSEC_PER_SEC)), queue: DispatchQueue.main, using: { [weak self] time in
|
||||
guard let self = self else { return }
|
||||
self.currentTimeSubject.value = time.seconds
|
||||
})
|
||||
player.publisher(for: \.status, options: .new)
|
||||
.sink(receiveValue: { [weak self] status in
|
||||
guard let self = self else { return }
|
||||
switch status {
|
||||
case .failed:
|
||||
self.playbackState.value = .failed
|
||||
case .readyToPlay:
|
||||
self.playbackState.value = .readyToPlay
|
||||
case .unknown:
|
||||
self.playbackState.value = .unknown
|
||||
@unknown default:
|
||||
assertionFailure()
|
||||
}
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: nil)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.player.seek(to: .zero)
|
||||
self.playbackState.value = .stopped
|
||||
self.currentTimeSubject.value = 0
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
func isPlaying() -> Bool {
|
||||
return self.playbackState.value == .readyToPlay || self.playbackState.value == .playing
|
||||
}
|
||||
func resume() {
|
||||
player.play()
|
||||
playbackState.value = .playing
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player.pause()
|
||||
playbackState.value = .paused
|
||||
}
|
||||
|
||||
func seekToTime(time: TimeInterval) {
|
||||
player.seek(to: CMTimeMake(value:Int64(time), timescale: 1))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
//
|
||||
// PlaybackState.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/9.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum PlaybackState : Int {
|
||||
|
||||
case unknown = 0
|
||||
|
||||
case buffering = 1
|
||||
|
||||
case readyToPlay = 2
|
||||
|
||||
case playing = 3
|
||||
|
||||
case paused = 4
|
||||
|
||||
case stopped = 5
|
||||
|
||||
case failed = 6
|
||||
}
|
|
@ -0,0 +1,227 @@
|
|||
//
|
||||
// Mastodon+API+Account+Credentials.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-8.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
// MARK: - Account credentials
|
||||
extension Mastodon.API.Account {
|
||||
|
||||
static func accountsEndpointURL(domain: String) -> URL {
|
||||
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts")
|
||||
}
|
||||
|
||||
/// Register an account
|
||||
///
|
||||
/// Creates a user and account records.
|
||||
///
|
||||
/// - Since: 2.7.0
|
||||
/// - Version: 3.3.0
|
||||
/// # Last Update
|
||||
/// 2021/2/9
|
||||
/// # Reference
|
||||
/// [Document](https://docs.joinmastodon.org/methods/accounts/)
|
||||
/// - Parameters:
|
||||
/// - session: `URLSession`
|
||||
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||
/// - query: `RegisterQuery` with account registration information
|
||||
/// - authorization: App token
|
||||
/// - Returns: `AnyPublisher` contains `Token` nested in the response
|
||||
public static func register(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
query: RegisterQuery,
|
||||
authorization: Mastodon.API.OAuth.Authorization
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Token>, Error> {
|
||||
let request = Mastodon.API.post(
|
||||
url: accountsEndpointURL(domain: domain),
|
||||
query: query,
|
||||
authorization: authorization
|
||||
)
|
||||
return session.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response in
|
||||
let value = try Mastodon.API.decode(type: Mastodon.Entity.Token.self, from: data, response: response)
|
||||
return Mastodon.Response.Content(value: value, response: response)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public struct RegisterQuery: Codable, PostQuery {
|
||||
public let reason: String?
|
||||
public let username: String
|
||||
public let email: String
|
||||
public let password: String
|
||||
public let agreement: Bool
|
||||
public let locale: String
|
||||
|
||||
public init(reason: String? = nil, username: String, email: String, password: String, agreement: Bool, locale: String) {
|
||||
self.reason = reason
|
||||
self.username = username
|
||||
self.email = email
|
||||
self.password = password
|
||||
self.agreement = agreement
|
||||
self.locale = locale
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Mastodon.API.Account {
|
||||
|
||||
static func verifyCredentialsEndpointURL(domain: String) -> URL {
|
||||
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/verify_credentials")
|
||||
}
|
||||
|
||||
/// Verify account credentials
|
||||
///
|
||||
/// Test to make sure that the user token works.
|
||||
///
|
||||
/// - Since: 0.0.0
|
||||
/// - Version: 3.3.0
|
||||
/// # Last Update
|
||||
/// 2021/2/9
|
||||
/// # Reference
|
||||
/// [Document](https://docs.joinmastodon.org/methods/accounts/)
|
||||
/// - Parameters:
|
||||
/// - session: `URLSession`
|
||||
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||
/// - authorization: App token
|
||||
/// - Returns: `AnyPublisher` contains `Account` nested in the response
|
||||
public static func verifyCredentials(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
authorization: Mastodon.API.OAuth.Authorization
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
|
||||
let request = Mastodon.API.get(
|
||||
url: verifyCredentialsEndpointURL(domain: domain),
|
||||
query: nil,
|
||||
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()
|
||||
}
|
||||
|
||||
static func updateCredentialsEndpointURL(domain: String) -> URL {
|
||||
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/update_credentials")
|
||||
}
|
||||
|
||||
/// Update account credentials
|
||||
///
|
||||
/// Update the user's display and preferences.
|
||||
///
|
||||
/// - Since: 1.1.1
|
||||
/// - Version: 3.3.0
|
||||
/// # Last Update
|
||||
/// 2021/2/9
|
||||
/// # Reference
|
||||
/// [Document](https://docs.joinmastodon.org/methods/accounts/)
|
||||
/// - Parameters:
|
||||
/// - session: `URLSession`
|
||||
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||
/// - query: `CredentialQuery` with update credential information
|
||||
/// - authorization: user token
|
||||
/// - Returns: `AnyPublisher` contains updated `Account` nested in the response
|
||||
public static func updateCredentials(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
query: UpdateCredentialQuery,
|
||||
authorization: Mastodon.API.OAuth.Authorization
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
|
||||
let request = Mastodon.API.patch(
|
||||
url: updateCredentialsEndpointURL(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()
|
||||
}
|
||||
|
||||
public struct UpdateCredentialQuery: PatchQuery {
|
||||
public let discoverable: Bool?
|
||||
public let bot: Bool?
|
||||
public let displayName: String?
|
||||
public let note: String?
|
||||
public let avatar: Mastodon.Query.MediaAttachment?
|
||||
public let header: Mastodon.Query.MediaAttachment?
|
||||
public let locked: Bool?
|
||||
public let source: Mastodon.Entity.Source?
|
||||
public let fieldsAttributes: [Mastodon.Entity.Field]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case discoverable
|
||||
case bot
|
||||
case displayName = "display_name"
|
||||
case note
|
||||
|
||||
case avatar
|
||||
case header
|
||||
case locked
|
||||
case source
|
||||
case fieldsAttributes = "fields_attributes"
|
||||
}
|
||||
|
||||
public init(
|
||||
discoverable: Bool? = nil,
|
||||
bot: Bool? = nil,
|
||||
displayName: String? = nil,
|
||||
note: String? = nil,
|
||||
avatar: Mastodon.Query.MediaAttachment? = nil,
|
||||
header: Mastodon.Query.MediaAttachment? = nil,
|
||||
locked: Bool? = nil,
|
||||
source: Mastodon.Entity.Source? = nil,
|
||||
fieldsAttributes: [Mastodon.Entity.Field]? = nil
|
||||
) {
|
||||
self.discoverable = discoverable
|
||||
self.bot = bot
|
||||
self.displayName = displayName
|
||||
self.note = note
|
||||
self.avatar = avatar
|
||||
self.header = header
|
||||
self.locked = locked
|
||||
self.source = source
|
||||
self.fieldsAttributes = fieldsAttributes
|
||||
}
|
||||
|
||||
var contentType: String? {
|
||||
return Self.multipartContentType()
|
||||
}
|
||||
|
||||
var body: Data? {
|
||||
var data = Data()
|
||||
|
||||
discoverable.flatMap { data.append(Data.multipart(key: "discoverable", value: $0)) }
|
||||
bot.flatMap { data.append(Data.multipart(key: "bot", value: $0)) }
|
||||
displayName.flatMap { data.append(Data.multipart(key: "display_name", value: $0)) }
|
||||
note.flatMap { data.append(Data.multipart(key: "note", value: $0)) }
|
||||
avatar.flatMap { data.append(Data.multipart(key: "avatar", value: $0)) }
|
||||
header.flatMap { data.append(Data.multipart(key: "header", value: $0)) }
|
||||
locked.flatMap { data.append(Data.multipart(key: "locked", value: $0)) }
|
||||
if let source = source {
|
||||
source.privacy.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0.rawValue)) }
|
||||
source.sensitive.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0)) }
|
||||
source.language.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0)) }
|
||||
}
|
||||
fieldsAttributes.flatMap { fieldsAttributes in
|
||||
for fieldsAttribute in fieldsAttributes {
|
||||
data.append(Data.multipart(key: "fields_attributes[name][]", value: fieldsAttribute.name))
|
||||
data.append(Data.multipart(key: "fields_attributes[value][]", value: fieldsAttribute.value))
|
||||
}
|
||||
}
|
||||
data.append(Data.multipartEnd())
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -8,119 +8,17 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
|
||||
// MARK: - Retrieve information
|
||||
extension Mastodon.API.Account {
|
||||
|
||||
static func verifyCredentialsEndpointURL(domain: String) -> URL {
|
||||
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/verify_credentials")
|
||||
}
|
||||
static func accountsEndpointURL(domain: String) -> URL {
|
||||
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts")
|
||||
}
|
||||
|
||||
static func accountsInfoEndpointURL(domain: String, id: String) -> URL {
|
||||
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts")
|
||||
return Mastodon.API.endpointURL(domain: domain)
|
||||
.appendingPathComponent("accounts")
|
||||
.appendingPathComponent(id)
|
||||
}
|
||||
static func updateCredentialsEndpointURL(domain: String) -> URL {
|
||||
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/update_credentials")
|
||||
}
|
||||
|
||||
/// Test to make sure that the user token works.
|
||||
/// Retrieve information
|
||||
///
|
||||
/// - Since: 0.0.0
|
||||
/// - Version: 3.3.0
|
||||
/// # Last Update
|
||||
/// 2021/2/9
|
||||
/// # Reference
|
||||
/// [Document](https://docs.joinmastodon.org/methods/accounts/)
|
||||
/// - Parameters:
|
||||
/// - session: `URLSession`
|
||||
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||
/// - authorization: App token
|
||||
/// - Returns: `AnyPublisher` contains `Account` nested in the response
|
||||
public static func verifyCredentials(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
authorization: Mastodon.API.OAuth.Authorization
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
|
||||
let request = Mastodon.API.get(
|
||||
url: verifyCredentialsEndpointURL(domain: domain),
|
||||
query: nil,
|
||||
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()
|
||||
}
|
||||
|
||||
/// Creates a user and account records.
|
||||
///
|
||||
/// - Since: 2.7.0
|
||||
/// - Version: 3.3.0
|
||||
/// # Last Update
|
||||
/// 2021/2/9
|
||||
/// # Reference
|
||||
/// [Document](https://docs.joinmastodon.org/methods/accounts/)
|
||||
/// - Parameters:
|
||||
/// - session: `URLSession`
|
||||
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||
/// - query: `RegisterQuery` with account registration information
|
||||
/// - authorization: App token
|
||||
/// - Returns: `AnyPublisher` contains `Token` nested in the response
|
||||
public static func register(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
query: RegisterQuery,
|
||||
authorization: Mastodon.API.OAuth.Authorization
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Token>, Error> {
|
||||
let request = Mastodon.API.post(
|
||||
url: accountsEndpointURL(domain: domain),
|
||||
query: query,
|
||||
authorization: authorization
|
||||
)
|
||||
return session.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response in
|
||||
let value = try Mastodon.API.decode(type: Mastodon.Entity.Token.self, from: data, response: response)
|
||||
return Mastodon.Response.Content(value: value, response: response)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// Update the user's display and preferences.
|
||||
///
|
||||
/// - Since: 1.1.1
|
||||
/// - Version: 3.3.0
|
||||
/// # Last Update
|
||||
/// 2021/2/9
|
||||
/// # Reference
|
||||
/// [Document](https://docs.joinmastodon.org/methods/accounts/)
|
||||
/// - Parameters:
|
||||
/// - session: `URLSession`
|
||||
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||
/// - query: `CredentialQuery` with update credential information
|
||||
/// - authorization: user token
|
||||
/// - Returns: `AnyPublisher` contains updated `Account` nested in the response
|
||||
public static func updateCredentials(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
query: UpdateCredentialQuery,
|
||||
authorization: Mastodon.API.OAuth.Authorization
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
|
||||
let request = Mastodon.API.patch(
|
||||
url: updateCredentialsEndpointURL(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()
|
||||
}
|
||||
|
||||
/// View information about a profile.
|
||||
///
|
||||
/// - Since: 0.0.0
|
||||
|
@ -138,11 +36,11 @@ extension Mastodon.API.Account {
|
|||
public static func accountInfo(
|
||||
session: URLSession,
|
||||
domain: String,
|
||||
query: AccountInfoQuery,
|
||||
userID: Mastodon.Entity.Account.ID,
|
||||
authorization: Mastodon.API.OAuth.Authorization?
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
|
||||
let request = Mastodon.API.get(
|
||||
url: accountsInfoEndpointURL(domain: domain, id: query.id),
|
||||
url: accountsInfoEndpointURL(domain: domain, id: userID),
|
||||
query: nil,
|
||||
authorization: authorization
|
||||
)
|
||||
|
@ -155,79 +53,3 @@ extension Mastodon.API.Account {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
extension Mastodon.API.Account {
|
||||
|
||||
public struct RegisterQuery: Codable, PostQuery {
|
||||
public let reason: String?
|
||||
public let username: String
|
||||
public let email: String
|
||||
public let password: String
|
||||
public let agreement: Bool
|
||||
public let locale: String
|
||||
|
||||
public init(reason: String? = nil, username: String, email: String, password: String, agreement: Bool, locale: String) {
|
||||
self.reason = reason
|
||||
self.username = username
|
||||
self.email = email
|
||||
self.password = password
|
||||
self.agreement = agreement
|
||||
self.locale = locale
|
||||
}
|
||||
}
|
||||
|
||||
public struct UpdateCredentialQuery: Codable, PatchQuery {
|
||||
|
||||
public var discoverable: Bool?
|
||||
public var bot: Bool?
|
||||
public var displayName: String?
|
||||
public var note: String?
|
||||
public var avatar: String?
|
||||
public var header: String?
|
||||
public var locked: Bool?
|
||||
public var source: Mastodon.Entity.Source?
|
||||
public var fieldsAttributes: [Mastodon.Entity.Field]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case discoverable
|
||||
case bot
|
||||
case displayName = "display_name"
|
||||
case note
|
||||
|
||||
case avatar
|
||||
case header
|
||||
case locked
|
||||
case source
|
||||
case fieldsAttributes = "fields_attributes"
|
||||
}
|
||||
|
||||
public init(
|
||||
discoverable: Bool? = nil,
|
||||
bot: Bool? = nil,
|
||||
displayName: String? = nil,
|
||||
note: String? = nil,
|
||||
avatar: Mastodon.Entity.MediaAttachment? = nil,
|
||||
header: Mastodon.Entity.MediaAttachment? = nil,
|
||||
locked: Bool? = nil,
|
||||
source: Mastodon.Entity.Source? = nil,
|
||||
fieldsAttributes: [Mastodon.Entity.Field]? = nil
|
||||
) {
|
||||
self.discoverable = discoverable
|
||||
self.bot = bot
|
||||
self.displayName = displayName
|
||||
self.note = note
|
||||
self.avatar = avatar?.base64EncondedString
|
||||
self.header = header?.base64EncondedString
|
||||
self.locked = locked
|
||||
self.source = source
|
||||
self.fieldsAttributes = fieldsAttributes
|
||||
}
|
||||
}
|
||||
|
||||
public struct AccountInfoQuery: GetQuery {
|
||||
|
||||
public let id: String
|
||||
|
||||
var queryItems: [URLQueryItem]? { nil }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -142,8 +142,13 @@ extension Mastodon.API {
|
|||
timeoutInterval: Mastodon.API.timeoutInterval
|
||||
)
|
||||
request.httpMethod = method.rawValue
|
||||
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = query?.body
|
||||
if let contentType = query?.contentType {
|
||||
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
if let body = query?.body {
|
||||
request.httpBody = body
|
||||
request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length")
|
||||
}
|
||||
if let authorization = authorization {
|
||||
request.setValue(
|
||||
"Bearer \(authorization.accessToken)",
|
||||
|
|
|
@ -23,7 +23,7 @@ extension Mastodon.Entity {
|
|||
public let id: ID
|
||||
public let type: Type
|
||||
public let url: String
|
||||
public let previewURL: String
|
||||
public let previewURL: String? // could be nil when attachement is audio
|
||||
|
||||
public let remoteURL: String?
|
||||
public let textURL: String?
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
//
|
||||
// Data.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-8.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Data {
|
||||
|
||||
static func multipart(
|
||||
boundary: String = Multipart.boundary,
|
||||
key: String,
|
||||
value: MultipartFormValue
|
||||
) -> Data {
|
||||
var data = Data()
|
||||
data.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
|
||||
data.append("Content-Disposition: form-data; name=\"\(key)\"".data(using: .utf8)!)
|
||||
if let filename = value.multipartFilename {
|
||||
data.append("; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
|
||||
} else {
|
||||
data.append("\r\n".data(using: .utf8)!)
|
||||
}
|
||||
if let contentType = value.multipartContentType {
|
||||
data.append("Content-Type: \(contentType)\r\n".data(using: .utf8)!)
|
||||
}
|
||||
data.append("\r\n".data(using: .utf8)!)
|
||||
data.append(value.multipartValue)
|
||||
return data
|
||||
}
|
||||
|
||||
static func multipartEnd(boundary: String = Multipart.boundary) -> Data {
|
||||
return "\r\n--\(boundary)--\r\n".data(using: .utf8)!
|
||||
}
|
||||
|
||||
}
|
|
@ -12,4 +12,5 @@ public enum Mastodon {
|
|||
public enum Response { }
|
||||
public enum API { }
|
||||
public enum Entity { }
|
||||
public enum Query { }
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// Mastodon+Entity+MediaAttachment.swift
|
||||
// MediaAttachment.swift
|
||||
//
|
||||
//
|
||||
// Created by jk234ert on 2/9/21.
|
||||
|
@ -7,7 +7,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
extension Mastodon.Entity {
|
||||
extension Mastodon.Query {
|
||||
public enum MediaAttachment {
|
||||
/// JPEG (Joint Photographic Experts Group) image
|
||||
case jpeg(Data?)
|
||||
|
@ -20,7 +20,7 @@ extension Mastodon.Entity {
|
|||
}
|
||||
}
|
||||
|
||||
extension Mastodon.Entity.MediaAttachment {
|
||||
extension Mastodon.Query.MediaAttachment {
|
||||
var data: Data? {
|
||||
switch self {
|
||||
case .jpeg(let data): return data
|
||||
|
@ -31,11 +31,12 @@ extension Mastodon.Entity.MediaAttachment {
|
|||
}
|
||||
|
||||
var fileName: String {
|
||||
let name = UUID().uuidString
|
||||
switch self {
|
||||
case .jpeg: return "file.jpg"
|
||||
case .gif: return "file.gif"
|
||||
case .png: return "file.png"
|
||||
case .other(_, let fileExtension, _): return "file.\(fileExtension)"
|
||||
case .jpeg: return "\(name).jpg"
|
||||
case .gif: return "\(name).gif"
|
||||
case .png: return "\(name).png"
|
||||
case .other(_, let fileExtension, _): return "\(name).\(fileExtension)"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -53,3 +54,8 @@ extension Mastodon.Entity.MediaAttachment {
|
|||
}
|
||||
}
|
||||
|
||||
extension Mastodon.Query.MediaAttachment: MultipartFormValue {
|
||||
var multipartValue: Data { return data ?? Data() }
|
||||
var multipartContentType: String? { return mimeType }
|
||||
var multipartFilename: String? { return fileName }
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
//
|
||||
// MultipartFormValue.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-8.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum Multipart {
|
||||
static let boundary = "__boundary__"
|
||||
}
|
||||
|
||||
protocol MultipartFormValue {
|
||||
var multipartValue: Data { get }
|
||||
var multipartContentType: String? { get }
|
||||
var multipartFilename: String? { get }
|
||||
}
|
||||
|
||||
extension Bool: MultipartFormValue {
|
||||
var multipartValue: Data {
|
||||
switch self {
|
||||
case true: return "true".data(using: .utf8)!
|
||||
case false: return "false".data(using: .utf8)!
|
||||
}
|
||||
}
|
||||
var multipartContentType: String? { return nil }
|
||||
var multipartFilename: String? { return nil }
|
||||
}
|
||||
|
||||
extension String: MultipartFormValue {
|
||||
var multipartValue: Data {
|
||||
return self.data(using: .utf8)!
|
||||
}
|
||||
var multipartContentType: String? { return nil }
|
||||
var multipartFilename: String? { return nil }
|
||||
}
|
|
@ -14,12 +14,22 @@ enum RequestMethod: String {
|
|||
protocol RequestQuery {
|
||||
// All kinds of queries could have queryItems and body
|
||||
var queryItems: [URLQueryItem]? { get }
|
||||
var contentType: String? { get }
|
||||
var body: Data? { get }
|
||||
}
|
||||
|
||||
extension RequestQuery {
|
||||
static func multipartContentType(boundary: String = Multipart.boundary) -> String {
|
||||
return "multipart/form-data; charset=utf-8; boundary=\"\(boundary)\""
|
||||
}
|
||||
}
|
||||
|
||||
// An `Encodable` query provides its body by encoding itself
|
||||
// A `Get` query only contains queryItems, it should not be `Encodable`
|
||||
extension RequestQuery where Self: Encodable {
|
||||
var contentType: String? {
|
||||
return "application/json; charset=utf-8"
|
||||
}
|
||||
var body: Data? {
|
||||
return try? Mastodon.API.encoder.encode(self)
|
||||
}
|
||||
|
@ -30,18 +40,20 @@ protocol GetQuery: RequestQuery { }
|
|||
extension GetQuery {
|
||||
// By default a `GetQuery` does not has data body
|
||||
var body: Data? { nil }
|
||||
var contentType: String? { nil }
|
||||
}
|
||||
|
||||
protocol PostQuery: RequestQuery & Encodable { }
|
||||
protocol PostQuery: RequestQuery { }
|
||||
|
||||
extension PostQuery {
|
||||
// By default a `GetQuery` does not has query items
|
||||
// By default a `PostQuery` does not has query items
|
||||
var queryItems: [URLQueryItem]? { nil }
|
||||
}
|
||||
|
||||
protocol PatchQuery: RequestQuery & Encodable { }
|
||||
protocol PatchQuery: RequestQuery { }
|
||||
|
||||
extension PatchQuery {
|
||||
// By default a `GetQuery` does not has query items
|
||||
// By default a `PatchQuery` does not has query items
|
||||
var queryItems: [URLQueryItem]? { nil }
|
||||
}
|
||||
|
|
@ -8,9 +8,11 @@
|
|||
import os.log
|
||||
import XCTest
|
||||
import Combine
|
||||
import UIKit
|
||||
@testable import MastodonSDK
|
||||
|
||||
extension MastodonSDKTests {
|
||||
|
||||
func testVerifyCredentials() throws {
|
||||
let theExpectation = expectation(description: "Verify Account Credentials")
|
||||
|
||||
|
@ -44,11 +46,14 @@ extension MastodonSDKTests {
|
|||
.flatMap({ (result) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
|
||||
|
||||
// TODO: replace with test account acct
|
||||
XCTAssertEqual(result.value.acct, "")
|
||||
XCTAssert(!result.value.acct.isEmpty)
|
||||
theExpectation1.fulfill()
|
||||
|
||||
var query = Mastodon.API.Account.UpdateCredentialQuery()
|
||||
query.note = dateString
|
||||
|
||||
let query = Mastodon.API.Account.UpdateCredentialQuery(
|
||||
bot: !(result.value.bot ?? false),
|
||||
note: dateString,
|
||||
header: Mastodon.Query.MediaAttachment.jpeg(UIImage(systemName: "house")!.jpegData(compressionQuality: 0.8))
|
||||
)
|
||||
return Mastodon.API.Account.updateCredentials(session: self.session, domain: self.domain, query: query, authorization: authorization)
|
||||
})
|
||||
.sink { completion in
|
||||
|
@ -73,8 +78,7 @@ extension MastodonSDKTests {
|
|||
func testRetrieveAccountInfo() throws {
|
||||
let theExpectation = expectation(description: "Verify Account Credentials")
|
||||
|
||||
let query = Mastodon.API.Account.AccountInfoQuery(id: "1")
|
||||
Mastodon.API.Account.accountInfo(session: session, domain: domain, query: query, authorization: nil)
|
||||
Mastodon.API.Account.accountInfo(session: session, domain: "mastodon.online", userID: "1", authorization: nil)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { completion in
|
||||
switch completion {
|
||||
|
@ -91,4 +95,5 @@ extension MastodonSDKTests {
|
|||
|
||||
wait(for: [theExpectation], timeout: 5.0)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue