diff --git a/Data Sources/CompositionAttachmentsDataSource.swift b/Data Sources/CompositionAttachmentsDataSource.swift index 5050e63..64daa40 100644 --- a/Data Sources/CompositionAttachmentsDataSource.swift +++ b/Data Sources/CompositionAttachmentsDataSource.swift @@ -8,7 +8,8 @@ final class CompositionAttachmentsDataSource: UICollectionViewDiffableDataSource private let updateQueue = DispatchQueue(label: "com.metabolist.metatext.composition-attachments-data-source.update-queue") - init(collectionView: UICollectionView, viewModelProvider: @escaping (IndexPath) -> CompositionAttachmentViewModel) { + init(collectionView: UICollectionView, + viewModelProvider: @escaping (IndexPath) -> CompositionAttachmentViewModel?) { let registration = UICollectionView.CellRegistration { $0.viewModel = $2 diff --git a/Data Sources/NewStatusDataSource.swift b/Data Sources/NewStatusDataSource.swift deleted file mode 100644 index f145e36..0000000 --- a/Data Sources/NewStatusDataSource.swift +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import UIKit -import ViewModels - -final class NewStatusDataSource: UICollectionViewDiffableDataSource { - private let updateQueue = - DispatchQueue(label: "com.metabolist.metatext.new-status-data-source.update-queue") - - init(collectionView: UICollectionView, viewModelProvider: @escaping (IndexPath) -> CompositionViewModel) { - let registration = UICollectionView.CellRegistration { - $0.viewModel = $2 - } - - super.init(collectionView: collectionView) { collectionView, indexPath, _ in - collectionView.dequeueConfiguredReusableCell( - using: registration, - for: indexPath, - item: viewModelProvider(indexPath)) - } - } - - override func apply(_ snapshot: NSDiffableDataSourceSnapshot, - animatingDifferences: Bool = true, - completion: (() -> Void)? = nil) { - updateQueue.async { - super.apply(snapshot, animatingDifferences: animatingDifferences, completion: completion) - } - } -} diff --git a/Extensions/Status+Extensions.swift b/Extensions/Status+Extensions.swift new file mode 100644 index 0000000..799ec10 --- /dev/null +++ b/Extensions/Status+Extensions.swift @@ -0,0 +1,51 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Mastodon + +extension Status.Visibility { + var systemImageName: String { + switch self { + case .public: + return "network" + case .unlisted: + return "lock.open" + case .private: + return "lock" + case .direct: + return "envelope" + case .unknown: + return "questionmark" + } + } + + var title: String? { + switch self { + case .public: + return NSLocalizedString("status.visibility.public", comment: "") + case .unlisted: + return NSLocalizedString("status.visibility.unlisted", comment: "") + case .private: + return NSLocalizedString("status.visibility.private", comment: "") + case .direct: + return NSLocalizedString("status.visibility.direct", comment: "") + case .unknown: + return nil + } + } + + var description: String? { + switch self { + case .public: + return NSLocalizedString("status.visibility.public.description", comment: "") + case .unlisted: + return NSLocalizedString("status.visibility.unlisted.description", comment: "") + case .private: + return NSLocalizedString("status.visibility.private.description", comment: "") + case .direct: + return NSLocalizedString("status.visibility.direct.description", comment: "") + case .unknown: + return nil + } + } +} diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index e84e13c..6be78ad 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -138,6 +138,7 @@ "report.forward-%@" = "Forward report to %@"; "share-extension-error.no-account-found" = "No account found"; "status.bookmark" = "Bookmark"; +"status.content-warning-abbreviation" = "CW"; "status.reblogged-by" = "%@ boosted"; "status.pinned-post" = "Pinned post"; "status.show-more" = "Show More"; @@ -146,10 +147,16 @@ "status.poll.time-left" = "%@ left"; "status.poll.refresh" = "Refresh"; "status.poll.closed" = "Closed"; +"status.spoiler-text-placeholder" = "Write your warning here"; "status.unbookmark" = "Unbookmark"; "status.visibility.public" = "Public"; "status.visibility.unlisted" = "Unlisted"; -"status.visibility.private" = "Private"; +"status.visibility.private" = "Followers-only"; +"status.visibility.direct" = "Direct"; +"status.visibility.public.description" = "Visible for all, shown in public timelines"; +"status.visibility.unlisted.description" = "Visible for all, but not in public timelines"; +"status.visibility.private.description" = "Visible for followers only"; +"status.visibility.direct.description" = "Visible for mentioned users only"; "submit" = "Submit"; "timelines.home" = "Home"; "timelines.local" = "Local"; diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusEndpoint.swift index 6abc9a9..f555c65 100644 --- a/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusEndpoint.swift +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusEndpoint.swift @@ -17,12 +17,21 @@ public extension StatusEndpoint { struct Components { public let inReplyToId: Status.Id? public let text: String + public let spoilerText: String public let mediaIds: [Attachment.Id] + public let visibility: Status.Visibility - public init(inReplyToId: Status.Id?, text: String, mediaIds: [Attachment.Id]) { + public init( + inReplyToId: Status.Id?, + text: String, + spoilerText: String, + mediaIds: [Attachment.Id], + visibility: Status.Visibility) { self.inReplyToId = inReplyToId self.text = text + self.spoilerText = spoilerText self.mediaIds = mediaIds + self.visibility = visibility } } } @@ -35,11 +44,16 @@ extension StatusEndpoint.Components { params["status"] = text } + if !spoilerText.isEmpty { + params["spoiler_text"] = spoilerText + } + if !mediaIds.isEmpty { params["media_ids"] = mediaIds } params["in_reply_to_id"] = inReplyToId + params["visibility"] = visibility.rawValue return params } diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index f9b6f9c..9e84660 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -19,12 +19,15 @@ D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */; }; D01F41E424F8889700D55A2D /* StatusAttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41E224F8889700D55A2D /* StatusAttachmentsView.swift */; }; D02E1F95250B13210071AD56 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02E1F94250B13210071AD56 /* SafariView.swift */; }; + D036768E2593E6DE005DF15A /* Status+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */; }; D036AA02254B6101009094DF /* NotificationListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D036AA01254B6101009094DF /* NotificationListCell.swift */; }; D036AA07254B6118009094DF /* NotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D036AA06254B6118009094DF /* NotificationView.swift */; }; D036AA0C254B612B009094DF /* NotificationContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D036AA0B254B612B009094DF /* NotificationContentConfiguration.swift */; }; D036AA17254CA824009094DF /* StatusBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D036AA16254CA823009094DF /* StatusBodyView.swift */; }; + D038273C259EA38F00056E0F /* NewStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FCC10F259C4F20000B67DF /* NewStatusView.swift */; }; D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03B1B29253818F3008F964B /* MediaPreferencesView.swift */; }; D04226FD2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04226FC2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift */; }; + D04F9E8E259E9C950081B0C9 /* ViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D04F9E8D259E9C950081B0C9 /* ViewModels */; }; D0625E59250F092900502611 /* StatusListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E58250F092900502611 /* StatusListCell.swift */; }; D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */; }; D065965B25899DAE0096AC5D /* CompositionAttachmentsDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D065965A25899DAE0096AC5D /* CompositionAttachmentsDataSource.swift */; }; @@ -37,6 +40,7 @@ D0804134258D902900AD6139 /* CompositionAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0804132258D902900AD6139 /* CompositionAttachmentView.swift */; }; D080413E258D904400AD6139 /* CompositionAttachmentContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D080413D258D904400AD6139 /* CompositionAttachmentContentConfiguration.swift */; }; D080413F258D904400AD6139 /* CompositionAttachmentContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D080413D258D904400AD6139 /* CompositionAttachmentContentConfiguration.swift */; }; + D0849C7F25903C4900A5EBCC /* Status+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */; }; D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */; }; D08B8D42253F92B600B1EBEF /* ImagePageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */; }; D08B8D4A253FC36500B1EBEF /* ImageNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */; }; @@ -50,18 +54,10 @@ D08E512125786A6600FA2C5F /* UIButton+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E512025786A6600FA2C5F /* UIButton+Extensions.swift */; }; D08E52612579D2E100FA2C5F /* DomainBlocksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52602579D2E100FA2C5F /* DomainBlocksView.swift */; }; D08E5276257C36CA00FA2C5F /* Share Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D08E526C257C36CA00FA2C5F /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - D08E5292257C53B600FA2C5F /* NewStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E5291257C53B600FA2C5F /* NewStatusViewController.swift */; }; - D08E529C257C58D600FA2C5F /* NewStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E529B257C58D600FA2C5F /* NewStatusView.swift */; }; D08E52A6257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52A5257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift */; }; - D08E52B8257C62D500FA2C5F /* ViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D08E52B7257C62D500FA2C5F /* ViewModels */; }; - D08E52BD257C635800FA2C5F /* NewStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E5291257C53B600FA2C5F /* NewStatusViewController.swift */; }; D08E52C7257C7AEE00FA2C5F /* ShareErrorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52C6257C7AEE00FA2C5F /* ShareErrorViewController.swift */; }; D08E52CC257C80E300FA2C5F /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D0C7D45724F76169001EBDBB /* Localizable.strings */; }; D08E52D2257C811200FA2C5F /* ShareExtensionError+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52D1257C811200FA2C5F /* ShareExtensionError+Extensions.swift */; }; - D08E52DC257D742B00FA2C5F /* CompositionListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52DB257D742B00FA2C5F /* CompositionListCell.swift */; }; - D08E52DD257D742B00FA2C5F /* CompositionListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52DB257D742B00FA2C5F /* CompositionListCell.swift */; }; - D08E52E3257D747400FA2C5F /* CompositionContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52E2257D747400FA2C5F /* CompositionContentConfiguration.swift */; }; - D08E52E4257D747400FA2C5F /* CompositionContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52E2257D747400FA2C5F /* CompositionContentConfiguration.swift */; }; D08E52EE257D757100FA2C5F /* CompositionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52ED257D757100FA2C5F /* CompositionView.swift */; }; D08E52EF257D757100FA2C5F /* CompositionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52ED257D757100FA2C5F /* CompositionView.swift */; }; D08E52F8257D78BE00FA2C5F /* ViewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EA59472522B8B600804347 /* ViewConstants.swift */; }; @@ -119,12 +115,13 @@ D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B125251A90F400942152 /* AccountListCell.swift */; }; D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B12D251A97E400942152 /* TableViewController.swift */; }; D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B135251AA12700942152 /* CollectionItem+Extensions.swift */; }; - D0F2D4D1257EE84400986197 /* NewStatusDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F2D4D0257EE84400986197 /* NewStatusDataSource.swift */; }; - D0F2D4D6257EED6100986197 /* NewStatusDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F2D4D0257EE84400986197 /* NewStatusDataSource.swift */; }; D0F2D4DB257F018300986197 /* Array+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01C6FAB252024BD003D0300 /* Array+Extensions.swift */; }; D0F2D54025818C4B00986197 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = D0F2D53F25818C4B00986197 /* Kingfisher */; }; D0F2D5452581ABAB00986197 /* KingfisherOptionsInfo+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */; }; D0F2D54B2581CF7D00986197 /* VisualEffectBlur.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F2D54A2581CF7D00986197 /* VisualEffectBlur.swift */; }; + D0FCC105259C4E61000B67DF /* NewStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FCC104259C4E61000B67DF /* NewStatusViewController.swift */; }; + D0FCC106259C4E62000B67DF /* NewStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FCC104259C4E61000B67DF /* NewStatusViewController.swift */; }; + D0FCC110259C4F20000B67DF /* NewStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FCC10F259C4F20000B67DF /* NewStatusView.swift */; }; D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FE1C8E253686F9003EF1EB /* PlayerView.swift */; }; D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FE1C9725368A9D003EF1EB /* PlayerCache.swift */; }; /* End PBXBuildFile section */ @@ -197,6 +194,7 @@ D06BC5E525202AD90079541D /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; D0804132258D902900AD6139 /* CompositionAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachmentView.swift; sourceTree = ""; }; D080413D258D904400AD6139 /* CompositionAttachmentContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachmentContentConfiguration.swift; sourceTree = ""; }; + D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Status+Extensions.swift"; sourceTree = ""; }; D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = ""; }; D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = ""; }; D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePageViewController.swift; sourceTree = ""; }; @@ -213,13 +211,9 @@ D08E526C257C36CA00FA2C5F /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; D08E5273257C36CA00FA2C5F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D08E5277257C36CB00FA2C5F /* Share Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Share Extension.entitlements"; sourceTree = ""; }; - D08E5291257C53B600FA2C5F /* NewStatusViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewStatusViewController.swift; sourceTree = ""; }; - D08E529B257C58D600FA2C5F /* NewStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewStatusView.swift; sourceTree = ""; }; D08E52A5257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareExtensionNavigationViewController.swift; sourceTree = ""; }; D08E52C6257C7AEE00FA2C5F /* ShareErrorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareErrorViewController.swift; sourceTree = ""; }; D08E52D1257C811200FA2C5F /* ShareExtensionError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ShareExtensionError+Extensions.swift"; sourceTree = ""; }; - D08E52DB257D742B00FA2C5F /* CompositionListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionListCell.swift; sourceTree = ""; }; - D08E52E2257D747400FA2C5F /* CompositionContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionContentConfiguration.swift; sourceTree = ""; }; D08E52ED257D757100FA2C5F /* CompositionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionView.swift; sourceTree = ""; }; D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = ""; }; D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportStatusView.swift; sourceTree = ""; }; @@ -280,8 +274,9 @@ D0F0B125251A90F400942152 /* AccountListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListCell.swift; sourceTree = ""; }; D0F0B12D251A97E400942152 /* TableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewController.swift; sourceTree = ""; }; D0F0B135251AA12700942152 /* CollectionItem+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionItem+Extensions.swift"; sourceTree = ""; }; - D0F2D4D0257EE84400986197 /* NewStatusDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewStatusDataSource.swift; sourceTree = ""; }; D0F2D54A2581CF7D00986197 /* VisualEffectBlur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectBlur.swift; sourceTree = ""; }; + D0FCC104259C4E61000B67DF /* NewStatusViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewStatusViewController.swift; sourceTree = ""; }; + D0FCC10F259C4F20000B67DF /* NewStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewStatusView.swift; sourceTree = ""; }; D0FE1C8E253686F9003EF1EB /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = ""; }; D0FE1C9725368A9D003EF1EB /* PlayerCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerCache.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -309,7 +304,7 @@ buildActionMask = 2147483647; files = ( D0F2D54025818C4B00986197 /* Kingfisher in Frameworks */, - D08E52B8257C62D500FA2C5F /* ViewModels in Frameworks */, + D04F9E8E259E9C950081B0C9 /* ViewModels in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -424,7 +419,6 @@ isa = PBXGroup; children = ( D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */, - D0F2D4D0257EE84400986197 /* NewStatusDataSource.swift */, D065965A25899DAE0096AC5D /* CompositionAttachmentsDataSource.swift */, ); path = "Data Sources"; @@ -452,9 +446,7 @@ D065966025899E890096AC5D /* CompositionAttachmentCollectionViewCell.swift */, D080413D258D904400AD6139 /* CompositionAttachmentContentConfiguration.swift */, D0804132258D902900AD6139 /* CompositionAttachmentView.swift */, - D08E52E2257D747400FA2C5F /* CompositionContentConfiguration.swift */, D0E9F9A9258450B300EF503D /* CompositionInputAccessoryView.swift */, - D08E52DB257D742B00FA2C5F /* CompositionListCell.swift */, D08E52ED257D757100FA2C5F /* CompositionView.swift */, D007023D25562A2800F38136 /* ConversationAvatarsView.swift */, D00702352555F4C500F38136 /* ConversationContentConfiguration.swift */, @@ -471,7 +463,7 @@ D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.swift */, D0E569DA2529319100FA1D72 /* LoadMoreView.swift */, D03B1B29253818F3008F964B /* MediaPreferencesView.swift */, - D08E529B257C58D600FA2C5F /* NewStatusView.swift */, + D0FCC10F259C4F20000B67DF /* NewStatusView.swift */, D036AA0B254B612B009094DF /* NotificationContentConfiguration.swift */, D036AA01254B6101009094DF /* NotificationListCell.swift */, D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */, @@ -505,7 +497,7 @@ D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */, D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */, D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */, - D08E5291257C53B600FA2C5F /* NewStatusViewController.swift */, + D0FCC104259C4E61000B67DF /* NewStatusViewController.swift */, D06BC5E525202AD90079541D /* ProfileViewController.swift */, D0F0B12D251A97E400942152 /* TableViewController.swift */, ); @@ -538,6 +530,7 @@ D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */, D0C7D46B24F76169001EBDBB /* NSMutableAttributedString+Extensions.swift */, D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */, + D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */, D0C7D46A24F76169001EBDBB /* String+Extensions.swift */, D08E512025786A6600FA2C5F /* UIButton+Extensions.swift */, D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */, @@ -629,8 +622,8 @@ ); name = "Share Extension"; packageProductDependencies = ( - D08E52B7257C62D500FA2C5F /* ViewModels */, D0F2D53F25818C4B00986197 /* Kingfisher */, + D04F9E8D259E9C950081B0C9 /* ViewModels */, ); productName = "Share Extension"; productReference = D08E526C257C36CA00FA2C5F /* Share Extension.appex */; @@ -769,7 +762,6 @@ files = ( D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */, D02E1F95250B13210071AD56 /* SafariView.swift in Sources */, - D0F2D4D1257EE84400986197 /* NewStatusDataSource.swift in Sources */, D00702292555E51200F38136 /* ConversationListCell.swift in Sources */, D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */, D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */, @@ -781,7 +773,6 @@ D08E52EE257D757100FA2C5F /* CompositionView.swift in Sources */, D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */, D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */, - D08E52E3257D747400FA2C5F /* CompositionContentConfiguration.swift in Sources */, D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */, D007023E25562A2800F38136 /* ConversationAvatarsView.swift in Sources */, D0E7AD3925870B13005F5E2D /* UIVIewController+Extensions.swift in Sources */, @@ -789,7 +780,6 @@ D0BEB1F324F8EE8C001B0F04 /* StatusAttachmentView.swift in Sources */, D0C7D49A24F7616A001EBDBB /* TableView.swift in Sources */, D08B8D622540DE3B00B1EBEF /* ZoomTransitionController.swift in Sources */, - D08E529C257C58D600FA2C5F /* NewStatusView.swift in Sources */, D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */, D0DD50CB256B1F24004A04F7 /* ReportView.swift in Sources */, D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */, @@ -800,15 +790,14 @@ D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */, D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */, D0E9F9AA258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */, + D0849C7F25903C4900A5EBCC /* Status+Extensions.swift in Sources */, D080413E258D904400AD6139 /* CompositionAttachmentContentConfiguration.swift in Sources */, D0625E59250F092900502611 /* StatusListCell.swift in Sources */, D0E569DB2529319100FA1D72 /* LoadMoreView.swift in Sources */, D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */, D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */, - D08E52DC257D742B00FA2C5F /* CompositionListCell.swift in Sources */, D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */, D08B8D602540DE3B00B1EBEF /* ZoomAnimator.swift in Sources */, - D08E5292257C53B600FA2C5F /* NewStatusViewController.swift in Sources */, D08B8D672540DEB200B1EBEF /* ZoomAnimatableView.swift in Sources */, D08B8D822544D80000B1EBEF /* PollOptionButton.swift in Sources */, D0C7D4DA24F7616A001EBDBB /* View+Extensions.swift in Sources */, @@ -847,11 +836,13 @@ D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */, D0C7D4C424F7616A001EBDBB /* AppDelegate.swift in Sources */, D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */, + D0FCC105259C4E61000B67DF /* NewStatusViewController.swift in Sources */, D0F2D54B2581CF7D00986197 /* VisualEffectBlur.swift in Sources */, D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */, D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */, D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */, D0BEB20524FA1107001B0F04 /* FiltersView.swift in Sources */, + D0FCC110259C4F20000B67DF /* NewStatusView.swift in Sources */, D0C7D49B24F7616A001EBDBB /* PreferencesView.swift in Sources */, D0C7D4D724F7616A001EBDBB /* UIColor+Extensions.swift in Sources */, ); @@ -869,23 +860,22 @@ buildActionMask = 2147483647; files = ( D08E52A6257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift in Sources */, - D08E52BD257C635800FA2C5F /* NewStatusViewController.swift in Sources */, D08E52D2257C811200FA2C5F /* ShareExtensionError+Extensions.swift in Sources */, D0E9F9AB258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */, D0F2D4DB257F018300986197 /* Array+Extensions.swift in Sources */, - D08E52DD257D742B00FA2C5F /* CompositionListCell.swift in Sources */, D0E7AD4225870C79005F5E2D /* UIVIewController+Extensions.swift in Sources */, + D038273C259EA38F00056E0F /* NewStatusView.swift in Sources */, D08E52EF257D757100FA2C5F /* CompositionView.swift in Sources */, D0CE9F88258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */, + D036768E2593E6DE005DF15A /* Status+Extensions.swift in Sources */, D080413F258D904400AD6139 /* CompositionAttachmentContentConfiguration.swift in Sources */, D0F2D5452581ABAB00986197 /* KingfisherOptionsInfo+Extensions.swift in Sources */, D08E52C7257C7AEE00FA2C5F /* ShareErrorViewController.swift in Sources */, D08E52F8257D78BE00FA2C5F /* ViewConstants.swift in Sources */, - D0F2D4D6257EED6100986197 /* NewStatusDataSource.swift in Sources */, D065966725899E910096AC5D /* CompositionAttachmentsDataSource.swift in Sources */, + D0FCC106259C4E62000B67DF /* NewStatusViewController.swift in Sources */, D0804134258D902900AD6139 /* CompositionAttachmentView.swift in Sources */, D08E52FD257D78CB00FA2C5F /* UIColor+Extensions.swift in Sources */, - D08E52E4257D747400FA2C5F /* CompositionContentConfiguration.swift in Sources */, D065966225899E890096AC5D /* CompositionAttachmentCollectionViewCell.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1293,15 +1283,15 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + D04F9E8D259E9C950081B0C9 /* ViewModels */ = { + isa = XCSwiftPackageProductDependency; + productName = ViewModels; + }; D06B492224D4611300642749 /* KingfisherSwiftUI */ = { isa = XCSwiftPackageProductDependency; package = D06B492124D4611300642749 /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = KingfisherSwiftUI; }; - D08E52B7257C62D500FA2C5F /* ViewModels */ = { - isa = XCSwiftPackageProductDependency; - productName = ViewModels; - }; D0BECB972501C0FC002C1B13 /* Secrets */ = { isa = XCSwiftPackageProductDependency; productName = Secrets; diff --git a/Share Extension/ShareExtensionNavigationViewController.swift b/Share Extension/ShareExtensionNavigationViewController.swift index 293a709..e8f093b 100644 --- a/Share Extension/ShareExtensionNavigationViewController.swift +++ b/Share Extension/ShareExtensionNavigationViewController.swift @@ -2,7 +2,7 @@ import Combine import ServiceLayer -import UIKit +import SwiftUI import ViewModels @objc(ShareExtensionNavigationViewController) @@ -25,7 +25,7 @@ class ShareExtensionNavigationViewController: UINavigationController { } setViewControllers( - [NewStatusViewController(viewModel: newStatusViewModel, isShareExtension: true)], + [UIHostingController(rootView: NewStatusView { newStatusViewModel })], animated: false) } diff --git a/View Controllers/NewStatusViewController.swift b/View Controllers/NewStatusViewController.swift index 3ec57e3..5de4216 100644 --- a/View Controllers/NewStatusViewController.swift +++ b/View Controllers/NewStatusViewController.swift @@ -6,29 +6,22 @@ import PhotosUI import UIKit import ViewModels -class NewStatusViewController: UICollectionViewController { +final class NewStatusViewController: UIViewController { private let viewModel: NewStatusViewModel - private let isShareExtension: Bool + private let scrollView = UIScrollView() + private let stackView = UIStackView() private let postButton = UIBarButtonItem( title: NSLocalizedString("post", comment: ""), style: .done, target: nil, action: nil) - private var attachMediaTo: CompositionViewModel? + private let mediaSelections = PassthroughSubject<[PHPickerResult], Never>() private var cancellables = Set() - private lazy var dataSource: NewStatusDataSource = { - .init(collectionView: collectionView, viewModelProvider: viewModel.viewModel(indexPath:)) - }() - - init(viewModel: NewStatusViewModel, isShareExtension: Bool) { + init(viewModel: NewStatusViewModel) { self.viewModel = viewModel - self.isShareExtension = isShareExtension - let configuration = UICollectionLayoutListConfiguration(appearance: .plain) - let layout = UICollectionViewCompositionalLayout.list(using: configuration) - - super.init(collectionViewLayout: layout) + super.init(nibName: nil, bundle: nil) } @available(*, unavailable) @@ -39,15 +32,101 @@ class NewStatusViewController: UICollectionViewController { override func viewDidLoad() { super.viewDidLoad() - collectionView.dataSource = dataSource - view.backgroundColor = .systemBackground + view.addSubview(scrollView) + scrollView.translatesAutoresizingMaskIntoConstraints = false + + scrollView.addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.distribution = .equalSpacing + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + stackView.topAnchor.constraint(equalTo: scrollView.topAnchor), + stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + stackView.widthAnchor.constraint(equalTo: scrollView.widthAnchor) + ]) + postButton.primaryAction = UIAction(title: NSLocalizedString("post", comment: "")) { [weak self] _ in self?.viewModel.post() } + setupViewModelBindings() + } + + override func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + setupBarButtonItems(identification: viewModel.identification) + } +} + +extension NewStatusViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + mediaSelections.send(results) + dismiss(animated: true) + } +} + +private extension NewStatusViewController { + func handle(event: NewStatusViewModel.Event) { + switch event { + case let .presentMediaPicker(compositionViewModel): + presentMediaPicker(compositionViewModel: compositionViewModel) + } + } + + func dismiss() { + if let extensionContext = extensionContext { + extensionContext.completeRequest(returningItems: nil) + } else { + presentingViewController?.dismiss(animated: true) + } + } + + func setupViewModelBindings() { + viewModel.events.sink { [weak self] in self?.handle(event: $0) }.store(in: &cancellables) + + viewModel.$canPost.sink { [weak self] in self?.postButton.isEnabled = $0 }.store(in: &cancellables) + + viewModel.$compositionViewModels.sink { [weak self] in + guard let self = self else { return } + + let diff = [$0.map(\.id)].snapshot().itemIdentifiers.difference( + from: [self.stackView.arrangedSubviews.compactMap { ($0 as? CompositionView)?.id }] + .snapshot().itemIdentifiers) + + for insertion in diff.insertions { + guard case let .insert(index, id, _) = insertion, + let compositionViewModel = $0.first(where: { $0.id == id }) + else { continue } + + let compositionView = CompositionView( + viewModel: compositionViewModel, + parentViewModel: self.viewModel) + self.stackView.insertArrangedSubview(compositionView, at: index) + compositionView.textView.becomeFirstResponder() + DispatchQueue.main.async { + self.scrollView.scrollRectToVisible( + self.scrollView.convert(compositionView.frame, from: self.stackView), + animated: true) + } + } + + for removal in diff.removals { + guard case let .remove(_, id, _) = removal else { continue } + + self.stackView.arrangedSubviews.first { ($0 as? CompositionView)?.id == id }?.removeFromSuperview() + } + } + .store(in: &cancellables) viewModel.$identification .sink { [weak self] in @@ -57,33 +136,6 @@ class NewStatusViewController: UICollectionViewController { } .store(in: &cancellables) - viewModel.$compositionViewModels.sink { [weak self] in - guard let self = self else { return } - - let oldSnapshot = self.dataSource.snapshot() - let newSnapshot = [$0.map(\.id)].snapshot() - let diff = newSnapshot.itemIdentifiers.difference(from: oldSnapshot.itemIdentifiers) - - self.dataSource.apply(newSnapshot) { - if case let .insert(_, id, _) = diff.insertions.first, - let indexPath = self.dataSource.indexPath(for: id) { - self.collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .top) - } - } - } - .store(in: &cancellables) - - // Invalidate the collection view layout on anything that could change the height of a cell - viewModel.$compositionViewModels - .flatMap { Publishers.MergeMany($0.map(\.objectWillChange)) } - .receive(on: DispatchQueue.main) - .sink { [weak self] in self?.collectionView.collectionViewLayout.invalidateLayout() } - .store(in: &cancellables) - - viewModel.$canPost.sink { [weak self] in self?.postButton.isEnabled = $0 }.store(in: &cancellables) - - viewModel.events.sink { [weak self] in self?.handle(event: $0) }.store(in: &cancellables) - viewModel.$alertItem .compactMap { $0 } .receive(on: DispatchQueue.main) @@ -91,46 +143,37 @@ class NewStatusViewController: UICollectionViewController { .store(in: &cancellables) } - override func didMove(toParent parent: UIViewController?) { - super.didMove(toParent: parent) - - setupBarButtonItems(identification: viewModel.identification) - } - func setupBarButtonItems(identification: Identification) { - let target = isShareExtension ? self : parent let closeButton = UIBarButtonItem( systemItem: .close, primaryAction: UIAction { [weak self] _ in self?.dismiss() }) - target?.navigationItem.leftBarButtonItem = closeButton - target?.navigationItem.titleView = viewModel.canChangeIdentity + parent?.navigationItem.leftBarButtonItem = closeButton + parent?.navigationItem.titleView = viewModel.canChangeIdentity ? changeIdentityButton(identification: identification) : nil - target?.navigationItem.rightBarButtonItem = postButton + parent?.navigationItem.rightBarButtonItem = postButton } - func dismiss() { - if isShareExtension { - extensionContext?.completeRequest(returningItems: nil) - } else { - presentingViewController?.dismiss(animated: true) + func presentMediaPicker(compositionViewModel: CompositionViewModel) { + mediaSelections.first().sink { [weak self] results in + guard let self = self, let result = results.first else { return } + + self.viewModel.attach(itemProvider: result.itemProvider, to: compositionViewModel) } + .store(in: &cancellables) + + var configuration = PHPickerConfiguration() + + configuration.preferredAssetRepresentationMode = .current + + let picker = PHPickerViewController(configuration: configuration) + + picker.modalPresentationStyle = .overFullScreen + picker.delegate = self + present(picker, animated: true) } -} -extension NewStatusViewController: PHPickerViewControllerDelegate { - func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { - dismiss(animated: true) - - guard let result = results.first else { return } - - attachMediaTo?.attach(itemProvider: result.itemProvider) - attachMediaTo = nil - } -} - -private extension NewStatusViewController { func changeIdentityButton(identification: Identification) -> UIButton { let changeIdentityButton = UIButton() let downsampled = KingfisherOptionsInfo.downsampled( @@ -168,22 +211,4 @@ private extension NewStatusViewController { return changeIdentityButton } - - func handle(event: CompositionViewModel.Event) { - switch event { - case let .presentMediaPicker(compositionViewModel): - attachMediaTo = compositionViewModel - - var configuration = PHPickerConfiguration() - - configuration.preferredAssetRepresentationMode = .current - - let picker = PHPickerViewController(configuration: configuration) - - picker.delegate = self - present(picker, animated: true) - default: - break - } - } } diff --git a/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift b/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift index 3166815..f517cd1 100644 --- a/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift +++ b/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift @@ -90,8 +90,4 @@ public extension DomainBlocksViewModel { static let preview = DomainBlocksViewModel(service: .init(mastodonAPIClient: .preview)) } -public extension NewStatusViewModel { - static let preview = RootViewModel.preview.newStatusViewModel(identification: .preview) -} - // swiftlint:enable force_try diff --git a/ViewModels/Sources/ViewModels/CompositionViewModel.swift b/ViewModels/Sources/ViewModels/CompositionViewModel.swift index c6a32ed..09e4691 100644 --- a/ViewModels/Sources/ViewModels/CompositionViewModel.swift +++ b/ViewModels/Sources/ViewModels/CompositionViewModel.swift @@ -5,25 +5,26 @@ import Foundation import Mastodon import ServiceLayer -public final class CompositionViewModel: ObservableObject { +public final class CompositionViewModel: ObservableObject, Identifiable { public let id = Id() public var isPosted = false @Published public var text = "" + @Published public var contentWarning = "" + @Published public var displayContentWarning = false @Published public private(set) var attachmentViewModels = [CompositionAttachmentViewModel]() @Published public private(set) var isPostable = false - @Published public private(set) var identification: Identification @Published public private(set) var attachmentUpload: AttachmentUpload? - private let eventsSubject: PassthroughSubject private var cancellables = Set() - init(identification: Identification, - identificationPublisher: AnyPublisher, - eventsSubject: PassthroughSubject) { - self.identification = identification - self.eventsSubject = eventsSubject - identificationPublisher.assign(to: &$identification) - $text.map { !$0.isEmpty }.removeDuplicates().assign(to: &$isPostable) + init() { + $text.map { !$0.isEmpty } + .removeDuplicates() + .combineLatest($attachmentViewModels.map { !$0.isEmpty }) + .map { textPresent, attachmentPresent in + textPresent || attachmentPresent + } + .assign(to: &$isPostable) } } @@ -36,49 +37,41 @@ public extension CompositionViewModel { case error(Error) } - func components(inReplyToId: Status.Id?) -> StatusComponents { + func components(inReplyToId: Status.Id?, visibility: Status.Visibility) -> StatusComponents { StatusComponents( inReplyToId: inReplyToId, text: text, - mediaIds: attachmentViewModels.map(\.attachment.id)) - } - - func presentMediaPicker() { - eventsSubject.send(.presentMediaPicker(self)) - } - - func insert() { - eventsSubject.send(.insertAfter(self)) - } - - func attach(itemProvider: NSItemProvider) { - let progress = Progress(totalUnitCount: 1) - - MediaProcessingService.dataAndMimeType(itemProvider: itemProvider) - .flatMap { [weak self] data, mimeType -> AnyPublisher in - guard let self = self else { return Empty().eraseToAnyPublisher() } - - DispatchQueue.main.async { - self.attachmentUpload = AttachmentUpload(progress: progress, data: data, mimeType: mimeType) - } - - return self.identification.service.uploadAttachment(data: data, mimeType: mimeType, progress: progress) - } - .print() - .receive(on: DispatchQueue.main) - .sink { [weak self] in - self?.attachmentUpload = nil - - if case let .failure(error) = $0 { - self?.eventsSubject.send(.error(error)) - } - } receiveValue: { [weak self] in - self?.attachmentViewModels.append(CompositionAttachmentViewModel(attachment: $0)) - } - .store(in: &cancellables) + spoilerText: displayContentWarning ? contentWarning : "", + mediaIds: attachmentViewModels.map(\.attachment.id), + visibility: visibility) } func attachmentViewModel(indexPath: IndexPath) -> CompositionAttachmentViewModel { attachmentViewModels[indexPath.item] } } + +extension CompositionViewModel { + func attach(itemProvider: NSItemProvider, service: IdentityService) -> AnyPublisher { + MediaProcessingService.dataAndMimeType(itemProvider: itemProvider) + .flatMap { [weak self] data, mimeType -> AnyPublisher in + guard let self = self else { return Empty().eraseToAnyPublisher() } + + let progress = Progress(totalUnitCount: 1) + + DispatchQueue.main.async { + self.attachmentUpload = AttachmentUpload(progress: progress, data: data, mimeType: mimeType) + } + + return service.uploadAttachment(data: data, mimeType: mimeType, progress: progress) + } + .receive(on: DispatchQueue.main) + .handleEvents( + receiveOutput: { [weak self] in + self?.attachmentViewModels.append(CompositionAttachmentViewModel(attachment: $0)) + }, + receiveCompletion: { [weak self] _ in self?.attachmentUpload = nil }) + .ignoreOutput() + .eraseToAnyPublisher() + } +} diff --git a/ViewModels/Sources/ViewModels/NewStatusViewModel.swift b/ViewModels/Sources/ViewModels/NewStatusViewModel.swift index badf110..b75eee7 100644 --- a/ViewModels/Sources/ViewModels/NewStatusViewModel.swift +++ b/ViewModels/Sources/ViewModels/NewStatusViewModel.swift @@ -6,18 +6,19 @@ import Mastodon import ServiceLayer public final class NewStatusViewModel: ObservableObject { - @Published public private(set) var compositionViewModels = [CompositionViewModel]() + @Published public var visibility: Status.Visibility + @Published public private(set) var compositionViewModels = [CompositionViewModel()] @Published public private(set) var identification: Identification @Published public private(set) var authenticatedIdentities = [Identity]() @Published public var canPost = false @Published public var canChangeIdentity = true @Published public var alertItem: AlertItem? @Published public private(set) var loading = false - public let events: AnyPublisher + public let events: AnyPublisher private let allIdentitiesService: AllIdentitiesService private let environment: AppEnvironment - private let eventsSubject = PassthroughSubject() + private let eventsSubject = PassthroughSubject() private let itemEventsSubject = PassthroughSubject() private var cancellables = Set() @@ -28,8 +29,7 @@ public final class NewStatusViewModel: ObservableObject { self.identification = identification self.environment = environment events = eventsSubject.eraseToAnyPublisher() - compositionViewModels = [newCompositionViewModel()] - itemEventsSubject.sink { [weak self] in self?.handle(event: $0) }.store(in: &cancellables) + visibility = identification.identity.preferences.postingDefaultVisibility allIdentitiesService.authenticatedIdentitiesPublisher() .assignErrorsToAlertItem(to: \.alertItem, on: self) .assign(to: &$authenticatedIdentities) @@ -43,8 +43,8 @@ public final class NewStatusViewModel: ObservableObject { } public extension NewStatusViewModel { - func viewModel(indexPath: IndexPath) -> CompositionViewModel { - compositionViewModels[indexPath.row] + enum Event { + case presentMediaPicker(CompositionViewModel) } func setIdentity(_ identity: Identity) { @@ -66,6 +66,34 @@ public extension NewStatusViewModel { environment: environment) } + func presentMediaPicker(viewModel: CompositionViewModel) { + eventsSubject.send(.presentMediaPicker(viewModel)) + } + + func insert(after: CompositionViewModel) { + guard let index = compositionViewModels.firstIndex(where: { $0 === after }) + else { return } + + let newViewModel = CompositionViewModel() + + newViewModel.contentWarning = after.contentWarning + newViewModel.displayContentWarning = after.displayContentWarning + + if index >= compositionViewModels.count - 1 { + compositionViewModels.append(newViewModel) + } else { + compositionViewModels.insert(newViewModel, at: index + 1) + } + } + + func attach(itemProvider: NSItemProvider, to compositionViewModel: CompositionViewModel) { + compositionViewModel.attach(itemProvider: itemProvider, service: identification.service) + .receive(on: DispatchQueue.main) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .sink { _ in } + .store(in: &cancellables) + } + func post() { guard let unposted = compositionViewModels.first(where: { !$0.isPosted }) else { return } @@ -74,35 +102,11 @@ public extension NewStatusViewModel { } private extension NewStatusViewModel { - func newCompositionViewModel() -> CompositionViewModel { - CompositionViewModel( - identification: identification, - identificationPublisher: $identification.eraseToAnyPublisher(), - eventsSubject: itemEventsSubject) - } - - func handle(event: CompositionViewModel.Event) { - switch event { - case let .insertAfter(viewModel): - guard let index = compositionViewModels.firstIndex(where: { $0 === viewModel }) else { return } - - let newViewModel = newCompositionViewModel() - - if index >= compositionViewModels.count - 1 { - compositionViewModels.append(newViewModel) - } else { - compositionViewModels.insert(newViewModel, at: index + 1) - } - case let .error(error): - alertItem = AlertItem(error: error) - default: - eventsSubject.send(event) - } - } - func post(viewModel: CompositionViewModel, inReplyToId: Status.Id?) { loading = true - identification.service.post(statusComponents: viewModel.components(inReplyToId: inReplyToId)) + identification.service.post(statusComponents: viewModel.components( + inReplyToId: inReplyToId, + visibility: visibility)) .receive(on: DispatchQueue.main) .sink { [weak self] in guard let self = self else { return } diff --git a/Views/CompositionContentConfiguration.swift b/Views/CompositionContentConfiguration.swift deleted file mode 100644 index 41ca270..0000000 --- a/Views/CompositionContentConfiguration.swift +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import UIKit -import ViewModels - -struct CompositionContentConfiguration { - let viewModel: CompositionViewModel -} - -extension CompositionContentConfiguration: UIContentConfiguration { - func makeContentView() -> UIView & UIContentView { - CompositionView(configuration: self) - } - - func updated(for state: UIConfigurationState) -> CompositionContentConfiguration { - self - } -} diff --git a/Views/CompositionInputAccessoryView.swift b/Views/CompositionInputAccessoryView.swift index 89feedf..b1f53a9 100644 --- a/Views/CompositionInputAccessoryView.swift +++ b/Views/CompositionInputAccessoryView.swift @@ -1,16 +1,23 @@ // Copyright © 2020 Metabolist. All rights reserved. import Combine +import Mastodon import UIKit import ViewModels -class CompositionInputAccessoryView: UIView { - private let stackView = UIStackView() +final class CompositionInputAccessoryView: UIView { + let visibilityButton = UIButton() + let addButton = UIButton() + let contentWarningButton = UIButton(type: .system) + private let viewModel: CompositionViewModel + private let parentViewModel: NewStatusViewModel + private let stackView = UIStackView() private var cancellables = Set() - init(viewModel: CompositionViewModel) { + init(viewModel: CompositionViewModel, parentViewModel: NewStatusViewModel) { self.viewModel = viewModel + self.parentViewModel = parentViewModel super.init(frame: .zero) @@ -28,6 +35,7 @@ class CompositionInputAccessoryView: UIView { } private extension CompositionInputAccessoryView { + // swiftlint:disable:next function_body_length func initialSetup() { autoresizingMask = .flexibleHeight backgroundColor = .secondarySystemFill @@ -44,7 +52,12 @@ private extension CompositionInputAccessoryView { systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(scale: .medium)), for: .normal) - mediaButton.addAction(UIAction { [weak self] _ in self?.viewModel.presentMediaPicker() }, for: .touchUpInside) + mediaButton.addAction(UIAction { [weak self] _ in + guard let self = self else { return } + + self.parentViewModel.presentMediaPicker(viewModel: self.viewModel) + }, + for: .touchUpInside) let pollButton = UIButton() @@ -55,9 +68,26 @@ private extension CompositionInputAccessoryView { withConfiguration: UIImage.SymbolConfiguration(scale: .medium)), for: .normal) - stackView.addArrangedSubview(UIView()) + stackView.addArrangedSubview(visibilityButton) + visibilityButton.showsMenuAsPrimaryAction = true + visibilityButton.menu = UIMenu(children: Status.Visibility.allCasesExceptUnknown.reversed().map { visibility in + UIAction( + title: visibility.title ?? "", + image: UIImage(systemName: visibility.systemImageName), + discoverabilityTitle: visibility.description) { [weak self] _ in + self?.parentViewModel.visibility = visibility + } + }) - let addButton = UIButton() + stackView.addArrangedSubview(contentWarningButton) + contentWarningButton.setTitle( + NSLocalizedString("status.content-warning-abbreviation", comment: ""), + for: .normal) + contentWarningButton.addAction( + UIAction { [weak self] _ in self?.viewModel.displayContentWarning.toggle() }, + for: .touchUpInside) + + stackView.addArrangedSubview(UIView()) stackView.addArrangedSubview(addButton) addButton.setImage( @@ -65,20 +95,32 @@ private extension CompositionInputAccessoryView { systemName: "plus.circle.fill", withConfiguration: UIImage.SymbolConfiguration(scale: .medium)), for: .normal) - addButton.addAction(UIAction { [weak self] _ in self?.viewModel.insert() }, for: .touchUpInside) + addButton.addAction(UIAction { [weak self] _ in + guard let self = self else { return } - for button in [mediaButton, pollButton, addButton] { + self.parentViewModel.insert(after: self.viewModel) + }, for: .touchUpInside) + + viewModel.$isPostable + .sink { [weak self] in self?.addButton.isEnabled = $0 } + .store(in: &cancellables) + + parentViewModel.$visibility + .sink { [weak self] in + self?.visibilityButton.setImage(UIImage(systemName: $0.systemImageName), for: .normal) + } + .store(in: &cancellables) + + for button in [mediaButton, pollButton, visibilityButton, contentWarningButton, addButton] { button.heightAnchor.constraint(greaterThanOrEqualToConstant: .minimumButtonDimension).isActive = true button.widthAnchor.constraint(greaterThanOrEqualToConstant: .minimumButtonDimension).isActive = true } NSLayoutConstraint.activate([ - stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor), stackView.topAnchor.constraint(equalTo: topAnchor), - stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + stackView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor), stackView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) - - viewModel.$isPostable.sink { addButton.isEnabled = $0 }.store(in: &cancellables) } } diff --git a/Views/CompositionListCell.swift b/Views/CompositionListCell.swift deleted file mode 100644 index 63e94cb..0000000 --- a/Views/CompositionListCell.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import UIKit -import ViewModels - -class CompositionListCell: UICollectionViewListCell { - var viewModel: CompositionViewModel? - - override func updateConfiguration(using state: UICellConfigurationState) { - guard let viewModel = viewModel else { return } - - contentConfiguration = CompositionContentConfiguration(viewModel: viewModel).updated(for: state) - backgroundConfiguration = UIBackgroundConfiguration.clear().updated(for: state) - } - - override var isSelected: Bool { - didSet { - if isSelected { - (contentView as? CompositionView)?.textView.becomeFirstResponder() - } - } - } - - override func updateConstraints() { - super.updateConstraints() - - separatorLayoutGuide.trailingAnchor.constraint(equalTo: separatorLayoutGuide.leadingAnchor).isActive = true - } -} diff --git a/Views/CompositionView.swift b/Views/CompositionView.swift index 0938cd3..8a4b301 100644 --- a/Views/CompositionView.swift +++ b/Views/CompositionView.swift @@ -3,24 +3,29 @@ import Combine import Kingfisher import UIKit +import ViewModels final class CompositionView: UIView { let avatarImageView = UIImageView() + let spoilerTextField = UITextField() let textView = UITextView() let attachmentUploadView = AttachmentUploadView() let attachmentsCollectionView: UICollectionView - private var compositionConfiguration: CompositionContentConfiguration + private let viewModel: CompositionViewModel + private let parentViewModel: NewStatusViewModel private var cancellables = Set() private lazy var attachmentsDataSource: CompositionAttachmentsDataSource = { CompositionAttachmentsDataSource( - collectionView: attachmentsCollectionView, - viewModelProvider: compositionConfiguration.viewModel.attachmentViewModel(indexPath:)) + collectionView: attachmentsCollectionView) { [weak self] in + self?.viewModel.attachmentViewModel(indexPath: $0) + } }() - init(configuration: CompositionContentConfiguration) { - self.compositionConfiguration = configuration + init(viewModel: CompositionViewModel, parentViewModel: NewStatusViewModel) { + self.viewModel = viewModel + self.parentViewModel = parentViewModel let itemSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(0.2), @@ -42,7 +47,6 @@ final class CompositionView: UIView { super.init(frame: .zero) initialSetup() - applyCompositionConfiguration() } @available(*, unavailable) @@ -51,93 +55,124 @@ final class CompositionView: UIView { } } -extension CompositionView: UIContentView { - var configuration: UIContentConfiguration { - get { compositionConfiguration } - set { - guard let compositionConfiguration = newValue as? CompositionContentConfiguration else { return } - - self.compositionConfiguration = compositionConfiguration - - applyCompositionConfiguration() - } - } +extension CompositionView { + var id: CompositionViewModel.Id { viewModel.id } } extension CompositionView: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { - compositionConfiguration.viewModel.text = textView.text + viewModel.text = textView.text } } private extension CompositionView { static let attachmentUploadViewHeight: CGFloat = 100 + // swiftlint:disable:next function_body_length func initialSetup() { + tag = viewModel.id.hashValue + addSubview(avatarImageView) avatarImageView.translatesAutoresizingMaskIntoConstraints = false avatarImageView.layer.cornerRadius = .avatarDimension / 2 avatarImageView.clipsToBounds = true let stackView = UIStackView() + let inputAccessoryView = CompositionInputAccessoryView(viewModel: viewModel, parentViewModel: parentViewModel) addSubview(stackView) stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .vertical + stackView.spacing = .defaultSpacing + + stackView.addArrangedSubview(spoilerTextField) + spoilerTextField.backgroundColor = .secondarySystemBackground + spoilerTextField.layer.cornerRadius = .defaultCornerRadius + spoilerTextField.adjustsFontForContentSizeCategory = true + spoilerTextField.font = .preferredFont(forTextStyle: .body) + spoilerTextField.placeholder = NSLocalizedString("status.spoiler-text-placeholder", comment: "") + spoilerTextField.inputAccessoryView = inputAccessoryView + spoilerTextField.addAction( + UIAction { [weak self] _ in + guard let self = self, let text = self.spoilerTextField.text else { return } + + self.viewModel.contentWarning = text + }, + for: .editingChanged) stackView.addArrangedSubview(textView) + textView.backgroundColor = .secondarySystemBackground + textView.layer.cornerRadius = .defaultCornerRadius textView.isScrollEnabled = false textView.adjustsFontForContentSizeCategory = true textView.font = .preferredFont(forTextStyle: .body) - textView.textContainer.lineFragmentPadding = 0 - textView.inputAccessoryView = CompositionInputAccessoryView(viewModel: compositionConfiguration.viewModel) +// textView.textContainer.lineFragmentPadding = 0 + textView.inputAccessoryView = inputAccessoryView textView.inputAccessoryView?.sizeToFit() textView.delegate = self + textView.setContentHuggingPriority(.required, for: .vertical) stackView.addArrangedSubview(attachmentsCollectionView) attachmentsCollectionView.dataSource = attachmentsDataSource stackView.addArrangedSubview(attachmentUploadView) - let constraints = [ - avatarImageView.heightAnchor.constraint(equalToConstant: .avatarDimension), - avatarImageView.widthAnchor.constraint(equalToConstant: .avatarDimension), - avatarImageView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor), - avatarImageView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), - avatarImageView.bottomAnchor.constraint(lessThanOrEqualTo: readableContentGuide.bottomAnchor), - stackView.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: .defaultSpacing), - stackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor), - stackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), - stackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor), - attachmentsCollectionView.heightAnchor.constraint( - equalTo: attachmentsCollectionView.widthAnchor, - multiplier: 1 / 4), - attachmentUploadView.heightAnchor.constraint(equalToConstant: Self.attachmentUploadViewHeight) - ] + textView.text = viewModel.text + spoilerTextField.text = viewModel.contentWarning - for constraint in constraints { - constraint.priority = .justBelowMax - } + viewModel.$displayContentWarning + .sink { [weak self] in + guard let self = self else { return } - NSLayoutConstraint.activate(constraints) - } + if self.spoilerTextField.isHidden && self.textView.isFirstResponder && $0 { + self.spoilerTextField.becomeFirstResponder() + } else if !self.spoilerTextField.isHidden && self.spoilerTextField.isFirstResponder && !$0 { + self.textView.becomeFirstResponder() + } - func applyCompositionConfiguration() { - cancellables.removeAll() + self.spoilerTextField.isHidden = !$0 + } + .store(in: &cancellables) - compositionConfiguration.viewModel.$identification.map(\.identity.image) + parentViewModel.$identification.map(\.identity.image) .sink { [weak self] in self?.avatarImageView.kf.setImage(with: $0) } .store(in: &cancellables) - compositionConfiguration.viewModel.$attachmentViewModels + viewModel.$attachmentViewModels + .receive(on: DispatchQueue.main) // hack to punt to next run loop, consider refactoring .sink { [weak self] in self?.attachmentsDataSource.apply([$0.map(\.attachment)].snapshot()) self?.attachmentsCollectionView.isHidden = $0.isEmpty } .store(in: &cancellables) - compositionConfiguration.viewModel.$attachmentUpload + viewModel.$attachmentUpload .sink { [weak self] in self?.attachmentUploadView.attachmentUpload = $0 } .store(in: &cancellables) + + let guide = UIDevice.current.userInterfaceIdiom == .pad ? readableContentGuide : layoutMarginsGuide + let constraints = [ + avatarImageView.heightAnchor.constraint(equalToConstant: .avatarDimension), + avatarImageView.widthAnchor.constraint(equalToConstant: .avatarDimension), + avatarImageView.topAnchor.constraint(equalTo: guide.topAnchor), + avatarImageView.leadingAnchor.constraint(equalTo: guide.leadingAnchor), + avatarImageView.bottomAnchor.constraint(lessThanOrEqualTo: guide.bottomAnchor), + stackView.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: .defaultSpacing), + stackView.topAnchor.constraint(equalTo: guide.topAnchor), + stackView.trailingAnchor.constraint(equalTo: guide.trailingAnchor), + stackView.bottomAnchor.constraint(lessThanOrEqualTo: guide.bottomAnchor), + attachmentsCollectionView.heightAnchor.constraint( + equalTo: attachmentsCollectionView.widthAnchor, + multiplier: 1 / 4), + attachmentUploadView.heightAnchor.constraint(equalToConstant: Self.attachmentUploadViewHeight) + ] + + if UIDevice.current.userInterfaceIdiom == .pad { + for constraint in constraints { + constraint.priority = .justBelowMax + } + } + + NSLayoutConstraint.activate(constraints) } } diff --git a/Views/NewStatusView.swift b/Views/NewStatusView.swift index 1a09cc0..20de5f2 100644 --- a/Views/NewStatusView.swift +++ b/Views/NewStatusView.swift @@ -7,16 +7,10 @@ struct NewStatusView: UIViewControllerRepresentable { let viewModelClosure: () -> NewStatusViewModel func makeUIViewController(context: Context) -> NewStatusViewController { - NewStatusViewController(viewModel: viewModelClosure(), isShareExtension: false) + NewStatusViewController(viewModel: viewModelClosure()) } func updateUIViewController(_ uiViewController: NewStatusViewController, context: Context) { } } - -struct NewStatusView_Previews: PreviewProvider { - static var previews: some View { - NewStatusView { .preview } - } -} diff --git a/Views/TabNavigationView.swift b/Views/TabNavigationView.swift index 78e00a3..9b0a4f2 100644 --- a/Views/TabNavigationView.swift +++ b/Views/TabNavigationView.swift @@ -41,10 +41,7 @@ struct TabNavigationView: View { EmptyView() .fullScreenCover(isPresented: $viewModel.presentingNewStatus) { NavigationView { - NewStatusView { - rootViewModel.newStatusViewModel(identification: viewModel.identification) - } - .edgesIgnoringSafeArea(.all) + NewStatusView { rootViewModel.newStatusViewModel(identification: viewModel.identification) } .navigationBarTitleDisplayMode(.inline) } .navigationViewStyle(StackNavigationViewStyle())