Merge pull request #727 from j-f1/status-cards

Support cards in status views
This commit is contained in:
Marcus Kida 2022-12-15 14:28:22 +01:00 committed by GitHub
commit c37b8b5e74
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1466 additions and 151 deletions

View File

@ -78,6 +78,7 @@
"sign_up": "Create account",
"see_more": "See More",
"preview": "Preview",
"copy": "Copy",
"share": "Share",
"share_user": "Share %s",
"share_post": "Share Post",
@ -132,6 +133,8 @@
"sensitive_content": "Sensitive Content",
"media_content_warning": "Tap anywhere to reveal",
"tap_to_reveal": "Tap to reveal",
"load_embed": "Load Embed",
"link_via_user": "%s via %s",
"poll": {
"vote": "Vote",
"closed": "Closed"
@ -153,6 +156,7 @@
"show_image": "Show image",
"show_gif": "Show GIF",
"show_video_player": "Show video player",
"share_link_in_post": "Share Link in Post",
"tap_then_hold_to_show_menu": "Tap then hold to show menu"
},
"tag": {

View File

@ -83,6 +83,7 @@
"sign_up": "Create account",
"see_more": "See More",
"preview": "Preview",
"copy": "Copy",
"share": "Share",
"share_user": "Share %s",
"share_post": "Share Post",
@ -141,6 +142,8 @@
"sensitive_content": "Sensitive Content",
"media_content_warning": "Tap anywhere to reveal",
"tap_to_reveal": "Tap to reveal",
"load_embed": "Load Embed",
"link_via_user": "%s via %s",
"poll": {
"vote": "Vote",
"closed": "Closed"
@ -162,6 +165,7 @@
"show_image": "Show image",
"show_gif": "Show GIF",
"show_video_player": "Show video player",
"share_link_in_post": "Share Link in Post",
"tap_then_hold_to_show_menu": "Tap then hold to show menu"
},
"tag": {

View File

@ -23,6 +23,7 @@
0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33725E6401400AAD544 /* PickServerCell.swift */; };
164F0EBC267D4FE400249499 /* BoopSound.caf in Resources */ = {isa = PBXBuildFile; fileRef = 164F0EBB267D4FE400249499 /* BoopSound.caf */; };
18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; };
27D701F5292FC2D60031BCBB /* DataSourceFacade+URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27D701F4292FC2D60031BCBB /* DataSourceFacade+URL.swift */; };
2A1FE47C2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1FE47B2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift */; };
2A1FE47E2938C11200784BF1 /* Collection+IsNotEmpty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1FE47D2938C11200784BF1 /* Collection+IsNotEmpty.swift */; };
2A3F6FE3292ECB5E002E6DA7 /* FollowedTagsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */; };
@ -96,6 +97,8 @@
62FD27D12893707600B205C5 /* BookmarkViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FD27D02893707600B205C5 /* BookmarkViewController.swift */; };
62FD27D32893707B00B205C5 /* BookmarkViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FD27D22893707B00B205C5 /* BookmarkViewController+DataSourceProvider.swift */; };
62FD27D52893708A00B205C5 /* BookmarkViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FD27D42893708A00B205C5 /* BookmarkViewModel+Diffable.swift */; };
85904C02293BC0EB0011C817 /* ImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85904C01293BC0EB0011C817 /* ImageProvider.swift */; };
85904C04293BC1940011C817 /* URLActivityItemWithMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85904C03293BC1940011C817 /* URLActivityItemWithMetadata.swift */; };
87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; };
C24C97032922F30500BAE8CB /* RefreshControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24C97022922F30500BAE8CB /* RefreshControl.swift */; };
D87BFC8B291D5C6B00FEE264 /* MastodonLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87BFC8A291D5C6B00FEE264 /* MastodonLoginView.swift */; };
@ -528,6 +531,7 @@
0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = "<group>"; };
164F0EBB267D4FE400249499 /* BoopSound.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = BoopSound.caf; sourceTree = "<group>"; };
1D6D967E77A5357E2C6110D9 /* Pods-Mastodon.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.asdk - debug.xcconfig"; sourceTree = "<group>"; };
27D701F4292FC2D60031BCBB /* DataSourceFacade+URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+URL.swift"; sourceTree = "<group>"; };
2A1FE47B2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowedTagsViewModel+DiffableDataSource.swift"; sourceTree = "<group>"; };
2A1FE47D2938C11200784BF1 /* Collection+IsNotEmpty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+IsNotEmpty.swift"; sourceTree = "<group>"; };
2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewModel.swift; sourceTree = "<group>"; };
@ -616,6 +620,8 @@
7CB58D292DA7ACEF179A9050 /* Pods-Mastodon.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.profile.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.profile.xcconfig"; sourceTree = "<group>"; };
7CEFFAE9AF9284B13C0A758D /* Pods-MastodonTests.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.asdk - debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.asdk - debug.xcconfig"; sourceTree = "<group>"; };
819CEC9DCAD8E8E7BD85A7BB /* Pods-Mastodon.asdk.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.asdk.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.asdk.xcconfig"; sourceTree = "<group>"; };
85904C01293BC0EB0011C817 /* ImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProvider.swift; sourceTree = "<group>"; };
85904C03293BC1940011C817 /* URLActivityItemWithMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLActivityItemWithMetadata.swift; sourceTree = "<group>"; };
8850E70A1D5FF51432E43653 /* Pods-Mastodon-MastodonUITests.asdk - release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.asdk - release.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.asdk - release.xcconfig"; sourceTree = "<group>"; };
8E79CCBE51FBC3F7FE8CF49F /* Pods-MastodonTests.release snapshot.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release snapshot.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release snapshot.xcconfig"; sourceTree = "<group>"; };
8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.debug.xcconfig"; sourceTree = "<group>"; };
@ -2118,6 +2124,7 @@
DB63F778279ABF9C00455B82 /* DataSourceFacade+Reblog.swift */,
DB63F77A279ACAE500455B82 /* DataSourceFacade+Favorite.swift */,
DB0FCB67279507EF006C02E2 /* DataSourceFacade+Meta.swift */,
27D701F4292FC2D60031BCBB /* DataSourceFacade+URL.swift */,
DB0FCB79279576A2006C02E2 /* DataSourceFacade+Thread.swift */,
DB63F74627990B0600455B82 /* DataSourceFacade+Hashtag.swift */,
DB63F7532799491600455B82 /* DataSourceFacade+SearchHistory.swift */,
@ -2549,6 +2556,8 @@
isa = PBXGroup;
children = (
DBF3B7402733EB9400E21627 /* MastodonLocalCode.swift */,
85904C01293BC0EB0011C817 /* ImageProvider.swift */,
85904C03293BC1940011C817 /* URLActivityItemWithMetadata.swift */,
);
path = Helper;
sourceTree = "<group>";
@ -3174,6 +3183,7 @@
DB03A793272A7E5700EE37C5 /* SidebarListHeaderView.swift in Sources */,
DB4FFC2B269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift in Sources */,
DB5B7298273112C800081888 /* FollowingListViewModel.swift in Sources */,
27D701F5292FC2D60031BCBB /* DataSourceFacade+URL.swift in Sources */,
0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */,
DB5B54AE2833C15F00DEF8B2 /* UserListViewModel+Diffable.swift in Sources */,
DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */,
@ -3200,6 +3210,7 @@
62FD27D52893708A00B205C5 /* BookmarkViewModel+Diffable.swift in Sources */,
DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */,
2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */,
85904C04293BC1940011C817 /* URLActivityItemWithMetadata.swift in Sources */,
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */,
DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */,
DBEFCD80282A2AA900C0ABEA /* ReportServerRulesViewModel.swift in Sources */,
@ -3340,6 +3351,7 @@
DB1D843026566512000346B3 /* KeyboardPreference.swift in Sources */,
DB852D1926FAEB6B00FC9D81 /* SidebarViewController.swift in Sources */,
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */,
85904C02293BC0EB0011C817 /* ImageProvider.swift in Sources */,
DBDFF1932805554900557A48 /* DiscoveryPostsViewModel.swift in Sources */,
DB3E6FE72806A7A200B035AE /* DiscoveryItem.swift in Sources */,
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */,

View File

@ -0,0 +1,39 @@
//
// ImageProvider.swift
// Mastodon
//
// Created by Jed Fox on 2022-12-03.
//
import Foundation
import AlamofireImage
import UniformTypeIdentifiers
import UIKit
class ImageProvider: NSObject, NSItemProviderWriting {
let url: URL
let filter: ImageFilter?
init(url: URL, filter: ImageFilter? = nil) {
self.url = url
self.filter = filter
}
var itemProvider: NSItemProvider {
NSItemProvider(object: self)
}
static var writableTypeIdentifiersForItemProvider: [String] {
[UTType.png.identifier]
}
func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping @Sendable (Data?, Error?) -> Void) -> Progress? {
let receipt = UIImageView.af.sharedImageDownloader.download(URLRequest(url: url), filter: filter, completion: { response in
switch response.result {
case .failure(let error): completionHandler(nil, error)
case .success(let image): completionHandler(image.pngData(), nil)
}
})
return receipt?.request.downloadProgress
}
}

View File

@ -0,0 +1,33 @@
//
// URLActivityItemWithMetadata.swift
// Mastodon
//
// Created by Jed Fox on 2022-12-03.
//
import UIKit
import LinkPresentation
class URLActivityItemWithMetadata: NSObject, UIActivityItemSource {
init(url: URL, configureMetadata: (LPLinkMetadata) -> Void) {
self.url = url
self.metadata = LPLinkMetadata()
metadata.url = url
configureMetadata(metadata)
}
let url: URL
let metadata: LPLinkMetadata
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
url
}
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
url
}
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
metadata
}
}

View File

@ -48,18 +48,12 @@ extension DataSourceFacade {
assertionFailure()
return
}
let domain = provider.authContext.mastodonAuthenticationBox.domain
if url.host == domain,
url.pathComponents.count >= 4,
url.pathComponents[0] == "/",
url.pathComponents[1] == "web",
url.pathComponents[2] == "statuses" {
let statusID = url.pathComponents[3]
let threadViewModel = RemoteThreadViewModel(context: provider.context, authContext: provider.authContext, statusID: statusID)
_ = await provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show)
} else {
_ = await provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil))
}
await responseToURLAction(
provider: provider,
status: status,
url: url
)
case .hashtag(_, let hashtag, _):
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: provider.context, authContext: provider.authContext, hashtag: hashtag)
_ = await provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: provider, transition: .show)

View File

@ -59,8 +59,18 @@ extension DataSourceFacade {
status: ManagedObjectRecord<Status>
) async throws -> UIActivityViewController {
var activityItems: [Any] = try await dependency.context.managedObjectContext.perform {
guard let status = status.object(in: dependency.context.managedObjectContext) else { return [] }
return [StatusActivityItem(status: status)].compactMap { $0 } as [Any]
guard let status = status.object(in: dependency.context.managedObjectContext),
let url = URL(string: status.url ?? status.uri)
else { return [] }
return [
URLActivityItemWithMetadata(url: url) { metadata in
metadata.title = "\(status.author.displayName) (@\(status.author.acctWithDomain))"
metadata.iconProvider = ImageProvider(
url: status.author.avatarImageURLWithFallback(domain: status.author.domain),
filter: ScaledToSizeFilter(size: CGSize.authorAvatarButtonSize)
).itemProvider
}
] as [Any]
}
var applicationActivities: [UIActivity] = [
SafariActivity(sceneCoordinator: dependency.coordinator), // open URL
@ -77,54 +87,6 @@ extension DataSourceFacade {
)
return activityViewController
}
private class StatusActivityItem: NSObject, UIActivityItemSource {
init?(status: Status) {
guard let url = URL(string: status.url ?? status.uri) else { return nil }
self.url = url
self.metadata = LPLinkMetadata()
metadata.url = url
metadata.title = "\(status.author.displayName) (@\(status.author.acctWithDomain))"
metadata.iconProvider = NSItemProvider(object: IconProvider(url: status.author.avatarImageURLWithFallback(domain: status.author.domain)))
}
let url: URL
let metadata: LPLinkMetadata
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
url
}
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
url
}
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
metadata
}
private class IconProvider: NSObject, NSItemProviderWriting {
let url: URL
init(url: URL) {
self.url = url
}
static var writableTypeIdentifiersForItemProvider: [String] {
[UTType.png.identifier]
}
func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping @Sendable (Data?, Error?) -> Void) -> Progress? {
let filter = ScaledToSizeFilter(size: CGSize.authorAvatarButtonSize)
let receipt = UIImageView.af.sharedImageDownloader.download(URLRequest(url: url), filter: filter, completion: { response in
switch response.result {
case .failure(let error): completionHandler(nil, error)
case .success(let image): completionHandler(image.pngData(), nil)
}
})
return receipt?.request.downloadProgress
}
}
}
}
// ActionToolBar
@ -155,7 +117,7 @@ extension DataSourceFacade {
let composeViewModel = ComposeViewModel(
context: provider.context,
authContext: provider.authContext,
kind: .reply(status: status)
destination: .reply(parent: status)
)
_ = provider.coordinator.present(
scene: .compose(viewModel: composeViewModel),

View File

@ -0,0 +1,32 @@
//
// DataSourceFacade+URL.swift
// Mastodon
//
// Created by Kyle Bashour on 11/24/22.
//
import Foundation
import CoreDataStack
import MetaTextKit
import MastodonCore
extension DataSourceFacade {
static func responseToURLAction(
provider: DataSourceProvider & AuthContextProvider,
status: ManagedObjectRecord<Status>,
url: URL
) async {
let domain = provider.authContext.mastodonAuthenticationBox.domain
if url.host == domain,
url.pathComponents.count >= 4,
url.pathComponents[0] == "/",
url.pathComponents[1] == "web",
url.pathComponents[2] == "statuses" {
let statusID = url.pathComponents[3]
let threadViewModel = RemoteThreadViewModel(context: provider.context, authContext: provider.authContext, statusID: statusID)
_ = await provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show)
} else {
_ = await provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil))
}
}
}

View File

@ -10,6 +10,9 @@ import CoreDataStack
import MetaTextKit
import MastodonCore
import MastodonUI
import MastodonLocalization
import MastodonAsset
import LinkPresentation
// MARK: - header
extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
@ -120,7 +123,127 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
)
}
}
}
// MARK: - card
extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
func tableViewCell(
_ cell: UITableViewCell,
statusView: StatusView,
didTapCardWithURL url: URL
) {
Task {
let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil)
guard let item = await item(from: source) else {
assertionFailure()
return
}
guard case let .status(status) = item else {
assertionFailure("only works for status data provider")
return
}
await DataSourceFacade.responseToURLAction(
provider: self,
status: status,
url: url
)
}
}
func tableViewCell(
_ cell: UITableViewCell,
statusView: StatusView,
cardControl: StatusCardControl,
didTapURL url: URL
) {
Task {
let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil)
guard let item = await item(from: source) else {
assertionFailure()
return
}
guard case let .status(status) = item else {
assertionFailure("only works for status data provider")
return
}
await DataSourceFacade.responseToURLAction(
provider: self,
status: status,
url: url
)
}
}
func tableViewCell(
_ cell: UITableViewCell,
statusView: StatusView,
cardControlMenu statusCardControl: StatusCardControl
) -> UIMenu? {
guard let card = statusView.viewModel.card,
let url = card.url else {
return nil
}
return UIMenu(children: [
UIAction(
title: L10n.Common.Controls.Actions.copy,
image: UIImage(systemName: "doc.on.doc")
) { _ in
UIPasteboard.general.url = url
},
UIAction(
title: L10n.Common.Controls.Actions.share,
image: Asset.Arrow.squareAndArrowUp.image.withRenderingMode(.alwaysTemplate)
) { _ in
DispatchQueue.main.async {
let activityViewController = UIActivityViewController(
activityItems: [
URLActivityItemWithMetadata(url: url) { metadata in
metadata.title = card.title
if let image = card.imageURL {
metadata.iconProvider = ImageProvider(url: image, filter: nil).itemProvider
}
}
],
applicationActivities: []
)
self.coordinator.present(
scene: .activityViewController(
activityViewController: activityViewController,
sourceView: statusCardControl, barButtonItem: nil
),
from: self,
transition: .activityViewControllerPresent(animated: true)
)
}
},
UIAction(
title: L10n.Common.Controls.Status.Actions.shareLinkInPost,
image: Asset.ObjectsAndTools.squareAndPencil.image.withRenderingMode(.alwaysTemplate)
) { _ in
DispatchQueue.main.async {
self.coordinator.present(
scene: .compose(viewModel: ComposeViewModel(
context: self.context,
authContext: self.authContext,
destination: .topLevel,
initialContent: L10n.Common.Controls.Status.linkViaUser(url.absoluteString, "@" + (statusView.viewModel.authorUsername ?? ""))
)),
from: self,
transition: .modal(animated: true)
)
}
}
])
}
}
// MARK: - media

View File

@ -100,7 +100,7 @@ extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvid
let composeViewModel = ComposeViewModel(
context: self.context,
authContext: authContext,
kind: .reply(status: status)
destination: .reply(parent: status)
)
_ = self.coordinator.present(
scene: .compose(viewModel: composeViewModel),

View File

@ -34,7 +34,8 @@ final class ComposeViewController: UIViewController, NeedsDependency {
return ComposeContentViewModel(
context: context,
authContext: viewModel.authContext,
kind: viewModel.kind
destination: viewModel.destination,
initialContent: viewModel.initialContent
)
}()
private(set) lazy var composeContentViewController: ComposeContentViewController = {

View File

@ -29,7 +29,8 @@ final class ComposeViewModel {
// input
let context: AppContext
let authContext: AuthContext
let kind: ComposeContentViewModel.Kind
let destination: ComposeContentViewModel.Destination
let initialContent: String
let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make initial event emit
@ -41,17 +42,19 @@ final class ComposeViewModel {
init(
context: AppContext,
authContext: AuthContext,
kind: ComposeContentViewModel.Kind
destination: ComposeContentViewModel.Destination,
initialContent: String = ""
) {
self.context = context
self.authContext = authContext
self.kind = kind
self.destination = destination
self.initialContent = initialContent
// end init
self.title = {
switch kind {
case .post, .hashtag, .mention: return L10n.Scene.Compose.Title.newPost
case .reply: return L10n.Scene.Compose.Title.newReply
switch destination {
case .topLevel: return L10n.Scene.Compose.Title.newPost
case .reply: return L10n.Scene.Compose.Title.newReply
}
}()
}

View File

@ -206,10 +206,13 @@ extension HashtagTimelineViewController {
@objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let hashtag = "#" + viewModel.hashtag
UITextChecker.learnWord(hashtag)
let composeViewModel = ComposeViewModel(
context: context,
authContext: viewModel.authContext,
kind: .hashtag(hashtag: viewModel.hashtag)
destination: .topLevel,
initialContent: hashtag
)
_ = coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil))
}

View File

@ -550,10 +550,13 @@ extension ProfileViewController {
@objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let mastodonUser = viewModel.user else { return }
let mention = "@" + mastodonUser.acct
UITextChecker.learnWord(mention)
let composeViewModel = ComposeViewModel(
context: context,
authContext: viewModel.authContext,
kind: .mention(user: mastodonUser.asRecord)
destination: .topLevel,
initialContent: mention
)
_ = coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil))
}

View File

@ -379,7 +379,7 @@ extension MainTabBarController {
let composeViewModel = ComposeViewModel(
context: context,
authContext: authContext,
kind: .post
destination: .topLevel
)
_ = coordinator.present(scene: .compose(viewModel: composeViewModel), from: nil, transition: .modal(animated: true, completion: nil))
}
@ -803,7 +803,7 @@ extension MainTabBarController {
let composeViewModel = ComposeViewModel(
context: context,
authContext: authContext,
kind: .post
destination: .topLevel
)
_ = coordinator.present(scene: .compose(viewModel: composeViewModel), from: nil, transition: .modal(animated: true, completion: nil))
}

View File

@ -227,7 +227,7 @@ extension SidebarViewController: UICollectionViewDelegate {
let composeViewModel = ComposeViewModel(
context: context,
authContext: authContext,
kind: .post
destination: .topLevel
)
_ = coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil))
default:

View File

@ -67,6 +67,21 @@ extension StatusTableViewCell {
}
}
.store(in: &disposeBag)
statusView.viewModel.$card
.removeDuplicates()
.dropFirst()
.receive(on: DispatchQueue.main)
.sink { [weak tableView, weak self] _ in
guard let tableView = tableView else { return }
guard let _ = self else { return }
UIView.performWithoutAnimation {
tableView.beginUpdates()
tableView.endUpdates()
}
}
.store(in: &disposeBag)
}
}

View File

@ -27,6 +27,7 @@ protocol StatusTableViewCellDelegate: AnyObject, AutoGenerateProtocolDelegate {
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton)
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, contentSensitiveeToggleButtonDidPressed button: UIButton)
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta)
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, didTapCardWithURL url: URL)
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int)
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath)
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton)
@ -36,6 +37,8 @@ protocol StatusTableViewCellDelegate: AnyObject, AutoGenerateProtocolDelegate {
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton)
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, statusMetricView: StatusMetricView, reblogButtonDidPressed button: UIButton)
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, statusMetricView: StatusMetricView, favoriteButtonDidPressed button: UIButton)
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, cardControl: StatusCardControl, didTapURL url: URL)
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, cardControlMenu: StatusCardControl) -> UIMenu?
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, accessibilityActivate: Void)
// sourcery:end
}
@ -61,6 +64,10 @@ extension StatusViewDelegate where Self: StatusViewContainerTableViewCell {
delegate?.tableViewCell(self, statusView: statusView, metaText: metaText, didSelectMeta: meta)
}
func statusView(_ statusView: StatusView, didTapCardWithURL url: URL) {
delegate?.tableViewCell(self, statusView: statusView, didTapCardWithURL: url)
}
func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int) {
delegate?.tableViewCell(self, statusView: statusView, mediaGridContainerView: mediaGridContainerView, mediaView: mediaView, didSelectMediaViewAt: index)
}
@ -97,6 +104,14 @@ extension StatusViewDelegate where Self: StatusViewContainerTableViewCell {
delegate?.tableViewCell(self, statusView: statusView, statusMetricView: statusMetricView, favoriteButtonDidPressed: button)
}
func statusView(_ statusView: StatusView, cardControl: StatusCardControl, didTapURL url: URL) {
delegate?.tableViewCell(self, statusView: statusView, cardControl: cardControl, didTapURL: url)
}
func statusView(_ statusView: StatusView, cardControlMenu: StatusCardControl) -> UIMenu? {
return delegate?.tableViewCell(self, statusView: statusView, cardControlMenu: cardControlMenu)
}
func statusView(_ statusView: StatusView, accessibilityActivate: Void) {
delegate?.tableViewCell(self, statusView: statusView, accessibilityActivate: accessibilityActivate)
}

View File

@ -117,7 +117,7 @@ extension ThreadViewController {
let composeViewModel = ComposeViewModel(
context: context,
authContext: viewModel.authContext,
kind: .reply(status: threadContext.status)
destination: .reply(parent: threadContext.status)
)
_ = coordinator.present(
scene: .compose(viewModel: composeViewModel),

View File

@ -188,7 +188,7 @@ extension SceneDelegate {
let composeViewModel = ComposeViewModel(
context: AppContext.shared,
authContext: authContext,
kind: .post
destination: .topLevel
)
_ = coordinator?.present(scene: .compose(viewModel: composeViewModel), from: nil, transition: .modal(animated: true, completion: nil))
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): present compose scene")

View File

@ -1,3 +1,17 @@
<%
func methodDeclaration(_ method: SourceryRuntime.Method, newName: String) -> String {
var result = newName
if method.throws {
result = result + " throws"
} else if method.rethrows {
result = result + " rethrows"
}
if method.returnTypeName.isVoid {
return result
}
return result + " -> \(method.returnTypeName)"
}
-%>
<% for type in types.implementing["AutoGenerateProtocolDelegate"] {
guard let replaceOf = type.annotations["replaceOf"] as? String else { continue }
guard let replaceWith = type.annotations["replaceWith"] as? String else { continue }
@ -5,7 +19,7 @@
guard let aProtocol = types.protocols.first(where: { $0.name == protocolToGenerate }) else { continue } -%>
// sourcery:inline:<%= type.name %>.AutoGenerateProtocolDelegate
<% for method in aProtocol.methods { -%>
<%= method.name.replacingOccurrences(of: replaceOf, with: replaceWith) %>
<%= methodDeclaration(method, newName: method.name.replacingOccurrences(of: replaceOf, with: replaceWith)) %>
<% } -%>
// sourcery:end
<% } %>

View File

@ -6,6 +6,9 @@ func methodDeclaration(_ method: SourceryRuntime.Method) -> String {
} else if method.rethrows {
result = result + " rethrows"
}
if method.returnTypeName.isVoid {
return result
}
return result + " -> \(method.returnTypeName)"
}
-%>
@ -42,7 +45,7 @@ func methodCall(
guard let aProtocol = types.protocols.first(where: { $0.name == protocolToGenerate }) else { continue } -%>
// sourcery:inline:<%= type.name %>.AutoGenerateProtocolRelayDelegate
<% for method in aProtocol.methods { -%>
func <%= method.name -%> {
func <%= methodDeclaration(method) -%> {
<%= methodCall(method, replaceOf: replaceOf, replaceWith: replaceWith) %>
}

View File

@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>CoreData 6.xcdatamodel</string>
<string>CoreData 7.xcdatamodel</string>
</dict>
</plist>

View File

@ -0,0 +1,278 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21509" systemVersion="21G217" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Application" representedClassName="CoreDataStack.Application" syncable="YES">
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
<attribute name="vapidKey" optional="YES" attributeType="String"/>
<attribute name="website" optional="YES" attributeType="String"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="application" inverseEntity="Status"/>
</entity>
<entity name="Card" representedClassName="CoreDataStack.Card" syncable="YES">
<attribute name="authorName" optional="YES" attributeType="String"/>
<attribute name="authorURLRaw" optional="YES" attributeType="String"/>
<attribute name="blurhash" optional="YES" attributeType="String"/>
<attribute name="desc" attributeType="String"/>
<attribute name="embedURLRaw" optional="YES" attributeType="String"/>
<attribute name="height" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="html" optional="YES" attributeType="String"/>
<attribute name="image" optional="YES" attributeType="String"/>
<attribute name="providerName" optional="YES" attributeType="String"/>
<attribute name="providerURLRaw" optional="YES" attributeType="String"/>
<attribute name="title" attributeType="String"/>
<attribute name="typeRaw" attributeType="String"/>
<attribute name="urlRaw" attributeType="String"/>
<attribute name="width" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="card" inverseEntity="Status"/>
</entity>
<entity name="DomainBlock" representedClassName="CoreDataStack.DomainBlock" syncable="YES">
<attribute name="blockedDomain" attributeType="String"/>
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="userID" attributeType="String"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="userID"/>
<constraint value="domain"/>
<constraint value="blockedDomain"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="Emoji" representedClassName="CoreDataStack.Emoji" syncable="YES">
<attribute name="category" optional="YES" attributeType="String"/>
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="shortcode" attributeType="String"/>
<attribute name="staticURL" attributeType="String"/>
<attribute name="url" attributeType="String"/>
<attribute name="visibleInPicker" attributeType="Boolean" usesScalarValueType="YES"/>
</entity>
<entity name="Feed" representedClassName="CoreDataStack.Feed" syncable="YES">
<attribute name="acctRaw" optional="YES" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="hasMore" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="isLoadingMore" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="kindRaw" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="notification" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Notification" inverseName="feeds" inverseEntity="Notification"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="feeds" inverseEntity="Status"/>
</entity>
<entity name="Instance" representedClassName="CoreDataStack.Instance" syncable="YES">
<attribute name="configurationRaw" optional="YES" attributeType="Binary"/>
<attribute name="configurationV2Raw" optional="YES" attributeType="Binary"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="version" optional="YES" attributeType="String"/>
<relationship name="authentications" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonAuthentication" inverseName="instance" inverseEntity="MastodonAuthentication"/>
</entity>
<entity name="MastodonAuthentication" representedClassName="CoreDataStack.MastodonAuthentication" syncable="YES">
<attribute name="activedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="appAccessToken" attributeType="String"/>
<attribute name="clientID" attributeType="String"/>
<attribute name="clientSecret" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userAccessToken" attributeType="String"/>
<attribute name="userID" attributeType="String"/>
<attribute name="username" attributeType="String"/>
<relationship name="instance" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Instance" inverseName="authentications" inverseEntity="Instance"/>
<relationship name="user" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="mastodonAuthentication" inverseEntity="MastodonUser"/>
</entity>
<entity name="MastodonUser" representedClassName="CoreDataStack.MastodonUser" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" attributeType="String"/>
<attribute name="avatarStatic" optional="YES" attributeType="String"/>
<attribute name="bot" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="displayName" attributeType="String"/>
<attribute name="domain" attributeType="String"/>
<attribute name="emojis" optional="YES" attributeType="Binary"/>
<attribute name="fields" optional="YES" attributeType="Binary"/>
<attribute name="followersCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="followingCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="header" attributeType="String"/>
<attribute name="headerStatic" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="String"/>
<attribute name="identifier" attributeType="String"/>
<attribute name="locked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="note" optional="YES" attributeType="String"/>
<attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="suspended" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="url" optional="YES" attributeType="String"/>
<attribute name="username" attributeType="String"/>
<relationship name="blocking" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="blockingBy" inverseEntity="MastodonUser"/>
<relationship name="blockingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="blocking" inverseEntity="MastodonUser"/>
<relationship name="bookmarked" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="bookmarkedBy" inverseEntity="Status"/>
<relationship name="domainBlocking" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="domainBlockingBy" inverseEntity="MastodonUser"/>
<relationship name="domainBlockingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="domainBlocking" inverseEntity="MastodonUser"/>
<relationship name="endorsed" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="endorsedBy" inverseEntity="MastodonUser"/>
<relationship name="endorsedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="endorsed" inverseEntity="MastodonUser"/>
<relationship name="favourite" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="favouritedBy" inverseEntity="Status"/>
<relationship name="followedTags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="followedBy" inverseEntity="Tag"/>
<relationship name="following" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followingBy" inverseEntity="MastodonUser"/>
<relationship name="followingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="following" inverseEntity="MastodonUser"/>
<relationship name="followRequested" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followRequestedBy" inverseEntity="MastodonUser"/>
<relationship name="followRequestedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followRequested" inverseEntity="MastodonUser"/>
<relationship name="mastodonAuthentication" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonAuthentication" inverseName="user" inverseEntity="MastodonAuthentication"/>
<relationship name="muted" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="mutedBy" inverseEntity="Status"/>
<relationship name="muting" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="mutingBy" inverseEntity="MastodonUser"/>
<relationship name="mutingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="muting" inverseEntity="MastodonUser"/>
<relationship name="notifications" toMany="YES" deletionRule="Nullify" destinationEntity="Notification" inverseName="account" inverseEntity="Notification"/>
<relationship name="pinnedStatus" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="pinnedBy" inverseEntity="Status"/>
<relationship name="privateNotes" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PrivateNote" inverseName="to" inverseEntity="PrivateNote"/>
<relationship name="privateNotesTo" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PrivateNote" inverseName="from" inverseEntity="PrivateNote"/>
<relationship name="reblogged" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="rebloggedBy" inverseEntity="Status"/>
<relationship name="searchHistories" toMany="YES" deletionRule="Nullify" destinationEntity="SearchHistory" inverseName="account" inverseEntity="SearchHistory"/>
<relationship name="showingReblogs" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="showingReblogsBy" inverseEntity="MastodonUser"/>
<relationship name="showingReblogsBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="showingReblogs" inverseEntity="MastodonUser"/>
<relationship name="statuses" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="author" inverseEntity="Status"/>
<relationship name="votePollOptions" toMany="YES" deletionRule="Nullify" destinationEntity="PollOption" inverseName="votedBy" inverseEntity="PollOption"/>
<relationship name="votePolls" toMany="YES" deletionRule="Nullify" destinationEntity="Poll" inverseName="votedBy" inverseEntity="Poll"/>
</entity>
<entity name="Notification" representedClassName="CoreDataStack.Notification" syncable="YES">
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="followRequestState" optional="YES" attributeType="Binary"/>
<attribute name="id" attributeType="String"/>
<attribute name="transientFollowRequestState" optional="YES" transient="YES" attributeType="Binary"/>
<attribute name="typeRaw" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" attributeType="String"/>
<relationship name="account" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="notifications" inverseEntity="MastodonUser"/>
<relationship name="feeds" toMany="YES" deletionRule="Cascade" destinationEntity="Feed" inverseName="notification" inverseEntity="Feed"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="notifications" inverseEntity="Status"/>
</entity>
<entity name="Poll" representedClassName="CoreDataStack.Poll" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String" defaultValueString=""/>
<attribute name="expired" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="expiresAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" attributeType="String"/>
<attribute name="isVoting" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="multiple" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="votersCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="votesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="options" toMany="YES" deletionRule="Cascade" destinationEntity="PollOption" inverseName="poll" inverseEntity="PollOption"/>
<relationship name="status" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="poll" inverseEntity="Status"/>
<relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePolls" inverseEntity="MastodonUser"/>
</entity>
<entity name="PollOption" representedClassName="CoreDataStack.PollOption" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="index" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isSelected" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="title" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="votesCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="poll" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/>
<relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePollOptions" inverseEntity="MastodonUser"/>
</entity>
<entity name="PrivateNote" representedClassName="CoreDataStack.PrivateNote" syncable="YES">
<attribute name="note" optional="YES" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="from" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotesTo" inverseEntity="MastodonUser"/>
<relationship name="to" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotes" inverseEntity="MastodonUser"/>
</entity>
<entity name="SearchHistory" representedClassName="CoreDataStack.SearchHistory" syncable="YES">
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String" defaultValueString=""/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" attributeType="String" defaultValueString=""/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="searchHistories" inverseEntity="MastodonUser"/>
<relationship name="hashtag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag" inverseName="searchHistories" inverseEntity="Tag"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="searchHistories" inverseEntity="Status"/>
</entity>
<entity name="Setting" representedClassName="CoreDataStack.Setting" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="preferredStaticAvatar" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="preferredStaticEmoji" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="preferredTrueBlackDarkMode" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="preferredUsingDefaultBrowser" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" attributeType="String"/>
<relationship name="subscriptions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Subscription" inverseName="setting" inverseEntity="Subscription"/>
</entity>
<entity name="Status" representedClassName="CoreDataStack.Status" syncable="YES">
<attribute name="attachments" optional="YES" attributeType="Binary"/>
<attribute name="content" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="deletedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="emojis" optional="YES" attributeType="Binary"/>
<attribute name="favouritesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" attributeType="String"/>
<attribute name="identifier" attributeType="String"/>
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="isSensitiveToggled" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="language" optional="YES" attributeType="String"/>
<attribute name="mentions" optional="YES" attributeType="Binary"/>
<attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="repliesCount" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
<attribute name="revealedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="sensitive" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="spoilerText" optional="YES" attributeType="String"/>
<attribute name="text" optional="YES" attributeType="String"/>
<attribute name="translatedContent" optional="YES" transient="YES" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="uri" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="String"/>
<attribute name="visibilityRaw" optional="YES" attributeType="String" elementID="visibility"/>
<relationship name="application" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Application" inverseName="status" inverseEntity="Application"/>
<relationship name="author" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="statuses" inverseEntity="MastodonUser"/>
<relationship name="bookmarkedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="bookmarked" inverseEntity="MastodonUser"/>
<relationship name="card" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Card" inverseName="status" inverseEntity="Card"/>
<relationship name="favouritedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="favourite" inverseEntity="MastodonUser"/>
<relationship name="feeds" toMany="YES" deletionRule="Cascade" destinationEntity="Feed" inverseName="status" inverseEntity="Feed"/>
<relationship name="mutedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="muted" inverseEntity="MastodonUser"/>
<relationship name="notifications" toMany="YES" deletionRule="Cascade" destinationEntity="Notification" inverseName="status" inverseEntity="Notification"/>
<relationship name="pinnedBy" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="pinnedStatus" inverseEntity="MastodonUser"/>
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Poll" inverseName="status" inverseEntity="Poll"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="reblogFrom" inverseEntity="Status"/>
<relationship name="reblogFrom" toMany="YES" deletionRule="Cascade" destinationEntity="Status" inverseName="reblog" inverseEntity="Status"/>
<relationship name="rebloggedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="reblogged" inverseEntity="MastodonUser"/>
<relationship name="replyFrom" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="replyTo" inverseEntity="Status"/>
<relationship name="replyTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="replyFrom" inverseEntity="Status"/>
<relationship name="searchHistories" toMany="YES" deletionRule="Cascade" destinationEntity="SearchHistory" inverseName="status" inverseEntity="SearchHistory"/>
</entity>
<entity name="Subscription" representedClassName="CoreDataStack.Subscription" syncable="YES">
<attribute name="activedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="endpoint" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="policyRaw" attributeType="String"/>
<attribute name="serverKey" optional="YES" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userToken" optional="YES" attributeType="String"/>
<relationship name="alert" maxCount="1" deletionRule="Cascade" destinationEntity="SubscriptionAlerts" inverseName="subscription" inverseEntity="SubscriptionAlerts"/>
<relationship name="setting" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Setting" inverseName="subscriptions" inverseEntity="Setting"/>
</entity>
<entity name="SubscriptionAlerts" representedClassName="CoreDataStack.SubscriptionAlerts" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="favouriteRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="followRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="followRequestRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="mentionRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="pollRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblogRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="subscription" maxCount="1" deletionRule="Nullify" destinationEntity="Subscription" inverseName="alert" inverseEntity="Subscription"/>
</entity>
<entity name="Tag" representedClassName="CoreDataStack.Tag" syncable="YES">
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String" defaultValueString=""/>
<attribute name="following" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="histories" optional="YES" attributeType="Binary"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="url" attributeType="String"/>
<relationship name="followedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followedTags" inverseEntity="MastodonUser"/>
<relationship name="searchHistories" toMany="YES" deletionRule="Nullify" destinationEntity="SearchHistory" inverseName="hashtag" inverseEntity="SearchHistory"/>
</entity>
</model>

View File

@ -0,0 +1,180 @@
//
// Card.swift
// CoreDataStack
//
// Created by Kyle Bashour on 11/23/22.
//
import Foundation
import CoreData
public final class Card: NSManagedObject {
// sourcery: autoGenerateProperty
@NSManaged public private(set) var urlRaw: String
public var url: URL? {
URL(string: urlRaw)
}
// sourcery: autoGenerateProperty
@NSManaged public private(set) var title: String
// sourcery: autoGenerateProperty
@NSManaged public private(set) var desc: String
@NSManaged public private(set) var typeRaw: String
// sourcery: autoGenerateProperty
public var type: MastodonCardType {
get { MastodonCardType(rawValue: typeRaw) }
set { typeRaw = newValue.rawValue }
}
// sourcery: autoGenerateProperty
@NSManaged public private(set) var authorName: String?
// sourcery: autoGenerateProperty
@NSManaged public private(set) var authorURLRaw: String?
// sourcery: autoGenerateProperty
@NSManaged public private(set) var providerName: String?
// sourcery: autoGenerateProperty
@NSManaged public private(set) var providerURLRaw: String?
// sourcery: autoGenerateProperty
@NSManaged public private(set) var width: Int64
// sourcery: autoGenerateProperty
@NSManaged public private(set) var height: Int64
// sourcery: autoGenerateProperty
@NSManaged public private(set) var image: String?
public var imageURL: URL? {
image.flatMap(URL.init)
}
// sourcery: autoGenerateProperty
@NSManaged public private(set) var embedURLRaw: String?
// sourcery: autoGenerateProperty
@NSManaged public private(set) var blurhash: String?
// sourcery: autoGenerateProperty
@NSManaged public private(set) var html: String?
// sourcery: autoGenerateRelationship
@NSManaged public private(set) var status: Status
}
extension Card {
@discardableResult
public static func insert(
into context: NSManagedObjectContext,
property: Property
) -> Card {
let object: Card = context.insertObject()
object.configure(property: property)
return object
}
}
extension Card: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return []
}
}
// MARK: - AutoGenerateProperty
extension Card: AutoGenerateProperty {
// sourcery:inline:Card.AutoGenerateProperty
// Generated using Sourcery
// DO NOT EDIT
public struct Property {
public let urlRaw: String
public let title: String
public let desc: String
public let type: MastodonCardType
public let authorName: String?
public let authorURLRaw: String?
public let providerName: String?
public let providerURLRaw: String?
public let width: Int64
public let height: Int64
public let image: String?
public let embedURLRaw: String?
public let blurhash: String?
public let html: String?
public init(
urlRaw: String,
title: String,
desc: String,
type: MastodonCardType,
authorName: String?,
authorURLRaw: String?,
providerName: String?,
providerURLRaw: String?,
width: Int64,
height: Int64,
image: String?,
embedURLRaw: String?,
blurhash: String?,
html: String?
) {
self.urlRaw = urlRaw
self.title = title
self.desc = desc
self.type = type
self.authorName = authorName
self.authorURLRaw = authorURLRaw
self.providerName = providerName
self.providerURLRaw = providerURLRaw
self.width = width
self.height = height
self.image = image
self.embedURLRaw = embedURLRaw
self.blurhash = blurhash
self.html = html
}
}
public func configure(property: Property) {
self.urlRaw = property.urlRaw
self.title = property.title
self.desc = property.desc
self.type = property.type
self.authorName = property.authorName
self.authorURLRaw = property.authorURLRaw
self.providerName = property.providerName
self.providerURLRaw = property.providerURLRaw
self.width = property.width
self.height = property.height
self.image = property.image
self.embedURLRaw = property.embedURLRaw
self.blurhash = property.blurhash
self.html = property.html
}
public func update(property: Property) {
}
// sourcery:end
}
// MARK: - AutoGenerateRelationship
extension Card: AutoGenerateRelationship {
// sourcery:inline:Card.AutoGenerateRelationship
// Generated using Sourcery
// DO NOT EDIT
public struct Relationship {
public let status: Status
public init(
status: Status
) {
self.status = status
}
}
public func configure(relationship: Relationship) {
self.status = relationship.status
}
// sourcery:end
}

View File

@ -84,7 +84,9 @@ public final class Status: NSManagedObject {
@NSManaged public private(set) var pinnedBy: MastodonUser?
// sourcery: autoGenerateRelationship
@NSManaged public private(set) var poll: Poll?
// sourcery: autoGenerateRelationship
@NSManaged public private(set) var card: Card?
// one-to-many relationship
@NSManaged public private(set) var feeds: Set<Feed>
@ -382,15 +384,18 @@ extension Status: AutoGenerateRelationship {
public let author: MastodonUser
public let reblog: Status?
public let poll: Poll?
public let card: Card?
public init(
author: MastodonUser,
reblog: Status?,
poll: Poll?
poll: Poll?,
card: Card?
) {
self.author = author
self.reblog = reblog
self.poll = poll
self.card = card
}
}
@ -398,6 +403,7 @@ extension Status: AutoGenerateRelationship {
self.author = relationship.author
self.reblog = relationship.reblog
self.poll = relationship.poll
self.card = relationship.card
}
// sourcery:end
}

View File

@ -0,0 +1,34 @@
//
// MastodonCardType.swift
// CoreDataStack
//
// Created by Kyle Bashour on 11/23/22.
//
import Foundation
public enum MastodonCardType: RawRepresentable, Equatable {
case link
case photo
case video
case _other(String)
public init(rawValue: String) {
switch rawValue {
case "link": self = .link
case "photo": self = .photo
case "video": self = .video
default: self = ._other(rawValue)
}
}
public var rawValue: String {
switch self {
case .link: return "link"
case .photo: return "photo"
case .video: return "video"
case ._other(let value): return value
}
}
}

View File

@ -0,0 +1,95 @@
//
// Persistence+Card.swift
//
//
// Created by MainasuK on 2021-12-9.
//
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import os.log
extension Persistence.Card {
public struct PersistContext {
public let domain: String
public let entity: Mastodon.Entity.Card
public let me: MastodonUser?
public let log = Logger(subsystem: "Card", category: "Persistence")
public init(
domain: String,
entity: Mastodon.Entity.Card,
me: MastodonUser?
) {
self.domain = domain
self.entity = entity
self.me = me
}
}
public struct PersistResult {
public let card: Card
public let isNewInsertion: Bool
public init(
card: Card,
isNewInsertion: Bool
) {
self.card = card
self.isNewInsertion = isNewInsertion
}
#if DEBUG
public let logger = Logger(subsystem: "Persistence.MastodonCard.PersistResult", category: "Persist")
public func log() {
let pollInsertionFlag = isNewInsertion ? "+" : "-"
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(pollInsertionFlag)](\(card.title)):")
}
#endif
}
public static func create(
in managedObjectContext: NSManagedObjectContext,
context: PersistContext
) -> PersistResult {
var type: MastodonCardType {
switch context.entity.type {
case .link: return .link
case .photo: return .photo
case .video: return .video
case .rich: return ._other(context.entity.type.rawValue)
case ._other(let rawValue): return ._other(rawValue)
}
}
let property = Card.Property(
urlRaw: context.entity.url,
title: context.entity.title,
desc: context.entity.description,
type: type,
authorName: context.entity.authorName,
authorURLRaw: context.entity.authorURL,
providerName: context.entity.providerName,
providerURLRaw: context.entity.providerURL,
width: Int64(context.entity.width ?? 0),
height: Int64(context.entity.height ?? 0),
image: context.entity.image,
embedURLRaw: context.entity.embedURL,
blurhash: context.entity.blurhash,
html: context.entity.html.flatMap { $0.isEmpty ? nil : $0 }
)
let card = Card.insert(
into: managedObjectContext,
property: property
)
return PersistResult(
card: card,
isNewInsertion: true
)
}
}

View File

@ -87,7 +87,7 @@ extension Persistence.Status {
}
if let oldStatus = fetch(in: managedObjectContext, context: context) {
merge(mastodonStatus: oldStatus, context: context)
merge(in: managedObjectContext, mastodonStatus: oldStatus, context: context)
return PersistResult(
status: oldStatus,
isNewInsertion: false,
@ -107,7 +107,9 @@ extension Persistence.Status {
)
return result.poll
}()
let card = createCard(in: managedObjectContext, context: context)
let authorResult = Persistence.MastodonUser.createOrMerge(
in: managedObjectContext,
context: Persistence.MastodonUser.PersistContext(
@ -122,7 +124,8 @@ extension Persistence.Status {
let relationship = Status.Relationship(
author: author,
reblog: reblog,
poll: poll
poll: poll,
card: card
)
let status = create(
in: managedObjectContext,
@ -182,6 +185,7 @@ extension Persistence.Status {
}
public static func merge(
in managedObjectContext: NSManagedObjectContext,
mastodonStatus status: Status,
context: PersistContext
) {
@ -203,8 +207,31 @@ extension Persistence.Status {
)
)
}
if status.card == nil, context.entity.card != nil {
let card = createCard(in: managedObjectContext, context: context)
let relationship = Card.Relationship(status: status)
card?.configure(relationship: relationship)
}
update(status: status, context: context)
}
private static func createCard(
in managedObjectContext: NSManagedObjectContext,
context: PersistContext
) -> Card? {
guard let entity = context.entity.card else { return nil }
let result = Persistence.Card.create(
in: managedObjectContext,
context: Persistence.Card.PersistContext(
domain: context.domain,
entity: entity,
me: context.me
)
)
return result.card
}
private static func update(
status: Status,

View File

@ -15,6 +15,7 @@ extension Persistence {
public enum MastodonUser { }
public enum Status { }
public enum Poll { }
public enum Card { }
public enum PollOption { }
public enum Tag { }
public enum SearchHistory { }

View File

@ -17,4 +17,16 @@ extension NSLayoutConstraint {
self.identifier = identifier
return self
}
@discardableResult
public func activate() -> Self {
self.isActive = true
return self
}
@discardableResult
public func deactivate() -> Self {
self.isActive = false
return self
}
}

View File

@ -48,18 +48,22 @@ extension UIView {
}
public extension UIView {
func pinToParent() {
@discardableResult
func pinToParent() -> [NSLayoutConstraint] {
pinTo(to: self.superview)
}
func pinTo(to view: UIView?) {
guard let pinToView = view else { return }
NSLayoutConstraint.activate([
@discardableResult
func pinTo(to view: UIView?) -> [NSLayoutConstraint] {
guard let pinToView = view else { return [] }
let constraints = [
topAnchor.constraint(equalTo: pinToView.topAnchor),
leadingAnchor.constraint(equalTo: pinToView.leadingAnchor),
trailingAnchor.constraint(equalTo: pinToView.trailingAnchor),
bottomAnchor.constraint(equalTo: pinToView.bottomAnchor),
])
]
NSLayoutConstraint.activate(constraints)
return constraints
}
}

View File

@ -120,6 +120,8 @@ public enum L10n {
public static let confirm = L10n.tr("Localizable", "Common.Controls.Actions.Confirm", fallback: "Confirm")
/// Continue
public static let `continue` = L10n.tr("Localizable", "Common.Controls.Actions.Continue", fallback: "Continue")
/// Copy
public static let copy = L10n.tr("Localizable", "Common.Controls.Actions.Copy", fallback: "Copy")
/// Copy Photo
public static let copyPhoto = L10n.tr("Localizable", "Common.Controls.Actions.CopyPhoto", fallback: "Copy Photo")
/// Delete
@ -288,6 +290,12 @@ public enum L10n {
public enum Status {
/// Content Warning
public static let contentWarning = L10n.tr("Localizable", "Common.Controls.Status.ContentWarning", fallback: "Content Warning")
/// %@ via %@
public static func linkViaUser(_ p1: Any, _ p2: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.LinkViaUser", String(describing: p1), String(describing: p2), fallback: "%@ via %@")
}
/// Load Embed
public static let loadEmbed = L10n.tr("Localizable", "Common.Controls.Status.LoadEmbed", fallback: "Load Embed")
/// Tap anywhere to reveal
public static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning", fallback: "Tap anywhere to reveal")
/// Sensitive Content
@ -317,6 +325,8 @@ public enum L10n {
public static let reblog = L10n.tr("Localizable", "Common.Controls.Status.Actions.Reblog", fallback: "Reblog")
/// Reply
public static let reply = L10n.tr("Localizable", "Common.Controls.Status.Actions.Reply", fallback: "Reply")
/// Share Link in Post
public static let shareLinkInPost = L10n.tr("Localizable", "Common.Controls.Status.Actions.ShareLinkInPost", fallback: "Share Link in Post")
/// Show GIF
public static let showGif = L10n.tr("Localizable", "Common.Controls.Status.Actions.ShowGif", fallback: "Show GIF")
/// Show image

View File

@ -34,6 +34,7 @@ Please check your internet connection.";
"Common.Controls.Actions.Compose" = "Compose";
"Common.Controls.Actions.Confirm" = "Confirm";
"Common.Controls.Actions.Continue" = "Continue";
"Common.Controls.Actions.Copy" = "Copy";
"Common.Controls.Actions.CopyPhoto" = "Copy Photo";
"Common.Controls.Actions.Delete" = "Delete";
"Common.Controls.Actions.Discard" = "Discard";
@ -105,6 +106,7 @@ Please check your internet connection.";
"Common.Controls.Status.Actions.Menu" = "Menu";
"Common.Controls.Status.Actions.Reblog" = "Reblog";
"Common.Controls.Status.Actions.Reply" = "Reply";
"Common.Controls.Status.Actions.ShareLinkInPost" = "Share Link in Post";
"Common.Controls.Status.Actions.ShowGif" = "Show GIF";
"Common.Controls.Status.Actions.ShowImage" = "Show image";
"Common.Controls.Status.Actions.ShowVideoPlayer" = "Show video player";
@ -112,6 +114,8 @@ Please check your internet connection.";
"Common.Controls.Status.Actions.Unfavorite" = "Unfavorite";
"Common.Controls.Status.Actions.Unreblog" = "Undo reblog";
"Common.Controls.Status.ContentWarning" = "Content Warning";
"Common.Controls.Status.LinkViaUser" = "%@ via %@";
"Common.Controls.Status.LoadEmbed" = "Load Embed";
"Common.Controls.Status.MediaContentWarning" = "Tap anywhere to reveal";
"Common.Controls.Status.MetaEntity.Email" = "Email address: %@";
"Common.Controls.Status.MetaEntity.Hashtag" = "Hashtag: %@";

View File

@ -0,0 +1,18 @@
//
// UIScreen.swift
//
//
// Created by Jed Fox on 2022-12-15.
//
import UIKit
extension UIScreen {
public var pixelSize: CGFloat {
if scale > 0 {
return 1 / scale
}
// should never happen but just in case
return 1
}
}

View File

@ -47,10 +47,7 @@ extension ComposeContentViewModel {
}
.store(in: &disposeBag)
switch kind {
case .post:
break
case .reply(let status):
if case .reply(let status) = destination {
let cell = composeReplyToTableViewCell
// bind frame publisher
cell.$framePublisher
@ -66,10 +63,6 @@ extension ComposeContentViewModel {
guard let replyTo = status.object(in: context.managedObjectContext) else { return }
cell.statusView.configure(status: replyTo)
}
case .hashtag:
break
case .mention:
break
}
}
}
@ -83,7 +76,7 @@ extension ComposeContentViewModel: UITableViewDataSource {
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch Section.allCases[section] {
case .replyTo:
switch kind {
switch destination {
case .reply: return 1
default: return 0
}

View File

@ -32,7 +32,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
// input
let context: AppContext
let kind: Kind
let destination: Destination
weak var delegate: ComposeContentViewModelDelegate?
@Published var viewLayoutFrame = ViewLayoutFrame()
@ -59,8 +59,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
customEmojiPickerInputViewModel.configure(textInput: textView)
}
}
// for hashtag: "#<hashtag> "
// for mention: "@<mention> "
// allow dismissing the compose view without confirmation if content == intialContent
@Published public var initialContent = ""
@Published public var content = ""
@Published public var contentWeightedLength = 0
@ -138,11 +137,12 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
public init(
context: AppContext,
authContext: AuthContext,
kind: Kind
destination: Destination,
initialContent: String
) {
self.context = context
self.authContext = authContext
self.kind = kind
self.destination = destination
self.visibility = {
// default private when user locked
var visibility: Mastodon.Entity.Status.Visibility = {
@ -152,8 +152,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
return author.locked ? .private : .public
}()
// set visibility for reply post
switch kind {
case .reply(let record):
if case .reply(let record) = destination {
context.managedObjectContext.performAndWait {
guard let status = record.object(in: context.managedObjectContext) else {
assertionFailure()
@ -173,8 +172,6 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
break
}
}
default:
break
}
return visibility
}()
@ -185,7 +182,8 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
// end init
// setup initial value
switch kind {
let initialContentWithSpace = initialContent.isEmpty ? "" : initialContent + " "
switch destination {
case .reply(let record):
context.managedObjectContext.performAndWait {
guard let status = record.object(in: context.managedObjectContext) else {
@ -214,29 +212,15 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
}
let initialComposeContent = mentionAccts.joined(separator: " ")
let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " "
self.initialContent = preInsertedContent ?? ""
self.content = preInsertedContent ?? ""
let preInsertedContent = initialComposeContent.isEmpty ? "" : initialComposeContent + " "
self.initialContent = preInsertedContent + initialContentWithSpace
self.content = preInsertedContent + initialContentWithSpace
}
case .hashtag(let hashtag):
let initialComposeContent = "#" + hashtag
UITextChecker.learnWord(initialComposeContent)
let preInsertedContent = initialComposeContent + " "
self.initialContent = preInsertedContent
self.content = preInsertedContent
case .mention(let record):
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
let initialComposeContent = "@" + user.acct
UITextChecker.learnWord(initialComposeContent)
let preInsertedContent = initialComposeContent + " "
self.initialContent = preInsertedContent
self.content = preInsertedContent
}
case .post:
break
case .topLevel:
self.initialContent = initialContentWithSpace
self.content = initialContentWithSpace
}
// set limit
let _configuration: Mastodon.Entity.Instance.Configuration? = {
var configuration: Mastodon.Entity.Instance.Configuration? = nil
@ -443,11 +427,9 @@ extension ComposeContentViewModel {
}
extension ComposeContentViewModel {
public enum Kind {
case post
case hashtag(hashtag: String)
case mention(user: ManagedObjectRecord<MastodonUser>)
case reply(status: ManagedObjectRecord<Status>)
public enum Destination {
case topLevel
case reply(parent: ManagedObjectRecord<Status>)
}
public enum ScrollViewState {
@ -530,10 +512,10 @@ extension ComposeContentViewModel {
return MastodonStatusPublisher(
author: author,
replyTo: {
switch self.kind {
case .reply(let status): return status
default: return nil
if case .reply(let status) = destination {
return status
}
return nil
}(),
isContentWarningComposing: isContentWarningActive,
contentWarning: contentWarning,

View File

@ -496,7 +496,10 @@ extension NotificationView {
// MARK: - StatusViewDelegate
extension NotificationView: StatusViewDelegate {
public func statusView(_ statusView: StatusView, didTapCardWithURL url: URL) {
assertionFailure()
}
public func statusView(_ statusView: StatusView, headerDidPressed header: UIView) {
// do nothing
}
@ -599,6 +602,15 @@ extension NotificationView: StatusViewDelegate {
assertionFailure()
}
public func statusView(_ statusView: StatusView, cardControl: StatusCardControl, didTapURL url: URL) {
assertionFailure()
}
public func statusView(_ statusView: StatusView, cardControlMenu: StatusCardControl) -> UIMenu? {
assertionFailure()
return nil
}
}
// MARK: - MastodonMenuDelegate

View File

@ -0,0 +1,338 @@
//
// OpenGraphView.swift
//
//
// Created by Kyle Bashour on 11/11/22.
//
import AlamofireImage
import Combine
import MastodonAsset
import MastodonCore
import MastodonLocalization
import CoreDataStack
import UIKit
import WebKit
public protocol StatusCardControlDelegate: AnyObject {
func statusCardControl(_ statusCardControl: StatusCardControl, didTapURL url: URL)
func statusCardControlMenu(_ statusCardControl: StatusCardControl) -> UIMenu?
}
public final class StatusCardControl: UIControl {
public weak var delegate: StatusCardControlDelegate?
private var disposeBag = Set<AnyCancellable>()
private let containerStackView = UIStackView()
private let labelStackView = UIStackView()
private let highlightView = UIView()
private let dividerView = UIView()
private let imageView = UIImageView()
private let titleLabel = UILabel()
private let linkLabel = UILabel()
private lazy var showEmbedButton: UIButton = {
if #available(iOS 15.0, *) {
var configuration = UIButton.Configuration.gray()
configuration.background.visualEffect = UIBlurEffect(style: .systemUltraThinMaterial)
configuration.baseBackgroundColor = .clear
configuration.cornerStyle = .capsule
configuration.buttonSize = .large
configuration.title = L10n.Common.Controls.Status.loadEmbed
configuration.image = UIImage(systemName: "play.fill")
configuration.imagePadding = 12
return UIButton(configuration: configuration, primaryAction: UIAction { [weak self] _ in
self?.showWebView()
})
}
return UIButton(type: .system, primaryAction: UIAction { [weak self] _ in
self?.showWebView()
})
}()
private var html = ""
private static let cardContentPool = WKProcessPool()
private var webView: WKWebView?
private var layout: Layout?
private var layoutConstraints: [NSLayoutConstraint] = []
private var dividerConstraint: NSLayoutConstraint?
public override var isHighlighted: Bool {
didSet {
// override UIKit behavior of highlighting subviews when cell is highlighted
if isHighlighted,
let cell = sequence(first: self, next: \.superview).first(where: { $0 is UITableViewCell }) as? UITableViewCell {
highlightView.isHidden = cell.isHighlighted
} else {
highlightView.isHidden = !isHighlighted
}
}
}
public override init(frame: CGRect) {
super.init(frame: frame)
apply(theme: ThemeService.shared.currentTheme.value)
ThemeService.shared.currentTheme.sink { [weak self] theme in
self?.apply(theme: theme)
}.store(in: &disposeBag)
clipsToBounds = true
layer.cornerCurve = .continuous
layer.cornerRadius = 10
if #available(iOS 15, *) {
maximumContentSizeCategory = .accessibilityLarge
}
highlightView.backgroundColor = UIColor.label.withAlphaComponent(0.1)
highlightView.isHidden = true
titleLabel.numberOfLines = 2
titleLabel.textColor = Asset.Colors.Label.primary.color
titleLabel.font = .preferredFont(forTextStyle: .body)
linkLabel.numberOfLines = 1
linkLabel.textColor = Asset.Colors.Label.secondary.color
linkLabel.font = .preferredFont(forTextStyle: .subheadline)
imageView.tintColor = Asset.Colors.Label.secondary.color
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.setContentHuggingPriority(.zero, for: .horizontal)
imageView.setContentHuggingPriority(.zero, for: .vertical)
imageView.setContentCompressionResistancePriority(.zero, for: .horizontal)
imageView.setContentCompressionResistancePriority(.zero, for: .vertical)
labelStackView.addArrangedSubview(titleLabel)
labelStackView.addArrangedSubview(linkLabel)
labelStackView.layoutMargins = .init(top: 10, left: 10, bottom: 10, right: 10)
labelStackView.isLayoutMarginsRelativeArrangement = true
labelStackView.axis = .vertical
labelStackView.spacing = 2
containerStackView.addArrangedSubview(imageView)
containerStackView.addArrangedSubview(dividerView)
containerStackView.addArrangedSubview(labelStackView)
containerStackView.isUserInteractionEnabled = false
containerStackView.distribution = .fill
addSubview(containerStackView)
addSubview(highlightView)
addSubview(showEmbedButton)
containerStackView.translatesAutoresizingMaskIntoConstraints = false
highlightView.translatesAutoresizingMaskIntoConstraints = false
showEmbedButton.translatesAutoresizingMaskIntoConstraints = false
dividerView.translatesAutoresizingMaskIntoConstraints = false
containerStackView.pinToParent()
highlightView.pinToParent()
NSLayoutConstraint.activate([
showEmbedButton.centerXAnchor.constraint(equalTo: imageView.centerXAnchor),
showEmbedButton.centerYAnchor.constraint(equalTo: imageView.centerYAnchor),
])
addInteraction(UIContextMenuInteraction(delegate: self))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func configure(card: Card) {
if let host = card.url?.host {
accessibilityLabel = "\(card.title) \(host)"
} else {
accessibilityLabel = card.title
}
titleLabel.text = card.title
linkLabel.text = card.url?.host
imageView.contentMode = .center
imageView.sd_setImage(
with: card.imageURL,
placeholderImage: icon(for: card.layout)
) { [weak self] image, _, _, _ in
if image != nil {
self?.imageView.contentMode = .scaleAspectFill
}
self?.containerStackView.setNeedsLayout()
self?.containerStackView.layoutIfNeeded()
}
if let html = card.html, !html.isEmpty {
showEmbedButton.isHidden = false
self.html = html
} else {
webView?.removeFromSuperview()
webView = nil
showEmbedButton.isHidden = true
self.html = ""
}
updateConstraints(for: card.layout)
}
public override func didMoveToWindow() {
super.didMoveToWindow()
if let window = window {
layer.borderWidth = window.screen.pixelSize
dividerConstraint?.constant = window.screen.pixelSize
}
}
private func updateConstraints(for layout: Layout) {
guard layout != self.layout else { return }
self.layout = layout
NSLayoutConstraint.deactivate(layoutConstraints)
dividerConstraint?.deactivate()
let pixelSize = (window?.screen.pixelSize ?? 1)
switch layout {
case .large(let aspectRatio):
containerStackView.alignment = .fill
containerStackView.axis = .vertical
layoutConstraints = [
imageView.widthAnchor.constraint(
equalTo: imageView.heightAnchor,
multiplier: aspectRatio
)
// This priority is important or constraints break;
// it still renders the card correctly.
.priority(.defaultLow - 1),
// set a reasonable max height for very tall images
imageView.heightAnchor
.constraint(lessThanOrEqualToConstant: 400),
]
dividerConstraint = dividerView.heightAnchor.constraint(equalToConstant: pixelSize).activate()
case .compact:
containerStackView.alignment = .center
containerStackView.axis = .horizontal
layoutConstraints = [
imageView.heightAnchor.constraint(equalTo: heightAnchor),
imageView.widthAnchor.constraint(equalToConstant: 85),
heightAnchor.constraint(equalToConstant: 85).priority(.defaultLow - 1),
heightAnchor.constraint(greaterThanOrEqualToConstant: 85),
dividerView.heightAnchor.constraint(equalTo: containerStackView.heightAnchor),
]
dividerConstraint = dividerView.widthAnchor.constraint(equalToConstant: pixelSize).activate()
}
NSLayoutConstraint.activate(layoutConstraints)
}
private func icon(for layout: Layout) -> UIImage? {
switch layout {
case .compact:
return UIImage(systemName: "newspaper.fill")
case .large:
let configuration = UIImage.SymbolConfiguration(pointSize: 32)
return UIImage(systemName: "photo", withConfiguration: configuration)
}
}
private func apply(theme: Theme) {
layer.borderColor = theme.separator.cgColor
dividerView.backgroundColor = theme.separator
imageView.backgroundColor = UIColor.tertiarySystemFill
}
}
// MARK: WKWebView delegates
extension StatusCardControl: WKNavigationDelegate, WKUIDelegate {
fileprivate func showWebView() {
let webView = setupWebView()
webView.loadHTMLString("<meta name='viewport' content='width=device-width,user-scalable=no'><style>body { margin: 0; color-scheme: light dark; } body > :only-child { width: 100vw !important; height: 100vh !important }</style>" + html, baseURL: nil)
if webView.superview == nil {
addSubview(webView)
webView.pinTo(to: imageView)
}
}
private func setupWebView() -> WKWebView {
if let webView { return webView }
let config = WKWebViewConfiguration()
config.processPool = Self.cardContentPool
config.websiteDataStore = .nonPersistent() // private/incognito mode
config.suppressesIncrementalRendering = true
config.allowsInlineMediaPlayback = true
let webView = WKWebView(frame: .zero, configuration: config)
webView.uiDelegate = self
webView.navigationDelegate = self
webView.translatesAutoresizingMaskIntoConstraints = false
webView.isOpaque = false
webView.backgroundColor = .clear
self.webView = webView
return webView
}
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
let isTopLevelNavigation: Bool
if let frame = navigationAction.targetFrame {
isTopLevelNavigation = frame.isMainFrame
} else {
isTopLevelNavigation = true
}
if isTopLevelNavigation,
// ignore form submits and such
navigationAction.navigationType == .linkActivated || navigationAction.navigationType == .other,
let url = navigationAction.request.url,
url.absoluteString != "about:blank" {
delegate?.statusCardControl(self, didTapURL: url)
return .cancel
}
return .allow
}
public func webViewDidClose(_ webView: WKWebView) {
webView.removeFromSuperview()
self.webView = nil
}
}
// MARK: UIContextMenuInteractionDelegate
extension StatusCardControl {
public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { elements in
self.delegate?.statusCardControlMenu(self)
}
}
public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
UITargetedPreview(view: self)
}
}
private extension StatusCardControl {
enum Layout: Equatable {
case compact
case large(aspectRatio: CGFloat)
}
}
private extension Card {
var layout: StatusCardControl.Layout {
var aspectRatio = CGFloat(width) / CGFloat(height)
if !aspectRatio.isFinite {
aspectRatio = 1
}
return (abs(aspectRatio - 1) < 0.05 || image == nil) && html == nil
? .compact
: .large(aspectRatio: aspectRatio)
}
}
private extension UILayoutPriority {
static let zero = UILayoutPriority(rawValue: 0)
}

View File

@ -53,6 +53,7 @@ extension StatusView {
configureContent(status: status)
configureMedia(status: status)
configurePoll(status: status)
configureCard(status: status)
configureToolbar(status: status)
configureFilter(status: status)
viewModel.originalStatus = status
@ -403,6 +404,17 @@ extension StatusView {
.assign(to: \.isVoting, on: viewModel)
.store(in: &disposeBag)
}
private func configureCard(status: Status) {
let status = status.reblog ?? status
if viewModel.mediaViewConfigurations.isEmpty {
status.publisher(for: \.card)
.assign(to: \.card, on: viewModel)
.store(in: &disposeBag)
} else {
viewModel.card = nil
}
}
private func configureToolbar(status: Status) {
let status = status.reblog ?? status

View File

@ -76,7 +76,10 @@ extension StatusView {
@Published public var voteCount = 0
@Published public var expireAt: Date?
@Published public var expired: Bool = false
// Card
@Published public var card: Card?
// Visibility
@Published public var visibility: MastodonVisibility = .public
@ -194,6 +197,7 @@ extension StatusView.ViewModel {
bindContent(statusView: statusView)
bindMedia(statusView: statusView)
bindPoll(statusView: statusView)
bindCard(statusView: statusView)
bindToolbar(statusView: statusView)
bindMetric(statusView: statusView)
bindMenu(statusView: statusView)
@ -314,12 +318,14 @@ extension StatusView.ViewModel {
)
statusView.contentMetaText.textView.accessibilityTraits = [.staticText]
statusView.contentMetaText.textView.accessibilityElementsHidden = false
} else {
statusView.contentMetaText.reset()
statusView.contentMetaText.textView.accessibilityLabel = ""
}
statusView.contentMetaText.textView.alpha = isContentReveal ? 1 : 0 // keep the frame size and only display when revealing
statusView.statusCardControl.alpha = isContentReveal ? 1 : 0
statusView.setSpoilerOverlayViewHidden(isHidden: isContentReveal)
@ -489,6 +495,15 @@ extension StatusView.ViewModel {
.assign(to: \.isEnabled, on: statusView.pollVoteButton)
.store(in: &disposeBag)
}
private func bindCard(statusView: StatusView) {
$card.sink { card in
guard let card = card else { return }
statusView.statusCardControl.configure(card: card)
statusView.setStatusCardControlDisplay()
}
.store(in: &disposeBag)
}
private func bindToolbar(statusView: StatusView) {
$replyCount

View File

@ -23,6 +23,7 @@ public protocol StatusViewDelegate: AnyObject {
func statusView(_ statusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton)
func statusView(_ statusView: StatusView, contentSensitiveeToggleButtonDidPressed button: UIButton)
func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta)
func statusView(_ statusView: StatusView, didTapCardWithURL url: URL)
func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int)
func statusView(_ statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath)
func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton)
@ -32,6 +33,8 @@ public protocol StatusViewDelegate: AnyObject {
func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton)
func statusView(_ statusView: StatusView, statusMetricView: StatusMetricView, reblogButtonDidPressed button: UIButton)
func statusView(_ statusView: StatusView, statusMetricView: StatusMetricView, favoriteButtonDidPressed button: UIButton)
func statusView(_ statusView: StatusView, cardControl: StatusCardControl, didTapURL url: URL)
func statusView(_ statusView: StatusView, cardControlMenu: StatusCardControl) -> UIMenu?
// a11y
func statusView(_ statusView: StatusView, accessibilityActivate: Void)
@ -113,6 +116,8 @@ public final class StatusView: UIView {
]
return metaText
}()
public let statusCardControl = StatusCardControl()
// content warning
public let spoilerOverlayView = SpoilerOverlayView()
@ -261,6 +266,7 @@ public final class StatusView: UIView {
setMediaDisplay(isDisplay: false)
setPollDisplay(isDisplay: false)
setFilterHintLabelDisplay(isDisplay: false)
setStatusCardControlDisplay(isDisplay: false)
setupTranslationIndicator()
}
@ -302,10 +308,14 @@ extension StatusView {
// content
contentMetaText.textView.delegate = self
contentMetaText.textView.linkDelegate = self
// card
statusCardControl.addTarget(self, action: #selector(statusCardControlPressed), for: .touchUpInside)
statusCardControl.delegate = self
// media
mediaGridContainerView.delegate = self
// poll
pollTableView.translatesAutoresizingMaskIntoConstraints = false
pollTableViewHeightLayoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1)
@ -340,6 +350,12 @@ extension StatusView {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
delegate?.statusView(self, spoilerOverlayViewDidPressed: spoilerOverlayView)
}
@objc private func statusCardControlPressed(_ sender: StatusCardControl) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
guard let url = viewModel.card?.url else { return }
delegate?.statusView(self, didTapCardWithURL: url)
}
}
@ -415,11 +431,11 @@ extension StatusView.Style {
statusView.authorAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin
statusView.containerStackView.addArrangedSubview(statusView.authorAdaptiveMarginContainerView)
// content container: V - [ contentMetaText ]
// content container: V - [ contentMetaText statusCardControl ]
statusView.contentContainer.axis = .vertical
statusView.contentContainer.spacing = 12
statusView.contentContainer.distribution = .fill
statusView.contentContainer.alignment = .top
statusView.contentContainer.alignment = .fill
statusView.contentAdaptiveMarginContainerView.contentView = statusView.contentContainer
statusView.contentAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin
@ -429,7 +445,7 @@ extension StatusView.Style {
// status content
statusView.contentContainer.addArrangedSubview(statusView.contentMetaText.textView)
statusView.containerStackView.setCustomSpacing(16, after: statusView.contentMetaText.textView)
statusView.contentContainer.addArrangedSubview(statusView.statusCardControl)
// translated info
statusView.containerStackView.addArrangedSubview(statusView.isTranslatingLoadingView)
@ -521,6 +537,7 @@ extension StatusView.Style {
statusView.headerAdaptiveMarginContainerView.removeFromSuperview()
statusView.authorAdaptiveMarginContainerView.removeFromSuperview()
statusView.statusCardControl.removeFromSuperview()
}
func notificationQuote(statusView: StatusView) {
@ -529,6 +546,7 @@ extension StatusView.Style {
statusView.contentAdaptiveMarginContainerView.bottomLayoutConstraint?.constant = 16 // fix bottom margin missing issue
statusView.pollAdaptiveMarginContainerView.bottomLayoutConstraint?.constant = 16 // fix bottom margin missing issue
statusView.actionToolbarAdaptiveMarginContainerView.removeFromSuperview()
statusView.statusCardControl.removeFromSuperview()
}
func composeStatusReplica(statusView: StatusView) {
@ -574,6 +592,10 @@ extension StatusView {
func setFilterHintLabelDisplay(isDisplay: Bool = true) {
filterHintLabel.isHidden = !isDisplay
}
func setStatusCardControlDisplay(isDisplay: Bool = true) {
statusCardControl.isHidden = !isDisplay
}
// container width
public var contentMaxLayoutWidth: CGFloat {
@ -712,7 +734,7 @@ extension StatusView {
}
}
.store(in: &disposeBag)
viewModel.$translatedFromLanguage
.receive(on: DispatchQueue.main)
.sink { [weak self] translatedFromLanguage in
@ -728,6 +750,17 @@ extension StatusView {
}
}
// MARK: StatusCardControlDelegate
extension StatusView: StatusCardControlDelegate {
public func statusCardControl(_ statusCardControl: StatusCardControl, didTapURL url: URL) {
delegate?.statusView(self, cardControl: statusCardControl, didTapURL: url)
}
public func statusCardControlMenu(_ statusCardControl: StatusCardControl) -> UIMenu? {
delegate?.statusView(self, cardControlMenu: statusCardControl)
}
}
#if DEBUG
import SwiftUI

View File

@ -99,7 +99,8 @@ extension ShareViewController {
let composeContentViewModel = ComposeContentViewModel(
context: context,
authContext: authContext,
kind: .post
destination: .topLevel,
initialContent: ""
)
let composeContentViewController = ComposeContentViewController()
composeContentViewController.viewModel = composeContentViewModel