diff --git a/NotificationService/Info.plist b/NotificationService/Info.plist index 5350483f..f9aa0a6a 100644 --- a/NotificationService/Info.plist +++ b/NotificationService/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleVersion - 125 + 128 NSExtension NSExtensionPointIdentifier diff --git a/ShareExtension/Info.plist b/ShareExtension/Info.plist index 4b75f20f..6758123f 100644 --- a/ShareExtension/Info.plist +++ b/ShareExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 2.0.0 CFBundleVersion - 125 + 128 NSExtension NSExtensionAttributes diff --git a/TwidereSDK/Package.swift b/TwidereSDK/Package.swift index f36b14e6..24193981 100644 --- a/TwidereSDK/Package.swift +++ b/TwidereSDK/Package.swift @@ -51,7 +51,7 @@ let package = Package( .package(url: "https://github.com/krzyzanowskim/CryptoSwift.git", from: "1.6.0"), .package(url: "https://github.com/tid-kijyun/Kanna.git", from: "5.2.7"), .package(url: "https://github.com/JohnSundell/CollectionConcurrencyKit.git", from: "0.2.0"), - .package(url: "https://github.com/TwidereProject/TwitterSDK.git", exact: "0.4.0"), + .package(url: "https://github.com/TwidereProject/TwitterSDK.git", exact: "0.7.0"), .package(name: "ArkanaKeys", path: "../dependencies/ArkanaKeys"), .package(name: "CoverFlowStackLayout", path: "../CoverFlowStackLayout"), ], diff --git a/TwidereSDK/Sources/CoreDataStack/CoreDataStack.xcdatamodeld/CoreDataStack 7.xcdatamodel/contents b/TwidereSDK/Sources/CoreDataStack/CoreDataStack.xcdatamodeld/CoreDataStack 7.xcdatamodel/contents index 426ef9ee..ff7b27b2 100644 --- a/TwidereSDK/Sources/CoreDataStack/CoreDataStack.xcdatamodeld/CoreDataStack 7.xcdatamodel/contents +++ b/TwidereSDK/Sources/CoreDataStack/CoreDataStack.xcdatamodeld/CoreDataStack 7.xcdatamodel/contents @@ -253,6 +253,8 @@ + + diff --git a/TwidereSDK/Sources/CoreDataStack/Entity/Twitter/TwitterStatus.swift b/TwidereSDK/Sources/CoreDataStack/Entity/Twitter/TwitterStatus.swift index b3d8c125..213195a5 100644 --- a/TwidereSDK/Sources/CoreDataStack/Entity/Twitter/TwitterStatus.swift +++ b/TwidereSDK/Sources/CoreDataStack/Entity/Twitter/TwitterStatus.swift @@ -42,7 +42,12 @@ final public class TwitterStatus: NSManagedObject { @NSManaged public private(set) var replyToStatusID: TwitterStatus.ID? // sourcery: autoUpdatableObject, autoGenerateProperty @NSManaged public private(set) var replyToUserID: TwitterUser.ID? - + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var isMediaSensitive: Bool + // sourcery: autoUpdatableObject + @NSManaged public private(set) var isMediaSensitiveToggled: Bool + // sourcery: autoUpdatableObject, autoGenerateProperty @NSManaged public private(set) var createdAt: Date // sourcery: autoUpdatableObject, autoGenerateProperty @@ -238,6 +243,7 @@ extension TwitterStatus: AutoGenerateProperty { public let source: String? public let replyToStatusID: TwitterStatus.ID? public let replyToUserID: TwitterUser.ID? + public let isMediaSensitive: Bool public let createdAt: Date public let updatedAt: Date @@ -252,6 +258,7 @@ extension TwitterStatus: AutoGenerateProperty { source: String?, replyToStatusID: TwitterStatus.ID?, replyToUserID: TwitterUser.ID?, + isMediaSensitive: Bool, createdAt: Date, updatedAt: Date ) { @@ -265,6 +272,7 @@ extension TwitterStatus: AutoGenerateProperty { self.source = source self.replyToStatusID = replyToStatusID self.replyToUserID = replyToUserID + self.isMediaSensitive = isMediaSensitive self.createdAt = createdAt self.updatedAt = updatedAt } @@ -281,6 +289,7 @@ extension TwitterStatus: AutoGenerateProperty { self.source = property.source self.replyToStatusID = property.replyToStatusID self.replyToUserID = property.replyToUserID + self.isMediaSensitive = property.isMediaSensitive self.createdAt = property.createdAt self.updatedAt = property.updatedAt } @@ -292,6 +301,7 @@ extension TwitterStatus: AutoGenerateProperty { update(source: property.source) update(replyToStatusID: property.replyToStatusID) update(replyToUserID: property.replyToUserID) + update(isMediaSensitive: property.isMediaSensitive) update(createdAt: property.createdAt) update(updatedAt: property.updatedAt) } @@ -377,6 +387,16 @@ extension TwitterStatus: AutoUpdatableObject { self.replyToUserID = replyToUserID } } + public func update(isMediaSensitive: Bool) { + if self.isMediaSensitive != isMediaSensitive { + self.isMediaSensitive = isMediaSensitive + } + } + public func update(isMediaSensitiveToggled: Bool) { + if self.isMediaSensitiveToggled != isMediaSensitiveToggled { + self.isMediaSensitiveToggled = isMediaSensitiveToggled + } + } public func update(createdAt: Date) { if self.createdAt != createdAt { self.createdAt = createdAt diff --git a/TwidereSDK/Sources/CoreDataStack/Entity/Twitter/TwitterUser.swift b/TwidereSDK/Sources/CoreDataStack/Entity/Twitter/TwitterUser.swift index 451e42fe..0627bdf7 100644 --- a/TwidereSDK/Sources/CoreDataStack/Entity/Twitter/TwitterUser.swift +++ b/TwidereSDK/Sources/CoreDataStack/Entity/Twitter/TwitterUser.swift @@ -170,6 +170,7 @@ extension TwitterUser { public static func predicate(username: String) -> NSPredicate { return NSPredicate(format: "%K == %@", #keyPath(TwitterUser.username), username) + } } diff --git a/TwidereSDK/Sources/TwidereCore/Model/User/UserObject.swift b/TwidereSDK/Sources/TwidereCore/Model/User/UserObject.swift index 28c1376b..ff69aa4d 100644 --- a/TwidereSDK/Sources/TwidereCore/Model/User/UserObject.swift +++ b/TwidereSDK/Sources/TwidereCore/Model/User/UserObject.swift @@ -47,6 +47,19 @@ extension UserObject { } } + public var authenticationIndex: AuthenticationIndex? { + switch self { + case .twitter(let object): + return object.twitterAuthentication.flatMap { + $0.authenticationIndex + } + case .mastodon(let object): + return object.mastodonAuthentication.flatMap { + $0.authenticationIndex + } + } + } + public var notifications: Set { switch self { case .twitter: diff --git a/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Extension/TwitterStatus+Property.swift b/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Extension/TwitterStatus+Property.swift index a19066a4..92a4be0c 100644 --- a/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Extension/TwitterStatus+Property.swift +++ b/TwidereSDK/Sources/TwidereCore/Persistence/Twitter/Extension/TwitterStatus+Property.swift @@ -37,6 +37,7 @@ extension TwitterStatus.Property { }, replyToStatusID: entity.inReplyToStatusIDStr, replyToUserID: entity.inReplyToUserIDStr, + isMediaSensitive: entity.possiblySensitive ?? false, createdAt: entity.createdAt, updatedAt: networkDate ) @@ -135,6 +136,7 @@ extension TwitterStatus.Property { source: status.source, replyToStatusID: status.repliedToID, replyToUserID: status.inReplyToUserID, + isMediaSensitive: status.possiblySensitive ?? false, createdAt: status.createdAt, updatedAt: networkDate ) diff --git a/TwidereSDK/Sources/TwidereCore/Protocol/TextStyleConfigurable.swift b/TwidereSDK/Sources/TwidereCore/Protocol/TextStyleConfigurable.swift index d7efd15a..e250ac4d 100644 --- a/TwidereSDK/Sources/TwidereCore/Protocol/TextStyleConfigurable.swift +++ b/TwidereSDK/Sources/TwidereCore/Protocol/TextStyleConfigurable.swift @@ -25,6 +25,7 @@ public enum TextStyle { case statusTimestamp case statusLocation case statusContent + case statusTranslateButton case statusMetrics case userAuthorName case pollOptionTitle @@ -73,6 +74,7 @@ extension TextStyle { case .statusTimestamp: return 1 case .statusLocation: return 1 case .statusContent: return 0 + case .statusTranslateButton: return 1 case .statusMetrics: return 1 case .pollOptionTitle: return 1 case .pollOptionPercentage: return 1 @@ -116,6 +118,8 @@ extension TextStyle { return .preferredFont(forTextStyle: .caption1) case .statusContent: return .preferredFont(forTextStyle: .body) + case .statusTranslateButton: + return .preferredFont(forTextStyle: .headline) case .statusMetrics: return .preferredFont(forTextStyle: .footnote) case .pollOptionTitle: @@ -181,6 +185,8 @@ extension TextStyle { return .secondaryLabel case .statusContent: return .label.withAlphaComponent(0.8) + case .statusTranslateButton: + return .tintColor case .statusMetrics: return .secondaryLabel case .userAuthorName: diff --git a/TwidereSDK/Sources/TwidereCore/ViewModel/List/ListMembershipViewModel.swift b/TwidereSDK/Sources/TwidereCore/ViewModel/List/ListMembershipViewModel.swift index 3e3896bc..b2723c44 100644 --- a/TwidereSDK/Sources/TwidereCore/ViewModel/List/ListMembershipViewModel.swift +++ b/TwidereSDK/Sources/TwidereCore/ViewModel/List/ListMembershipViewModel.swift @@ -14,9 +14,10 @@ public protocol ListMembershipViewModelDelegate: AnyObject { } public final class ListMembershipViewModel { - + let logger = Logger(subsystem: "ListMembershipViewModel", category: "ViewModel") + public var id = UUID() public weak var delegate: ListMembershipViewModelDelegate? // input @@ -55,6 +56,7 @@ public final class ListMembershipViewModel { extension ListMembershipViewModel { + @MainActor public func add( user: UserRecord, authenticationContext: AuthenticationContext @@ -77,6 +79,7 @@ extension ListMembershipViewModel { } } + @MainActor public func remove( user: UserRecord, authenticationContext: AuthenticationContext @@ -100,3 +103,14 @@ extension ListMembershipViewModel { } } + +// MARK: - ListMembershipViewModel +extension ListMembershipViewModel: Hashable { + public static func == (lhs: ListMembershipViewModel, rhs: ListMembershipViewModel) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+Home.swift b/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+Home.swift index 8361c286..5ea4cd75 100644 --- a/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+Home.swift +++ b/TwidereSDK/Sources/TwidereCore/ViewModel/Status/StatusFetchViewModel+Timeline+Home.swift @@ -94,8 +94,6 @@ extension StatusFetchViewModel.Timeline.Home { public static func fetch(api: APIService, input: Input) async throws -> StatusFetchViewModel.Timeline.Output { switch input { case .twitter(let fetchContext): - throw AppError.implicit(.badRequest) - let responses = try await api.twitterHomeTimeline( query: .init( sinceID: fetchContext.sinceID, diff --git a/TwidereSDK/Sources/TwidereLocalization/Generated/Strings.swift b/TwidereSDK/Sources/TwidereLocalization/Generated/Strings.swift index ac520b3a..86da00ae 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Generated/Strings.swift +++ b/TwidereSDK/Sources/TwidereLocalization/Generated/Strings.swift @@ -1043,6 +1043,10 @@ public enum L10n { /// Search users public static let searchPlaceholder = L10n.tr("Localizable", "Scene.ComposeUserSearch.SearchPlaceholder", fallback: "Search users") } + public enum Detail { + /// Detail + public static let title = L10n.tr("Localizable", "Scene.Detail.Title", fallback: "Detail") + } public enum Drafts { /// Drafts public static let title = L10n.tr("Localizable", "Scene.Drafts.Title", fallback: "Drafts") diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/ar.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/ar.lproj/Localizable.strings index e4ebd371..f0b17c45 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/ar.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/ar.lproj/Localizable.strings @@ -322,6 +322,7 @@ "Scene.Compose.Vote.PlaceholderIndex" = "اختر %d"; "Scene.ComposeHashtagSearch.SearchPlaceholder" = "ابحث عن وسم"; "Scene.ComposeUserSearch.SearchPlaceholder" = "ابحث عن مستخدم"; +"Scene.Detail.Title" = "Detail"; "Scene.Drafts.Actions.DeleteDraft" = "احذف المسودة"; "Scene.Drafts.Actions.EditDraft" = "عدّل المسودة"; "Scene.Drafts.Title" = "المسودات"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/ca.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/ca.lproj/Localizable.strings index a8454527..c794d577 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/ca.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/ca.lproj/Localizable.strings @@ -322,6 +322,7 @@ "Scene.Compose.Vote.PlaceholderIndex" = "Elecció d"; "Scene.ComposeHashtagSearch.SearchPlaceholder" = "Cerca etiquetes"; "Scene.ComposeUserSearch.SearchPlaceholder" = "Cerca usuaris"; +"Scene.Detail.Title" = "Detail"; "Scene.Drafts.Actions.DeleteDraft" = "Suprimeix l'esborrany"; "Scene.Drafts.Actions.EditDraft" = "Edita l'esborrany"; "Scene.Drafts.Title" = "Esborranys"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/de.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/de.lproj/Localizable.strings index 5f590576..9ab45fe7 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/de.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/de.lproj/Localizable.strings @@ -322,6 +322,7 @@ "Scene.Compose.Vote.PlaceholderIndex" = "Choice %d"; "Scene.ComposeHashtagSearch.SearchPlaceholder" = "Search hashtag"; "Scene.ComposeUserSearch.SearchPlaceholder" = "Search users"; +"Scene.Detail.Title" = "Detail"; "Scene.Drafts.Actions.DeleteDraft" = "Entwurf löschen"; "Scene.Drafts.Actions.EditDraft" = "Entwurf bearbeiten"; "Scene.Drafts.Title" = "Entwürfe"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/en.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/en.lproj/Localizable.strings index 3e7e8a05..a921e13e 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/en.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/en.lproj/Localizable.strings @@ -322,6 +322,7 @@ "Scene.Compose.Vote.PlaceholderIndex" = "Choice %d"; "Scene.ComposeHashtagSearch.SearchPlaceholder" = "Search hashtag"; "Scene.ComposeUserSearch.SearchPlaceholder" = "Search users"; +"Scene.Detail.Title" = "Detail"; "Scene.Drafts.Actions.DeleteDraft" = "Delete draft"; "Scene.Drafts.Actions.EditDraft" = "Edit draft"; "Scene.Drafts.Title" = "Drafts"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/es.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/es.lproj/Localizable.strings index d4d5287f..6074affc 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/es.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/es.lproj/Localizable.strings @@ -322,6 +322,7 @@ "Scene.Compose.Vote.PlaceholderIndex" = "Opción %d"; "Scene.ComposeHashtagSearch.SearchPlaceholder" = "Buscar hashtags"; "Scene.ComposeUserSearch.SearchPlaceholder" = "Buscar usuarios"; +"Scene.Detail.Title" = "Detail"; "Scene.Drafts.Actions.DeleteDraft" = "Eliminar borrador"; "Scene.Drafts.Actions.EditDraft" = "Editar borrador"; "Scene.Drafts.Title" = "Borradores"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/eu.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/eu.lproj/Localizable.strings index 977d3c09..4fd104dd 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/eu.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/eu.lproj/Localizable.strings @@ -322,6 +322,7 @@ "Scene.Compose.Vote.PlaceholderIndex" = "%d aukera"; "Scene.ComposeHashtagSearch.SearchPlaceholder" = "Bilatu traola"; "Scene.ComposeUserSearch.SearchPlaceholder" = "Erabiltzaileak bilatu"; +"Scene.Detail.Title" = "Detail"; "Scene.Drafts.Actions.DeleteDraft" = "Ezabatu zirriborroa"; "Scene.Drafts.Actions.EditDraft" = "Editatu zirriborroa"; "Scene.Drafts.Title" = "Zirriborroak"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/gl.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/gl.lproj/Localizable.strings index 730c7590..70addc89 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/gl.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/gl.lproj/Localizable.strings @@ -322,6 +322,7 @@ "Scene.Compose.Vote.PlaceholderIndex" = "Opción %d"; "Scene.ComposeHashtagSearch.SearchPlaceholder" = "Buscar cancelo"; "Scene.ComposeUserSearch.SearchPlaceholder" = "Buscar usuarias"; +"Scene.Detail.Title" = "Detail"; "Scene.Drafts.Actions.DeleteDraft" = "Eliminar borrador"; "Scene.Drafts.Actions.EditDraft" = "Editar borrador"; "Scene.Drafts.Title" = "Borradores"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/ja.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/ja.lproj/Localizable.strings index fac12b1b..0002ce49 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/ja.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/ja.lproj/Localizable.strings @@ -322,6 +322,7 @@ "Scene.Compose.Vote.PlaceholderIndex" = "選択肢 %d"; "Scene.ComposeHashtagSearch.SearchPlaceholder" = "ハッシュタグを検索"; "Scene.ComposeUserSearch.SearchPlaceholder" = "ユーザーを検索"; +"Scene.Detail.Title" = "Detail"; "Scene.Drafts.Actions.DeleteDraft" = "下書きを削除"; "Scene.Drafts.Actions.EditDraft" = "下書きを編集"; "Scene.Drafts.Title" = "下書き"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/ko.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/ko.lproj/Localizable.strings index 642ab517..97dcecc0 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/ko.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/ko.lproj/Localizable.strings @@ -322,6 +322,7 @@ "Scene.Compose.Vote.PlaceholderIndex" = "%d 고르기"; "Scene.ComposeHashtagSearch.SearchPlaceholder" = "해시태그 찾기"; "Scene.ComposeUserSearch.SearchPlaceholder" = "사용자 찾기"; +"Scene.Detail.Title" = "Detail"; "Scene.Drafts.Actions.DeleteDraft" = "지우기"; "Scene.Drafts.Actions.EditDraft" = "다시 쓰기"; "Scene.Drafts.Title" = "임시 보관함"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/pt-BR.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/pt-BR.lproj/Localizable.strings index 7cf9ca13..cb2f1043 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/pt-BR.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/pt-BR.lproj/Localizable.strings @@ -322,6 +322,7 @@ "Scene.Compose.Vote.PlaceholderIndex" = "Opção %d"; "Scene.ComposeHashtagSearch.SearchPlaceholder" = "Buscar hashtag"; "Scene.ComposeUserSearch.SearchPlaceholder" = "Buscar usuários"; +"Scene.Detail.Title" = "Detail"; "Scene.Drafts.Actions.DeleteDraft" = "Excluir rascunho"; "Scene.Drafts.Actions.EditDraft" = "Editar rascunho"; "Scene.Drafts.Title" = "Rascunhos"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/tr.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/tr.lproj/Localizable.strings index 3f8c7bd6..6b12331f 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/tr.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/tr.lproj/Localizable.strings @@ -322,6 +322,7 @@ "Scene.Compose.Vote.PlaceholderIndex" = "Seçim %d"; "Scene.ComposeHashtagSearch.SearchPlaceholder" = "Hashtag ara"; "Scene.ComposeUserSearch.SearchPlaceholder" = "Kullanıcıları ara"; +"Scene.Detail.Title" = "Detail"; "Scene.Drafts.Actions.DeleteDraft" = "Taslağı sil"; "Scene.Drafts.Actions.EditDraft" = "Taslağı düzenle"; "Scene.Drafts.Title" = "Taslaklar"; diff --git a/TwidereSDK/Sources/TwidereLocalization/Resources/zh-Hans.lproj/Localizable.strings b/TwidereSDK/Sources/TwidereLocalization/Resources/zh-Hans.lproj/Localizable.strings index d8ea84d2..630827dd 100644 --- a/TwidereSDK/Sources/TwidereLocalization/Resources/zh-Hans.lproj/Localizable.strings +++ b/TwidereSDK/Sources/TwidereLocalization/Resources/zh-Hans.lproj/Localizable.strings @@ -322,6 +322,7 @@ "Scene.Compose.Vote.PlaceholderIndex" = "选项 %d"; "Scene.ComposeHashtagSearch.SearchPlaceholder" = "搜索标签"; "Scene.ComposeUserSearch.SearchPlaceholder" = "搜索用户"; +"Scene.Detail.Title" = "Detail"; "Scene.Drafts.Actions.DeleteDraft" = "删除草稿"; "Scene.Drafts.Actions.EditDraft" = "编辑草稿"; "Scene.Drafts.Title" = "草稿"; diff --git a/TwidereSDK/Sources/TwidereUI/Container/BadgeClipContainer.swift b/TwidereSDK/Sources/TwidereUI/Container/BadgeClipContainer.swift new file mode 100644 index 00000000..158cac34 --- /dev/null +++ b/TwidereSDK/Sources/TwidereUI/Container/BadgeClipContainer.swift @@ -0,0 +1,49 @@ +// +// BadgeClipContainer.swift +// +// +// Created by MainasuK on 2023/5/9. +// + +import SwiftUI + +public struct BadgeClipContainer: View { + + public let content: Content + public let badge: Badge + + public init( + @ViewBuilder content: () -> Content, + @ViewBuilder badge: () -> Badge + ) { + self.content = content() + self.badge = badge() + } + + public var body: some View { + ZStack(alignment: Alignment(horizontal: .trailing, vertical: .bottom)) { + content + badge + .scaleEffect(1.2) + .alignmentGuide(HorizontalAlignment.trailing, computeValue: { d in d.width - 4 }) + .alignmentGuide(VerticalAlignment.bottom, computeValue: { d in d.height - 4 }) + .blendMode(.destinationOut) + .overlay { + badge + } + + } + .compositingGroup() + } +} + +struct BadgeClipContainer_Previews: PreviewProvider { + static var previews: some View { + BadgeClipContainer(content: { + Color.blue + .frame(width: 44, height: 44) + }, badge: { + Image(uiImage: Asset.Badge.verified.image) + }) + } +} diff --git a/TwidereSDK/Sources/TwidereUI/Container/MediaGridContainerView.swift b/TwidereSDK/Sources/TwidereUI/Container/MediaGridContainerView.swift index a7b71b96..979989f2 100644 --- a/TwidereSDK/Sources/TwidereUI/Container/MediaGridContainerView.swift +++ b/TwidereSDK/Sources/TwidereUI/Container/MediaGridContainerView.swift @@ -172,7 +172,7 @@ extension MediaGridContainerView { ) default: Rectangle() - .fill(Color(uiColor: .placeholderText)) + .fill(.clear) .frame(width: width, height: height) .overlay( MediaView(viewModel: viewModel) @@ -180,16 +180,18 @@ extension MediaGridContainerView { ) } } + .background(Color(uiColor: .placeholderText).opacity(0.3)) .cornerRadius(MediaGridContainerView.cornerRadius) .clipped() .background(GeometryReader { proxy in - Color.clear.preference( - key: ViewFrameKey.self, - value: proxy.frame(in: .global) - ) - .onPreferenceChange(ViewFrameKey.self) { frame in - viewModels[index].frameInWindow = frame - } + Color.clear + .preference( + key: ViewFrameKey.self, + value: proxy.frame(in: .global) + ) + .onPreferenceChange(ViewFrameKey.self) { frame in + viewModels[index].frameInWindow = frame + } }) .overlay(alignment: .bottom) { MediaMetaIndicatorView(viewModel: viewModels[index]) diff --git a/TwidereSDK/Sources/TwidereUI/Content/StatusView+ViewModel.swift b/TwidereSDK/Sources/TwidereUI/Content/StatusView+ViewModel.swift index 44d8e9e7..db740060 100644 --- a/TwidereSDK/Sources/TwidereUI/Content/StatusView+ViewModel.swift +++ b/TwidereSDK/Sources/TwidereUI/Content/StatusView+ViewModel.swift @@ -115,7 +115,12 @@ extension StatusView { public var isMediaContentWarningOverlayReveal: Bool { return isMediaSensitiveToggled ? isMediaSensitive : !isMediaSensitive } - + public var isMediaContentWarningOverlayToggleButtonDisplay: Bool { + switch status { + case .twitter: return isMediaSensitive + default: return true + } + } // @Published public var isRepost = false // @Published public var isRepostEnabled = true @@ -1135,6 +1140,14 @@ extension StatusView.ViewModel { // media mediaViewModels = MediaView.ViewModel.viewModels(from: status) + // media content warning + isMediaSensitive = status.isMediaSensitive + isMediaSensitiveToggled = status.isMediaSensitiveToggled + status.publisher(for: \.isMediaSensitiveToggled) + .receive(on: DispatchQueue.main) + .assign(to: \.isMediaSensitiveToggled, on: self) + .store(in: &disposeBag) + // poll if let poll = status.poll { self.pollViewModel = PollView.ViewModel( diff --git a/TwidereSDK/Sources/TwidereUI/Content/StatusView.swift b/TwidereSDK/Sources/TwidereUI/Content/StatusView.swift index bf1ac3c4..b638837c 100644 --- a/TwidereSDK/Sources/TwidereUI/Content/StatusView.swift +++ b/TwidereSDK/Sources/TwidereUI/Content/StatusView.swift @@ -36,7 +36,10 @@ public protocol StatusViewDelegate: AnyObject { func statusView(_ viewModel: StatusView.ViewModel, pollVoteActionForViewModel pollViewModel: PollView.ViewModel) func statusView(_ viewModel: StatusView.ViewModel, pollUpdateIfNeedsForViewModel pollViewModel: PollView.ViewModel) func statusView(_ viewModel: StatusView.ViewModel, pollViewModel: PollView.ViewModel, pollOptionDidSelectForViewModel optionViewModel: PollOptionView.ViewModel) - + + // repost + func statusView(_ viewModel: StatusView.ViewModel, quoteStatusViewDidPressed quoteViewModel: StatusView.ViewModel) + // metric func statusView(_ viewModel: StatusView.ViewModel, statusMetricViewModel: StatusMetricView.ViewModel, statusMetricButtonDidPressed action: StatusMetricView.Action) @@ -142,6 +145,10 @@ public struct StatusView: View { // content if viewModel.isContentReveal { contentView + + if viewModel.isTranslateButtonDisplay { + translateButton + } } // media if !viewModel.mediaViewModels.isEmpty { @@ -153,12 +160,13 @@ public struct StatusView: View { viewModel.delegate?.statusView(viewModel, mediaViewModel: mediaViewModel, action: action) } ) - // .clipShape(RoundedRectangle(cornerRadius: MediaGridContainerView.cornerRadius)) .overlay { - ContentWarningOverlayView(isReveal: viewModel.isMediaContentWarningOverlayReveal) { - viewModel.delegate?.statusView(viewModel, toggleContentWarningOverlayDisplay: !viewModel.isMediaContentWarningOverlayReveal) + if viewModel.isMediaContentWarningOverlayToggleButtonDisplay { + ContentWarningOverlayView(isReveal: viewModel.isMediaContentWarningOverlayReveal) { + viewModel.delegate?.statusView(viewModel, toggleContentWarningOverlayDisplay: !viewModel.isMediaContentWarningOverlayReveal) + } + .cornerRadius(MediaGridContainerView.cornerRadius) } - .cornerRadius(MediaGridContainerView.cornerRadius) } } // poll @@ -182,6 +190,9 @@ public struct StatusView: View { Color(uiColor: .label.withAlphaComponent(0.04)) } .cornerRadius(12) + .onTapGesture { + viewModel.delegate?.statusView(viewModel, quoteStatusViewDidPressed: quoteViewModel) + } } // location (inline) if let location = viewModel.location { @@ -420,6 +431,20 @@ extension StatusView { } } + var translateButton: some View { + Button { + viewModel.delegate?.statusView(viewModel, statusToolbarViewModel: viewModel.toolbarViewModel, statusToolbarButtonDidPressed: .translate) + } label: { + HStack { + Text(L10n.Common.Controls.Status.Actions.translate) + .font(Font(TextStyle.statusTranslateButton.font)) + .foregroundColor(Color(uiColor: TextStyle.statusTranslateButton.textColor)) + Spacer() + } + .padding(.vertical) + } + } + var toolbarView: some View { StatusToolbarView( viewModel: viewModel.toolbarViewModel, diff --git a/TwidereSDK/Sources/TwidereUI/Content/UserContentView+ViewModel.swift b/TwidereSDK/Sources/TwidereUI/Content/UserContentView+ViewModel.swift deleted file mode 100644 index cd5941c2..00000000 --- a/TwidereSDK/Sources/TwidereUI/Content/UserContentView+ViewModel.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// UserContentView+ViewModel.swift -// -// -// Created by MainasuK on 2022-7-12. -// - -import Foundation -import Combine -import CoreData -import CoreDataStack -import SwiftUI -import TwidereCore -import Meta - -extension UserContentView { - public class ViewModel: ObservableObject { - - // input - public let user: UserObject - public let accessoryType: AccessoryType - - // output - @Published public var platform: Platform = .none - - @Published public var name: MetaContent = PlaintextMetaContent(string: " ") - @Published public var username: MetaContent = PlaintextMetaContent(string: " ") - @Published public var acct: MetaContent = PlaintextMetaContent(string: " ") - @Published public var avatarImageURL: URL? - - @Published public var protected: Bool = false - - public init( - user: UserObject, - accessoryType: AccessoryType - ) { - self.user = user - self.accessoryType = accessoryType - // end init - - // configure() - } - } -} - -extension UserContentView.ViewModel { - - public enum AccessoryType { - case none - case disclosureIndicator - } - -} - -//extension UserContentView.ViewModel { -// -// func configure() { -// assert(Thread.isMainThread) -// -// switch user { -// case .twitter(let user): -// configure(user: user) -// case .mastodon(let user): -// configure(user: user) -// } -// } -// -//} - -//extension UserContentView.ViewModel { -// private func configure(user: TwitterUser) { -// // platform -// platform = .twitter -// // avatar -// user.publisher(for: \.profileImageURL) -// .map { _ in user.avatarImageURL() } -// .assign(to: &$avatarImageURL) -// // author name -// user.publisher(for: \.name) -// .map { PlaintextMetaContent(string: $0) } -// .assign(to: &$name) -// // author username -// user.publisher(for: \.username) -// .map { PlaintextMetaContent(string: "@" + $0) } -// .assign(to: &$username) -// // acct -// user.publisher(for: \.username) -// .map { PlaintextMetaContent(string: "@" + $0) } -// .assign(to: &$acct) -// // protected -// user.publisher(for: \.protected) -// .assign(to: &$protected) -// } -//} - -//extension UserContentView.ViewModel { -// private func configure(user: MastodonUser) { -// // platform -// platform = .mastodon -// // avatar -// Publishers.CombineLatest3( -// UserDefaults.shared.publisher(for: \.preferredStaticAvatar), -// user.publisher(for: \.avatar), -// user.publisher(for: \.avatarStatic) -// ) -// .map { preferredStaticAvatar, avatar, avatarStatic in -// let string = preferredStaticAvatar ? (avatarStatic ?? avatar) : avatar -// return string.flatMap { URL(string: $0) } -// } -// .assign(to: &$avatarImageURL) -// // author name -// Publishers.CombineLatest( -// user.publisher(for: \.displayName), -// user.publisher(for: \.emojis) -// ) -// .map { name, _ -> MetaContent in -// user.nameMetaContent ?? PlaintextMetaContent(string: name) -// } -// .assign(to: &$name) -// // author username -// user.publisher(for: \.acct) -// .map { PlaintextMetaContent(string: "@" + $0) } -// .assign(to: &$username) -// // acct -// user.publisher(for: \.acct) -// .map { _ in PlaintextMetaContent(string: "@" + user.acctWithDomain) } -// .assign(to: &$acct) -// // protected -// user.publisher(for: \.locked) -// .assign(to: &$protected) -// } -//} diff --git a/TwidereSDK/Sources/TwidereUI/Content/UserContentView.swift b/TwidereSDK/Sources/TwidereUI/Content/UserContentView.swift deleted file mode 100644 index 92d416c7..00000000 --- a/TwidereSDK/Sources/TwidereUI/Content/UserContentView.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// UserContentView.swift -// -// -// Created by MainasuK on 2022-7-12. -// - -import SwiftUI - -public struct UserContentView: View { - - @ObservedObject public var viewModel: ViewModel - - public init(viewModel: ViewModel) { - self.viewModel = viewModel - } - - public var body: some View { - HStack { - let dimension = ProfileAvatarView.Dimension.inline - ProfileAvatarViewRepresentable( - configuration: .init(url: viewModel.avatarImageURL), - dimension: dimension, - badge: .none - ) - .frame( - width: dimension.primitiveAvatarButtonSize.width, - height: dimension.primitiveAvatarButtonSize.height - ) - VStack(alignment: .leading, spacing: .zero) { - Spacer() - MetaLabelRepresentable( - textStyle: .userAuthorName, - metaContent: viewModel.name - ) - MetaLabelRepresentable( - textStyle: .userAuthorUsername, - metaContent: viewModel.acct - ) - Spacer() - } - Spacer() - switch viewModel.accessoryType { - case .none: - EmptyView() - case .disclosureIndicator: - Image(systemName: "chevron.right") - .foregroundColor(Color(.secondaryLabel)) - } // end switch - } // end HStack - } // end body - -} diff --git a/TwidereSDK/Sources/TwidereUI/Content/UserView+Configuration.swift b/TwidereSDK/Sources/TwidereUI/Content/UserView+Configuration.swift index 296083b4..728586ca 100644 --- a/TwidereSDK/Sources/TwidereUI/Content/UserView+Configuration.swift +++ b/TwidereSDK/Sources/TwidereUI/Content/UserView+Configuration.swift @@ -14,21 +14,6 @@ import TwidereAsset import Meta import MastodonSDK -//extension UserView { -// public struct ConfigurationContext { -// public let authContext: AuthContext -// public let listMembershipViewModel: ListMembershipViewModel? -// -// public init( -// authContext: AuthContext, -// listMembershipViewModel: ListMembershipViewModel? -// ) { -// self.authContext = authContext -// self.listMembershipViewModel = listMembershipViewModel -// } -// } -//} -// //extension UserView { // public func configure( // user: UserObject, @@ -49,30 +34,6 @@ import MastodonSDK // // viewModel.relationshipViewModel.user = user // viewModel.relationshipViewModel.me = me -// -// viewModel.listMembershipViewModel = configurationContext.listMembershipViewModel -// if let listMembershipViewModel = configurationContext.listMembershipViewModel { -// listMembershipViewModel.$ownerUserIdentifier -// .assign(to: \.listOwnerUserIdentifier, on: viewModel) -// .store(in: &disposeBag) -// } -// -// // accessory -// switch style { -// case .addListMember: -// guard let listMembershipViewModel = configurationContext.listMembershipViewModel else { -// assertionFailure() -// break -// } -// let userRecord = user.asRecord -// listMembershipViewModel.$members -// .map { members in members.contains(userRecord) } -// .assign(to: \.isListMember, on: viewModel) -// .store(in: &disposeBag) -// listMembershipViewModel.$workingMembers -// .map { members in members.contains(userRecord) } -// .assign(to: \.isListMemberCandidate, on: viewModel) -// .store(in: &disposeBag) // default: // break // } diff --git a/TwidereSDK/Sources/TwidereUI/Content/UserView+ViewModel.swift b/TwidereSDK/Sources/TwidereUI/Content/UserView+ViewModel.swift index 8481e2ba..9307a5d7 100644 --- a/TwidereSDK/Sources/TwidereUI/Content/UserView+ViewModel.swift +++ b/TwidereSDK/Sources/TwidereUI/Content/UserView+ViewModel.swift @@ -24,7 +24,7 @@ extension UserView { let relationshipViewModel = RelationshipViewModel() // input - public let user: UserObject + public let user: UserObject? public let authContext: AuthContext? public let kind: Kind public weak var delegate: UserViewDelegate? @@ -40,7 +40,10 @@ extension UserView { @Published public var name: MetaContent = PlaintextMetaContent(string: "") @Published public var username: String = "" -// @Published public var platform: Platform = .none + @Published public var platform: Platform = .none + + @Published public var isMyself: Bool = false + // @Published public var authenticationContext: AuthenticationContext? // me // @Published public var userAuthenticationContext: AuthenticationContext? // @@ -62,22 +65,23 @@ extension UserView { // follow @Published public var followButtonViewModel: FollowButton.ViewModel? -// -// public var listMembershipViewModel: ListMembershipViewModel? -// @Published public var listOwnerUserIdentifier: UserIdentifier? = nil -// @Published public var isListMember = false -// @Published public var isListMemberCandidate = false // a.k.a isBusy -// @Published public var isMyList = false -// -// @Published public var badgeCount: Int = 0 -// + + public var listMembershipViewModel: ListMembershipViewModel? + @Published public var listOwnerUserIdentifier: UserIdentifier? = nil + @Published public var isListMember = false + @Published public var isListMemberCandidate = false // a.k.a isBusy + @Published public var isMyList = false + + // notification count + @Published public var notificationBadgeCount: Int = 0 + // public enum Header { // case none // case notification(info: NotificationHeaderInfo) // } private init( - object user: UserObject, + object user: UserObject?, authContext: AuthContext?, kind: Kind, delegate: UserViewDelegate? @@ -88,14 +92,39 @@ extension UserView { self.delegate = delegate // end init - // notification switch kind { case .notification(let notification): self.notification = notification + + case .listMember(let listMembershipViewModel), .addListMember(let listMembershipViewModel): + if let listMembershipViewModel = listMembershipViewModel, + let userRecord = user?.asRecord + { + self.listMembershipViewModel = listMembershipViewModel + listMembershipViewModel.$ownerUserIdentifier + .assign(to: \.listOwnerUserIdentifier, on: self) + .store(in: &disposeBag) + listMembershipViewModel.$members + .map { members in members.contains(userRecord) } + .assign(to: \.isListMember, on: self) + .store(in: &disposeBag) + listMembershipViewModel.$workingMembers + .map { members in members.contains(userRecord) } + .assign(to: \.isListMemberCandidate, on: self) + .store(in: &disposeBag) + } default: break } + // isMyself + isMyself = { + guard let authContext = self.authContext, + let user = self.user + else { return false } + return authContext.authenticationContext.userIdentifier == user.userIdentifer + }() + // follow request switch notification { case .twitter: @@ -110,7 +139,7 @@ extension UserView { switch kind { case .search: // follow - if let authContext = authContext { + if let authContext = authContext, let user = user { self.followButtonViewModel = .init(user: user, authContext: authContext) } default: @@ -120,33 +149,30 @@ extension UserView { // avatar style UserDefaults.shared.publisher(for: \.avatarStyle) .assign(to: &$avatarStyle) -// // isMyList -// Publishers.CombineLatest( -// $authenticationContext, -// $listOwnerUserIdentifier -// ) -// .map { authenticationContext, userIdentifier -> Bool in -// guard let authenticationContext = authenticationContext else { return false } -// guard let userIdentifier = userIdentifier else { return false } -// return authenticationContext.userIdentifier == userIdentifier -// } -// .assign(to: &$isMyList) -// // badge count -// $userAuthenticationContext -// .map { authenticationContext -> Int in -// switch authenticationContext { -// case .twitter: -// return 0 -// case .mastodon(let authenticationContext): -// let accessToken = authenticationContext.authorization.accessToken -// let count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: accessToken) -// return count -// case .none: -// return 0 -// } -// } -// .assign(to: &$badgeCount) - } + + // isMyList + $listOwnerUserIdentifier + .map { userIdentifier -> Bool in + guard let authenticationContext = authContext?.authenticationContext else { return false } + guard let userIdentifier = userIdentifier else { return false } + return authenticationContext.userIdentifier == userIdentifier + } + .assign(to: &$isMyList) + + // notification badge count + notificationBadgeCount = { + switch authContext?.authenticationContext { + case .twitter: + return 0 + case .mastodon(let authenticationContext): + let accessToken = authenticationContext.authorization.accessToken + let count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: accessToken) + return count + case nil: + return 0 + } + }() + } // end init } } @@ -185,13 +211,14 @@ extension UserView.ViewModel { // headline: name | lock | username // subheadline: follower count - // accessory: membership menu - case listMember + // accessory: membership menu (isMyList) + // menuActions: [ remove ] + case listMember(ListMembershipViewModel?) // headline: name | lock | username // subheadline: follower count // accessory: membership button - case addListMember + case addListMember(ListMembershipViewModel?) // headline: name | lock // subheadline: username @@ -211,8 +238,9 @@ extension UserView.ViewModel { } public enum MenuAction: Hashable { + case openInNewWindowForAccount case signOut - case remove + case removeListMember } } @@ -320,31 +348,6 @@ extension UserView.ViewModel { // .store(in: &disposeBag) // // // accessory -// switch userView.style { -// case .account: -// $badgeCount -// .sink { count in -// let count = max(0, min(count, 50)) -// userView.badgeImageView.image = UIImage(systemName: "\(count).circle.fill")?.withRenderingMode(.alwaysTemplate) -// userView.badgeImageView.isHidden = count == 0 -// } -// .store(in: &disposeBag) -// userView.menuButton.showsMenuAsPrimaryAction = true -// userView.menuButton.menu = { -// let children = [ -// UIAction( -// title: L10n.Common.Controls.Actions.signOut, -// image: UIImage(systemName: "person.crop.circle.badge.minus"), -// attributes: .destructive, -// state: .off -// ) { [weak userView] _ in -// guard let userView = userView else { return } -// userView.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): sign out user…") -// userView.delegate?.userView(userView, menuActionDidPressed: .signOut, menuButton: userView.menuButton) -// } -// ] -// return UIMenu(title: "", image: nil, options: [], children: children) -// }() // // case .notification: // $isFollowRequestBusy @@ -356,46 +359,6 @@ extension UserView.ViewModel { // } // .store(in: &disposeBag) // -// case .listMember: -// userView.menuButton.showsMenuAsPrimaryAction = true -// userView.menuButton.menu = { -// let children = [ -// UIAction( -// title: L10n.Common.Controls.Actions.remove, -// image: UIImage(systemName: "minus.circle"), -// attributes: .destructive, -// state: .off -// ) { [weak userView] _ in -// guard let userView = userView else { return } -// userView.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): remove user…") -// userView.delegate?.userView(userView, menuActionDidPressed: .remove, menuButton: userView.menuButton) -// } -// ] -// return UIMenu(title: "", image: nil, options: [], children: children) -// }() -// $isMyList -// .map { !$0 } -// .assign(to: \.isHidden, on: userView.menuButton) -// .store(in: &disposeBag) -// case .addListMember: -// Publishers.CombineLatest( -// $isListMember, -// $isListMemberCandidate -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak userView] isMember, isMemberCandidate in -// guard let userView = userView else { return } -// let image = isMember ? UIImage(systemName: "minus.circle") : UIImage(systemName: "plus.circle") -// let tintColor = isMember ? UIColor.systemRed : Asset.Colors.hightLight.color -// userView.membershipButton.setImage(image, for: .normal) -// userView.membershipButton.tintColor = tintColor -// -// userView.membershipButton.alpha = isMemberCandidate ? 0 : 1 -// userView.activityIndicatorView.isHidden = !isMemberCandidate -// userView.activityIndicatorView.startAnimating() -// } -// .store(in: &disposeBag) -// // default: // userView.menuButton.showsMenuAsPrimaryAction = true // userView.menuButton.menu = nil @@ -449,6 +412,7 @@ extension UserView.ViewModel { // end init // user + platform = .twitter user.publisher(for: \.profileImageURL) .map { _ in user.avatarImageURL() } .assign(to: &$avatarURL) @@ -474,6 +438,7 @@ extension UserView.ViewModel { // end init // user + platform = .mastodon user.publisher(for: \.avatar) .compactMap { $0.flatMap { URL(string: $0) } } .assign(to: &$avatarURL) @@ -485,3 +450,23 @@ extension UserView.ViewModel { .assign(to: &$username) } } + +#if DEBUG +extension UserView.ViewModel { + public convenience init(kind: Kind) { + self.init( + object: nil, + authContext: nil, + kind: kind, + delegate: nil + ) + // end init + + avatarURL = URL(string: "https://pbs.twimg.com/profile_images/1445764922474827784/W2zEPN7U_400x400.jpg") + name = PlaintextMetaContent(string: "Name") + username = "username" + platform = .twitter + notificationBadgeCount = 10 + } +} +#endif diff --git a/TwidereSDK/Sources/TwidereUI/Content/UserView.swift b/TwidereSDK/Sources/TwidereUI/Content/UserView.swift index 92ccd0f6..1c703746 100644 --- a/TwidereSDK/Sources/TwidereUI/Content/UserView.swift +++ b/TwidereSDK/Sources/TwidereUI/Content/UserView.swift @@ -18,8 +18,7 @@ import Kingfisher public protocol UserViewDelegate: AnyObject { func userView(_ viewModel: UserView.ViewModel, userAvatarButtonDidPressed user: UserRecord) func userView(_ viewModel: UserView.ViewModel, menuActionDidPressed action: UserView.ViewModel.MenuAction) -// func userView(_ userView: UserView, friendshipButtonDidPressed button: UIButton) -// func userView(_ userView: UserView, membershipButtonDidPressed button: UIButton) + func userView(_ viewModel: UserView.ViewModel, listMembershipButtonDidPressed user: UserRecord) func userView(_ viewModel: UserView.ViewModel, followReqeustButtonDidPressed user: UserRecord, accept: Bool) } @@ -72,8 +71,37 @@ extension UserView { var avatarButton: some View { Button { - viewModel.delegate?.userView(viewModel, userAvatarButtonDidPressed: viewModel.user.asRecord) + guard let user = viewModel.user?.asRecord else { + assertionFailure() + return + } + viewModel.delegate?.userView(viewModel, userAvatarButtonDidPressed: user) } label: { + switch viewModel.kind { + case .account: + BadgeClipContainer { + avatarButtonContentView + } badge: { + switch viewModel.platform { + case .none: + EmptyView() + case .twitter: + Image(uiImage: Asset.Badge.circleTwitter.image) + case .mastodon: + Image(uiImage: Asset.Badge.circleMastodon.image) + } + } + + default: + avatarButtonContentView + } + } + .buttonStyle(.borderless) + .allowsHitTesting(allowsAvatarButtonHitTesting) + } + + var avatarButtonContentView: some View { + Group { let dimension: CGFloat = StatusView.hangingAvatarButtonDimension KFImage(viewModel.avatarURL) .placeholder { progress in @@ -85,8 +113,6 @@ extension UserView { .clipShape(AvatarClipShape(avatarStyle: viewModel.avatarStyle)) .animation(.easeInOut, value: viewModel.avatarStyle) } - .buttonStyle(.borderless) - .allowsHitTesting(allowsAvatarButtonHitTesting) } var nameLabel: some View { @@ -120,6 +146,18 @@ extension UserView { Menu { switch viewModel.kind { case .account: + // open in new window + if !viewModel.isMyself, UIApplication.shared.supportsMultipleScenes { + Button { + viewModel.delegate?.userView(viewModel, menuActionDidPressed: .openInNewWindowForAccount) + } label: { + Label { + Text("Open in new window") + } icon: { + Image(systemName: "macwindow.badge.plus") + } + } + } // sign out Button(role: .destructive) { viewModel.delegate?.userView(viewModel, menuActionDidPressed: .signOut) @@ -130,7 +168,17 @@ extension UserView { Image(systemName: "person.crop.circle.badge.minus") } } - + case .listMember: + // remove + Button(role: .destructive) { + viewModel.delegate?.userView(viewModel, menuActionDidPressed: .removeListMember) + } label: { + Label { + Text(L10n.Common.Controls.Actions.remove) + } icon: { + Image(systemName: "minus.circle") + } + } default: EmptyView() } @@ -142,10 +190,24 @@ extension UserView { var membershipButton: some View { Button { -// switch + guard !viewModel.isListMemberCandidate else { return } + guard let user = viewModel.user?.asRecord else { return } + viewModel.delegate?.userView(viewModel, listMembershipButtonDidPressed: user) } label: { -// let systemName = -// Image(systemName: "ellipsis.circle") + let tintColor = viewModel.isListMember ? UIColor.systemRed : Asset.Colors.hightLight.color + let systemName = viewModel.isListMember ? "minus.circle" : "plus.circle" + Image(systemName: systemName) + .foregroundColor(Color(uiColor: tintColor)) + .padding() + .opacity(viewModel.isListMemberCandidate ? 0 : 1) + .overlay { + Group { + if viewModel.isListMemberCandidate { + ProgressView() + .progressViewStyle(.circular) + } + } + } // end overlay } } @@ -153,14 +215,22 @@ extension UserView { HStack(spacing: .zero) { Button { guard !viewModel.isFollowRequestBusy else { return } - viewModel.delegate?.userView(viewModel, followReqeustButtonDidPressed: viewModel.user.asRecord, accept: true) + guard let user = viewModel.user?.asRecord else { + assertionFailure() + return + } + viewModel.delegate?.userView(viewModel, followReqeustButtonDidPressed: user, accept: true) } label: { Image(uiImage: Asset.Indices.checkmarkCircle.image.withRenderingMode(.alwaysTemplate)) .padding() } Button { guard !viewModel.isFollowRequestBusy else { return } - viewModel.delegate?.userView(viewModel, followReqeustButtonDidPressed: viewModel.user.asRecord, accept: false) + guard let user = viewModel.user?.asRecord else { + assertionFailure() + return + } + viewModel.delegate?.userView(viewModel, followReqeustButtonDidPressed: user, accept: false) } label: { Image(uiImage: Asset.Indices.xmarkCircle.image.withRenderingMode(.alwaysTemplate)) .padding() @@ -175,6 +245,13 @@ extension UserView { } } } + + var notificationBadgeCountView: some View { + Group { + let count = max(0, min(viewModel.notificationBadgeCount, 50)) + Image(systemName: "\(count).circle.fill") + } + } } extension UserView { @@ -236,7 +313,12 @@ extension UserView { Group { switch viewModel.kind { case .account: - menuView + HStack { + if viewModel.notificationBadgeCount > 0 { + notificationBadgeCountView + } + menuView + } case .search: // TODO: follow button EmptyView() @@ -251,9 +333,11 @@ extension UserView { case .mentionPick: EmptyView() case .listMember: - EmptyView() + if viewModel.isMyList { + menuView + } case .addListMember: - EmptyView() + membershipButton case .settingAccountSection: Image(systemName: "chevron.right") .foregroundColor(Color(.secondaryLabel)) @@ -370,14 +454,6 @@ extension UserView { // return button // }() // -// // add/remove control -// public let membershipButton: HitTestExpandedButton = { -// let button = HitTestExpandedButton() -// button.setImage(UIImage(systemName: "plus.circle"), for: .normal) -// button.tintColor = Asset.Colors.hightLight.color // FIXME: tint color -// return button -// }() -// // // follow request // public let followRequestControlContainerView = UIStackView() // @@ -779,54 +855,36 @@ extension UserView { #if DEBUG -import SwiftUI +import CoreData +import CoreDataStack + struct UserView_Preview: PreviewProvider { + + static var kinds: [UserView.ViewModel.Kind] = [ + .account, + .search, + .friend, + .history, + // .notification, + .mentionPick, + // .listMember, + // .addListMember, + .settingAccountSection, + .plain + ] + static var previews: some View { - EmptyView() -// Group { -// UIViewPreview { -// let userView = UserView() -// userView.setup(style: .account) -// return userView -// } -// .previewLayout(.fixed(width: 375, height: 48)) -// .previewDisplayName("Account") -// UIViewPreview { -// let userView = UserView() -// userView.setup(style: .relationship) -// return userView -// } -// .previewLayout(.fixed(width: 375, height: 48)) -// .previewDisplayName("Relationship") -// UIViewPreview { -// let userView = UserView() -// userView.setup(style: .friendship) -// return userView -// } -// .previewLayout(.fixed(width: 375, height: 48)) -// .previewDisplayName("Friendship") -// UIViewPreview { -// let userView = UserView() -// userView.setup(style: .notification) -// return userView -// } -// .previewLayout(.fixed(width: 375, height: 48)) -// .previewDisplayName("Notification") -// UIViewPreview { -// let userView = UserView() -// userView.setup(style: .mentionPick) -// return userView -// } -// .previewLayout(.fixed(width: 375, height: 48)) -// .previewDisplayName("MentionPick") -// UIViewPreview { -// let userView = UserView() -// userView.setup(style: .addListMember) -// return userView -// } -// .previewLayout(.fixed(width: 375, height: 48)) -// .previewDisplayName("AddListMember") -// } + List { + ForEach(kinds, id: \.self) { kind in + Section(content: { + UserView(viewModel: .init(kind: kind)) + .padding(.horizontal) + }, header: { + Text("\(String(describing: kind).localizedCapitalized)") + }) + .textCase(nil) + } + } } } #endif diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/Status/StatusViewTableViewCellDelegate.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/Status/StatusViewTableViewCellDelegate.swift index a06d6e8b..d5d663bf 100644 --- a/TwidereSDK/Sources/TwidereUI/TableViewCell/Status/StatusViewTableViewCellDelegate.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/Status/StatusViewTableViewCellDelegate.swift @@ -32,6 +32,7 @@ public protocol StatusViewTableViewCellDelegate: AutoGenerateProtocolDelegate { func tableViewCell(_ cell: UITableViewCell, viewModel: StatusView.ViewModel, pollVoteActionForViewModel pollViewModel: PollView.ViewModel) func tableViewCell(_ cell: UITableViewCell, viewModel: StatusView.ViewModel, pollUpdateIfNeedsForViewModel pollViewModel: PollView.ViewModel) func tableViewCell(_ cell: UITableViewCell, viewModel: StatusView.ViewModel, pollViewModel: PollView.ViewModel, pollOptionDidSelectForViewModel optionViewModel: PollOptionView.ViewModel) + func tableViewCell(_ cell: UITableViewCell, viewModel: StatusView.ViewModel, quoteStatusViewDidPressed quoteViewModel: StatusView.ViewModel) func tableViewCell(_ cell: UITableViewCell, viewModel: StatusView.ViewModel, statusMetricViewModel: StatusMetricView.ViewModel, statusMetricButtonDidPressed action: StatusMetricView.Action) func tableViewCell(_ cell: UITableViewCell, viewModel: StatusView.ViewModel, statusToolbarViewModel: StatusToolbarView.ViewModel, statusToolbarButtonDidPressed action: StatusToolbarView.Action) func tableViewCell(_ cell: UITableViewCell, viewModel: StatusView.ViewModel, viewHeightDidChange: Void) @@ -74,6 +75,10 @@ public extension StatusViewDelegate where Self: StatusViewContainerTableViewCell statusViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, pollViewModel: pollViewModel, pollOptionDidSelectForViewModel: optionViewModel) } + func statusView(_ viewModel: StatusView.ViewModel, quoteStatusViewDidPressed quoteViewModel: StatusView.ViewModel) { + statusViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, quoteStatusViewDidPressed: quoteViewModel) + } + func statusView(_ viewModel: StatusView.ViewModel, statusMetricViewModel: StatusMetricView.ViewModel, statusMetricButtonDidPressed action: StatusMetricView.Action) { statusViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, statusMetricViewModel: statusMetricViewModel, statusMetricButtonDidPressed: action) } diff --git a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserViewTableViewCellDelegate.swift b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserViewTableViewCellDelegate.swift index 451633dc..1a9a56df 100644 --- a/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserViewTableViewCellDelegate.swift +++ b/TwidereSDK/Sources/TwidereUI/TableViewCell/User/UserViewTableViewCellDelegate.swift @@ -23,6 +23,7 @@ public protocol UserViewTableViewCellDelegate: AutoGenerateProtocolDelegate { // sourcery:inline:UserViewTableViewCellDelegate.AutoGenerateProtocolDelegate func tableViewCell(_ cell: UITableViewCell, viewModel: UserView.ViewModel, userAvatarButtonDidPressed user: UserRecord) func tableViewCell(_ cell: UITableViewCell, viewModel: UserView.ViewModel, menuActionDidPressed action: UserView.ViewModel.MenuAction) + func tableViewCell(_ cell: UITableViewCell, viewModel: UserView.ViewModel, listMembershipButtonDidPressed user: UserRecord) func tableViewCell(_ cell: UITableViewCell, viewModel: UserView.ViewModel, followReqeustButtonDidPressed user: UserRecord, accept: Bool) // sourcery:end } @@ -39,6 +40,10 @@ public extension UserViewDelegate where Self: UserViewContainerTableViewCell { userViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, menuActionDidPressed: action) } + func userView(_ viewModel: UserView.ViewModel, listMembershipButtonDidPressed user: UserRecord) { + userViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, listMembershipButtonDidPressed: user) + } + func userView(_ viewModel: UserView.ViewModel, followReqeustButtonDidPressed user: UserRecord, accept: Bool) { userViewTableViewCellDelegate?.tableViewCell(self, viewModel: viewModel, followReqeustButtonDidPressed: user, accept: accept) } diff --git a/TwidereX.xcodeproj/project.pbxproj b/TwidereX.xcodeproj/project.pbxproj index c4b7e5a2..6839e4d6 100644 --- a/TwidereX.xcodeproj/project.pbxproj +++ b/TwidereX.xcodeproj/project.pbxproj @@ -3431,7 +3431,7 @@ CODE_SIGN_ENTITLEMENTS = TwidereX/TwidereX.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 125; + CURRENT_PROJECT_VERSION = 128; DEVELOPMENT_ASSET_PATHS = "TwidereX/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereX/Info.plist; @@ -3458,7 +3458,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = Twidere; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 125; + CURRENT_PROJECT_VERSION = 128; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereXTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -3481,7 +3481,7 @@ baseConfigurationReference = BC377CE93F0DE1E07208F697 /* Pods-TwidereX-TwidereXUITests.profile.xcconfig */; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 125; + CURRENT_PROJECT_VERSION = 128; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereXUITests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -3505,7 +3505,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 125; + CURRENT_PROJECT_VERSION = 128; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -3532,7 +3532,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_ENTITLEMENTS = TwidereXIntent/TwidereXIntent.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 125; + CURRENT_PROJECT_VERSION = 128; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereXIntent/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TwidereXIntent; @@ -3561,7 +3561,7 @@ CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 125; + CURRENT_PROJECT_VERSION = 128; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = NotificationService/Info.plist; @@ -3590,7 +3590,7 @@ CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 125; + CURRENT_PROJECT_VERSION = 128; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Twidere. All rights reserved."; @@ -3618,7 +3618,7 @@ CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 125; + CURRENT_PROJECT_VERSION = 128; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Twidere. All rights reserved."; @@ -3644,7 +3644,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 125; + CURRENT_PROJECT_VERSION = 128; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -3671,7 +3671,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 125; + CURRENT_PROJECT_VERSION = 128; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -3825,7 +3825,7 @@ CODE_SIGN_ENTITLEMENTS = TwidereX/TwidereX.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 125; + CURRENT_PROJECT_VERSION = 128; DEVELOPMENT_ASSET_PATHS = "TwidereX/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereX/Info.plist; @@ -3857,7 +3857,7 @@ CODE_SIGN_ENTITLEMENTS = TwidereX/TwidereX.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 125; + CURRENT_PROJECT_VERSION = 128; DEVELOPMENT_ASSET_PATHS = "TwidereX/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereX/Info.plist; @@ -3884,7 +3884,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = Twidere; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 125; + CURRENT_PROJECT_VERSION = 128; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereXTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -3909,7 +3909,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = Twidere; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 125; + CURRENT_PROJECT_VERSION = 128; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereXTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -3932,7 +3932,7 @@ baseConfigurationReference = 2E41729E598B3379FC09BC03 /* Pods-TwidereX-TwidereXUITests.debug.xcconfig */; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 125; + CURRENT_PROJECT_VERSION = 128; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereXUITests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -3955,7 +3955,7 @@ baseConfigurationReference = 44D01BA9E21B7F374DBDA38A /* Pods-TwidereX-TwidereXUITests.release.xcconfig */; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 125; + CURRENT_PROJECT_VERSION = 128; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereXUITests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -3979,7 +3979,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_ENTITLEMENTS = TwidereXIntent/TwidereXIntent.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 125; + CURRENT_PROJECT_VERSION = 128; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereXIntent/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TwidereXIntent; @@ -4006,7 +4006,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_ENTITLEMENTS = TwidereXIntent/TwidereXIntent.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 125; + CURRENT_PROJECT_VERSION = 128; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = TwidereXIntent/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TwidereXIntent; diff --git a/TwidereX.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TwidereX.xcworkspace/xcshareddata/swiftpm/Package.resolved index f9f16352..049913c7 100644 --- a/TwidereX.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TwidereX.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -258,8 +258,8 @@ "repositoryURL": "https://github.com/TwidereProject/TwitterSDK.git", "state": { "branch": null, - "revision": "94732843985776a7b59d8e24cb4e026f20ae8942", - "version": "0.4.0" + "revision": "cf2f33c96e7b9f86dbee23111cd523f9a9294099", + "version": "0.7.0" } }, { diff --git a/TwidereX/Coordinator/SceneCoordinator.swift b/TwidereX/Coordinator/SceneCoordinator.swift index 044cace8..8f948a88 100644 --- a/TwidereX/Coordinator/SceneCoordinator.swift +++ b/TwidereX/Coordinator/SceneCoordinator.swift @@ -22,6 +22,8 @@ final public class SceneCoordinator { private(set) weak var sceneDelegate: SceneDelegate! private(set) weak var context: AppContext! + private(set) var authContext: AuthContext? + let id = UUID().uuidString // output @@ -147,8 +149,11 @@ extension SceneCoordinator { transition: .modal(animated: false) ) // entry #1: Welcome } + self.authContext = nil return } + + self.authContext = authContext switch UIDevice.current.userInterfaceIdiom { case .phone: @@ -169,6 +174,7 @@ extension SceneCoordinator { } catch { assertionFailure(error.localizedDescription) + self.authContext = nil Task { try? await Task.sleep(nanoseconds: .second * 2) setup() // entry #3: retry diff --git a/TwidereX/Info.plist b/TwidereX/Info.plist index 697cf1d3..33f4967d 100644 --- a/TwidereX/Info.plist +++ b/TwidereX/Info.plist @@ -40,7 +40,7 @@ CFBundleShortVersionString 2.0.0 CFBundleVersion - 125 + 128 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS @@ -62,7 +62,7 @@ UIApplicationSceneManifest UIApplicationSupportsMultipleScenes - + UISceneConfigurations UIWindowSceneSessionRoleApplication diff --git a/TwidereX/Protocol/Facade/DataSourceFacade+Media.swift b/TwidereX/Protocol/Facade/DataSourceFacade+Media.swift index 570e9f8a..fa3ada5d 100644 --- a/TwidereX/Protocol/Facade/DataSourceFacade+Media.swift +++ b/TwidereX/Protocol/Facade/DataSourceFacade+Media.swift @@ -45,7 +45,7 @@ extension DataSourceFacade { preloadThumbnails: thumbnails )), mediaPreviewTransitionItem: { - let source = MediaPreviewTransitionItem.Source.mediaView(mediaViewModel) + let source = MediaPreviewTransitionItem.Source.mediaView(mediaViewModel, viewModels: statusViewModel.mediaViewModels) let item = MediaPreviewTransitionItem( source: source, previewableViewController: provider diff --git a/TwidereX/Protocol/Facade/DataSourceFacade+Status.swift b/TwidereX/Protocol/Facade/DataSourceFacade+Status.swift index d14c4c2d..4e789043 100644 --- a/TwidereX/Protocol/Facade/DataSourceFacade+Status.swift +++ b/TwidereX/Protocol/Facade/DataSourceFacade+Status.swift @@ -266,8 +266,8 @@ extension DataSourceFacade { try await managedObjectContext.performChanges { guard let object = status.object(in: managedObjectContext) else { return } switch object { - case .twitter: - break + case .twitter(let status): + status.update(isMediaSensitiveToggled: !status.isMediaSensitiveToggled) case .mastodon(let status): status.update(isMediaSensitiveToggled: !status.isMediaSensitiveToggled) } diff --git a/TwidereX/Protocol/Provider/DataSourceProvider+StatusViewTableViewCellDelegate.swift b/TwidereX/Protocol/Provider/DataSourceProvider+StatusViewTableViewCellDelegate.swift index 4ed055fd..f3f9d9b6 100644 --- a/TwidereX/Protocol/Provider/DataSourceProvider+StatusViewTableViewCellDelegate.swift +++ b/TwidereX/Protocol/Provider/DataSourceProvider+StatusViewTableViewCellDelegate.swift @@ -127,7 +127,6 @@ extension StatusViewTableViewCellDelegate where Self: DataSourceProvider & AuthC // MARK: - media extension StatusViewTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider & MediaPreviewableViewController { - @MainActor func tableViewCell( _ cell: UITableViewCell, @@ -158,26 +157,6 @@ extension StatusViewTableViewCellDelegate where Self: DataSourceProvider & AuthC ) } // end Task } - - -// func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, mediaGridContainerView containerView: MediaGridContainerView, toggleContentWarningOverlayViewDisplay contentWarningOverlayView: ContentWarningOverlayView) { -// Task { -// let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) -// guard let item = await item(from: source) else { -// assertionFailure() -// return -// } -// guard let status = await item.status(in: self.context.managedObjectContext) else { -// assertionFailure("only works for status data provider") -// return -// } -// try await DataSourceFacade.responseToToggleMediaSensitiveAction( -// provider: self, -// target: .status, -// status: status -// ) -// } -// } } // MARK: - poll @@ -275,24 +254,19 @@ extension StatusViewTableViewCellDelegate where Self: DataSourceProvider & AuthC // MARK: - quote extension StatusViewTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider { -// func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, quoteStatusViewDidPressed quoteStatusView: StatusView) { -// Task { -// let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) -// guard let item = await item(from: source) else { -// return -// } -// guard let status = await item.status(in: self.context.managedObjectContext) else { -// assertionFailure("only works for status data provider") -// return -// } -// -// await DataSourceFacade.coordinateToStatusThreadScene( -// provider: self, -// target: .quote, -// status: status -// ) -// } -// } + func tableViewCell( + _ cell: UITableViewCell, + viewModel: StatusView.ViewModel, + quoteStatusViewDidPressed quoteViewModel: StatusView.ViewModel + ) { + guard let status = quoteViewModel.status?.asRecord else { return } + Task { + await DataSourceFacade.coordinateToStatusThreadScene( + provider: self, + kind: .status(status) + ) + } // end Task + } } // MARK: - metric diff --git a/TwidereX/Protocol/Provider/DataSourceProvider+UserViewTableViewCellDelegate.swift b/TwidereX/Protocol/Provider/DataSourceProvider+UserViewTableViewCellDelegate.swift index 3bd1d7bf..6bafb393 100644 --- a/TwidereX/Protocol/Provider/DataSourceProvider+UserViewTableViewCellDelegate.swift +++ b/TwidereX/Protocol/Provider/DataSourceProvider+UserViewTableViewCellDelegate.swift @@ -7,6 +7,8 @@ // import UIKit +import CoreData +import CoreDataStack import SwiftMessages // MARK: - avatar button @@ -34,47 +36,59 @@ extension UserViewTableViewCellDelegate where Self: DataSourceProvider { menuActionDidPressed action: UserView.ViewModel.MenuAction ) { switch action { + case .openInNewWindowForAccount: + guard let userRecord = viewModel.user?.asRecord else { return } + guard let requestingScene = self.view.window?.windowScene else { return } + Task { @MainActor in + let _record: ManagedObjectRecord? = await context.managedObjectContext.perform { + guard let user = userRecord.object(in: self.context.managedObjectContext) else { return nil } + return user.authenticationIndex?.asRecrod + } + guard let record = _record else { return } + try SceneDelegate.openSceneSessionForAccount(record, fromRequestingScene: requestingScene) + } // end Task case .signOut: Task { + guard let user = viewModel.user?.asRecord else { + assertionFailure() + return + } try await DataSourceFacade.responseToUserSignOut( dependency: self, - user: viewModel.user.asRecord + user: user ) } // end Task - case .remove: - assertionFailure("Override in view controller") - } - } - -// func tableViewCell( -// _ cell: UITableViewCell, -// userView: UserView, -// menuActionDidPressed action: UserView.MenuAction, -// menuButton button: UIButton -// ) { -// switch action { -// case .signOut: -// // TODO: move to view controller -// Task { -// let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) -// guard let item = await item(from: source) else { -// assertionFailure() -// return -// } -// guard case let .user(user) = item else { -// assertionFailure("only works for user data") -// return -// } -// try await DataSourceFacade.responseToUserSignOut( -// dependency: self, -// user: user -// ) -// } // end Task -// case .remove: -// assertionFailure("Override in view controller") -// } // end swtich -// } - + + case .removeListMember: + Task { @MainActor in + guard !viewModel.isListMemberCandidate else { return } + guard let authenticationContext = viewModel.authContext?.authenticationContext else { return } + guard let listMembershipViewModel = viewModel.listMembershipViewModel else { return } + guard let user = viewModel.user?.asRecord else { return } + do { + try await listMembershipViewModel.remove(user: user, authenticationContext: authenticationContext) + + var config = SwiftMessages.defaultConfig + config.duration = .seconds(seconds: 3) + config.interactiveHide = true + let bannerView = NotificationBannerView() + bannerView.configure(style: .success) + bannerView.titleLabel.text = L10n.Common.Alerts.ListMemberRemoved.title + bannerView.messageLabel.isHidden = true + SwiftMessages.show(config: config, view: bannerView) + } catch { + var config = SwiftMessages.defaultConfig + config.duration = .seconds(seconds: 3) + config.interactiveHide = true + let bannerView = NotificationBannerView() + bannerView.configure(style: .warning) + bannerView.titleLabel.text = L10n.Common.Alerts.FailedToRemoveListMember.title + bannerView.messageLabel.text = error.localizedDescription + SwiftMessages.show(config: config, view: bannerView) + } + } // end Task + } // end switch + } } // MARK: - friendship button @@ -92,53 +106,38 @@ extension UserViewTableViewCellDelegate where Self: DataSourceProvider { // MARK: - membership extension UserViewTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider { + func tableViewCell( + _ cell: UITableViewCell, + viewModel: UserView.ViewModel, + listMembershipButtonDidPressed user: UserRecord + ) { + guard !viewModel.isListMemberCandidate else { + return + } -// func tableViewCell( -// _ cell: UITableViewCell, -// userView: UserView, -// membershipButtonDidPressed button: UIButton -// ) { -// guard !userView.viewModel.isListMemberCandidate else { -// return -// } -// -// Task { @MainActor in -// let authenticationContext = self.authContext.authenticationContext -// -// let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) -// guard let item = await item(from: source) else { -// assertionFailure() -// return -// } -// guard case let .user(user) = item else { -// assertionFailure("only works for user data") -// return -// } -// -// guard let listMembershipViewModel = userView.viewModel.listMembershipViewModel else { -// assertionFailure() -// return -// } -// -// do { -// if userView.viewModel.isListMember { -// try await listMembershipViewModel.remove(user: user, authenticationContext: authenticationContext) -// } else { -// try await listMembershipViewModel.add(user: user, authenticationContext: authenticationContext) -// } -// } catch { -// var config = SwiftMessages.defaultConfig -// config.duration = .seconds(seconds: 3) -// config.interactiveHide = true -// let bannerView = NotificationBannerView() -// bannerView.configure(style: .warning) -// bannerView.titleLabel.text = L10n.Common.Alerts.FailedToAddListMember.title -// bannerView.messageLabel.text = error.localizedDescription -// SwiftMessages.show(config: config, view: bannerView) -// } -// } // end Task -// } - + Task { @MainActor in + guard !viewModel.isListMemberCandidate else { return } + guard let authenticationContext = viewModel.authContext?.authenticationContext else { return } + guard let listMembershipViewModel = viewModel.listMembershipViewModel else { return } + guard let user = viewModel.user?.asRecord else { return } + do { + if viewModel.isListMember { + try await listMembershipViewModel.remove(user: user, authenticationContext: authenticationContext) + } else { + try await listMembershipViewModel.add(user: user, authenticationContext: authenticationContext) + } + } catch { + var config = SwiftMessages.defaultConfig + config.duration = .seconds(seconds: 3) + config.interactiveHide = true + let bannerView = NotificationBannerView() + bannerView.configure(style: .warning) + bannerView.titleLabel.text = viewModel.isListMember ? L10n.Common.Alerts.FailedToRemoveListMember.title : L10n.Common.Alerts.FailedToAddListMember.title + bannerView.messageLabel.text = error.localizedDescription + SwiftMessages.show(config: config, view: bannerView) + } + } // end Task + } } // MARK: - follow request diff --git a/TwidereX/Scene/List/ListUser/ListUserViewController.swift b/TwidereX/Scene/List/ListUser/ListUserViewController.swift index 92f90c14..a38cc62d 100644 --- a/TwidereX/Scene/List/ListUser/ListUserViewController.swift +++ b/TwidereX/Scene/List/ListUser/ListUserViewController.swift @@ -91,6 +91,8 @@ extension ListUserViewController { self.viewModel.stateMachine.enter(ListUserViewModel.State.Loading.self) } .store(in: &disposeBag) + + viewModel.listMembershipViewModel.delegate = self } @@ -133,75 +135,21 @@ extension ListUserViewController: UITableViewDelegate, AutoGenerateTableViewDele } // MARK: - UserViewTableViewCellDelegate -extension ListUserViewController: UserViewTableViewCellDelegate { -// func tableViewCell( -// _ cell: UITableViewCell, -// userView: UserView, -// menuActionDidPressed action: UserView.MenuAction, -// menuButton button: UIButton -// ) { -// switch action { -// case .remove: -// Task { -// let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) -// guard let item = await item(from: source) else { -// assertionFailure() -// return -// } -// guard case let .user(user) = item else { -// assertionFailure("only works for status data provider") -// return -// } -// -// let authenticationContext = self.viewModel.authContext.authenticationContext -// -// do { -// let list = self.viewModel.kind.list -// _ = try await self.context.apiService.removeListMember( -// list: list, -// user: user, -// authenticationContext: authenticationContext -// ) -// await self.viewModel.update(user: user, action: .remove) -// -// var config = SwiftMessages.defaultConfig -// config.duration = .seconds(seconds: 3) -// config.interactiveHide = true -// let bannerView = NotificationBannerView() -// bannerView.configure(style: .success) -// bannerView.titleLabel.text = L10n.Common.Alerts.ListMemberRemoved.title -// bannerView.messageLabel.isHidden = true -// SwiftMessages.show(config: config, view: bannerView) -// } catch { -// var config = SwiftMessages.defaultConfig -// config.duration = .seconds(seconds: 3) -// config.interactiveHide = true -// let bannerView = NotificationBannerView() -// bannerView.configure(style: .warning) -// bannerView.titleLabel.text = L10n.Common.Alerts.FailedToRemoveListMember.title -// bannerView.messageLabel.text = L10n.Common.Alerts.FailedToRemoveListMember.message -// SwiftMessages.show(config: config, view: bannerView) -// } -// } // end Task -// default: -// assertionFailure() -// } // end swtich -// } -} +extension ListUserViewController: UserViewTableViewCellDelegate { } // MARK: - ListMembershipViewModelDelegate extension ListUserViewController: ListMembershipViewModelDelegate { func listMembershipViewModel(_ viewModel: ListMembershipViewModel, didAddUser user: UserRecord) { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - Task { + Task { @MainActor in await self.viewModel.update(user: user, action: .add) } // end Task } func listMembershipViewModel(_ viewModel: ListMembershipViewModel, didRemoveUser user: UserRecord) { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - Task { + Task { @MainActor in await self.viewModel.update(user: user, action: .remove) } // end Task } diff --git a/TwidereX/Scene/List/ListUser/ListUserViewModel+Diffable.swift b/TwidereX/Scene/List/ListUser/ListUserViewModel+Diffable.swift index cf21595d..bc0af447 100644 --- a/TwidereX/Scene/List/ListUser/ListUserViewModel+Diffable.swift +++ b/TwidereX/Scene/List/ListUser/ListUserViewModel+Diffable.swift @@ -32,7 +32,9 @@ extension ListUserViewModel { snapshot.appendSections([.main]) - let items = records.map { UserItem.user(record: $0, kind: .listMember) } + let items = records.map { + UserItem.user(record: $0, kind: .listMember(self.listMembershipViewModel)) + } snapshot.appendItems(items, toSection: .main) let currentState = await self.stateMachine.currentState diff --git a/TwidereX/Scene/MediaPreview/MediaPreviewViewController.swift b/TwidereX/Scene/MediaPreview/MediaPreviewViewController.swift index f36591a1..8936ae3f 100644 --- a/TwidereX/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/TwidereX/Scene/MediaPreview/MediaPreviewViewController.swift @@ -122,7 +122,8 @@ extension MediaPreviewViewController { visualEffectView.contentView.addSubview(pageControlBackgroundVisualEffectView) NSLayoutConstraint.activate([ pageControlBackgroundVisualEffectView.centerXAnchor.constraint(equalTo: mediaInfoDescriptionView.centerXAnchor), - mediaInfoDescriptionView.topAnchor.constraint(equalTo: pageControlBackgroundVisualEffectView.bottomAnchor, constant: 8), + view.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: pageControlBackgroundVisualEffectView.bottomAnchor, constant: 16), + // mediaInfoDescriptionView.topAnchor.constraint(equalTo: pageControlBackgroundVisualEffectView.bottomAnchor, constant: 8), ]) pageControl.translatesAutoresizingMaskIntoConstraints = false @@ -165,29 +166,24 @@ extension MediaPreviewViewController { closeButton.addTarget(self, action: #selector(MediaPreviewViewController.closeButtonPressed(_:)), for: .touchUpInside) // bind view model -// viewModel.$currentPage -// .receive(on: DispatchQueue.main) -// .sink { [weak self] index in -// guard let self = self else { return } -// // update page control -// self.pageControl.currentPage = index -// -// // update mediaGridContainerView -// switch self.viewModel.transitionItem.source { -// case .none: -// break -// case .attachment: -// break -// case .attachments(let mediaGridContainerView): -// UIView.animate(withDuration: 0.3) { -// mediaGridContainerView.setAlpha(1) -// mediaGridContainerView.setAlpha(0, index: index) -// } -// case .profileAvatar, .profileBanner: -// break -// } -// } -// .store(in: &disposeBag) + viewModel.$currentPage + .receive(on: DispatchQueue.main) + .sink { [weak self] index in + guard let self = self else { return } + // update page control + self.pageControl.currentPage = index + + // update mediaGridContainerView + switch self.viewModel.transitionItem.source { + case .none: + break + case .mediaView: + self.viewModel.transitionItem.source.updateAppearance(position: .current, index: index) + case .profileAvatar, .profileBanner: + break + } + } + .store(in: &disposeBag) viewModel.$currentPage .receive(on: DispatchQueue.main) diff --git a/TwidereX/Scene/Onboarding/Welcome/Twitter/PinBased/TwitterPinBasedAuthenticationViewController.swift b/TwidereX/Scene/Onboarding/Welcome/Twitter/PinBased/TwitterPinBasedAuthenticationViewController.swift index e2a1f3fb..f50c6259 100644 --- a/TwidereX/Scene/Onboarding/Welcome/Twitter/PinBased/TwitterPinBasedAuthenticationViewController.swift +++ b/TwidereX/Scene/Onboarding/Welcome/Twitter/PinBased/TwitterPinBasedAuthenticationViewController.swift @@ -19,7 +19,11 @@ final class TwitterPinBasedAuthenticationViewController: UIViewController, Needs var disposeBag = Set() var viewModel: TwitterPinBasedAuthenticationViewModel! - let webView = WKWebView() + lazy var webView: WKWebView = { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + return WKWebView(frame: view.bounds, configuration: configuration) + }() deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) diff --git a/TwidereX/Scene/Onboarding/Welcome/WelcomeViewController.swift b/TwidereX/Scene/Onboarding/Welcome/WelcomeViewController.swift index 0459002f..31effd3c 100644 --- a/TwidereX/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/TwidereX/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -202,7 +202,7 @@ extension WelcomeViewController: WelcomeViewModelDelegate { .sink { [weak self] authenticationSession in guard let self = self else { return } guard let authenticationSession = authenticationSession else { return } - authenticationSession.prefersEphemeralWebBrowserSession = false + authenticationSession.prefersEphemeralWebBrowserSession = true authenticationSession.presentationContextProvider = self authenticationSession.start() } diff --git a/TwidereX/Scene/Search/SearchResult/User/SearchUserViewModel+Diffable.swift b/TwidereX/Scene/Search/SearchResult/User/SearchUserViewModel+Diffable.swift index 2014d64d..a1affa0e 100644 --- a/TwidereX/Scene/Search/SearchResult/User/SearchUserViewModel+Diffable.swift +++ b/TwidereX/Scene/Search/SearchResult/User/SearchUserViewModel+Diffable.swift @@ -55,7 +55,7 @@ extension SearchUserViewModel { case .search: return .user(record: record, kind: .search) case .listMember: - return .user(record: record, kind: .addListMember) + return .user(record: record, kind: .addListMember(self.listMembershipViewModel)) } // end switch } snapshot.appendItems(newItems, toSection: .main) diff --git a/TwidereX/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/TwidereX/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift index b12bb5df..d69bc375 100644 --- a/TwidereX/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +++ b/TwidereX/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -187,7 +187,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { animator.addCompletion { position in if position == .end { // reset appearance - self.transitionItem.source.updateAppearance(position: position, index: nil) + self.transitionItem.source.updateAppearance(position: position, index: fromVC.viewModel.currentPage) } } @@ -244,44 +244,9 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { } // calculate transition mask -// let maskLayerToRect: CGRect? = { -// guard case .attachments = transitionItem.source else { return nil } -// guard let navigationBar = toVC.navigationController?.navigationBar, let navigationBarSuperView = navigationBar.superview else { return nil } -// let navigationBarFrameInWindow = navigationBarSuperView.convert(navigationBar.frame, to: nil) -// -// // crop rect top edge -// var rect = transitionMaskView.frame -// let _toViewFrameInWindow = toVC.view.superview.flatMap { $0.convert(toVC.view.frame, to: nil) } -// if let toViewFrameInWindow = _toViewFrameInWindow, toViewFrameInWindow.minY > navigationBarFrameInWindow.maxY { -// rect.origin.y = toViewFrameInWindow.minY -// } else { -// rect.origin.y = navigationBarFrameInWindow.maxY + UIView.separatorLineHeight(of: toVC.view) // extra hairline -// } -// -// return rect -// }() -// let maskLayerToPath = maskLayerToRect.flatMap { UIBezierPath(rect: $0) }?.cgPath -// let maskLayerToFinalRect: CGRect? = { -// guard case .attachments = transitionItem.source else { return nil } -// var rect = maskLayerToRect ?? transitionMaskView.frame -// // clip tabBar when bar visible -// guard let tabBarController = toVC.tabBarController, -// !tabBarController.tabBar.isHidden, -// let tabBarSuperView = tabBarController.tabBar.superview -// else { return rect } -// let tabBarFrameInWindow = tabBarSuperView.convert(tabBarController.tabBar.frame, to: nil) -// let offset = rect.maxY - tabBarFrameInWindow.minY -// guard offset > 0 else { return rect } -// rect.size.height -= offset -// return rect -// }() -// -// // FIXME: -// let maskLayerToFinalPath = maskLayerToFinalRect.flatMap { UIBezierPath(rect: $0) }?.cgPath -// -// if let maskLayerToPath = maskLayerToPath { -// maskLayer.path = maskLayerToPath -// } + if let path = createTransitionItemMaskLayerPath(transitionContext: transitionContext) { + transitionItem.interactiveTransitionMaskLayer?.path = path + } } mediaPreviewTransitionContext.transitionView.isHidden = true @@ -413,60 +378,16 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { let velocity = convert(gestureVelocity, for: transitionItem) let itemAnimator = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.animator(initialVelocity: velocity) - var maskLayerToFinalPath: CGPath? - if toPosition == .end, - let transitionMaskView = transitionItem.interactiveTransitionMaskView, - let snapshot = transitionItem.snapshotTransitioning { - let toVC = transitionItem.previewableViewController - - var needsMaskWithAnimation = true -// let maskLayerToRect: CGRect? = { -// guard case .attachments = transitionItem.source else { return nil } -// guard let navigationBar = toVC.navigationController?.navigationBar, let navigationBarSuperView = navigationBar.superview else { return nil } -// let navigationBarFrameInWindow = navigationBarSuperView.convert(navigationBar.frame, to: nil) -// -// // crop rect top edge -// var rect = transitionMaskView.frame -// let _toViewFrameInWindow = toVC.view.superview.flatMap { $0.convert(toVC.view.frame, to: nil) } -// if let toViewFrameInWindow = _toViewFrameInWindow, toViewFrameInWindow.minY > navigationBarFrameInWindow.maxY { -// rect.origin.y = toViewFrameInWindow.minY -// } else { -// rect.origin.y = navigationBarFrameInWindow.maxY + UIView.separatorLineHeight(of: toVC.view) // extra hairline -// } -// -// if rect.minY < snapshot.frame.minY { -// needsMaskWithAnimation = false -// } -// -// return rect -// }() -// let maskLayerToPath = maskLayerToRect.flatMap { UIBezierPath(rect: $0) }?.cgPath -// -// if let maskLayer = transitionItem.interactiveTransitionMaskLayer, !needsMaskWithAnimation { -// maskLayer.path = maskLayerToPath -// } -// -// let maskLayerToFinalRect: CGRect? = { -// guard case .attachments = transitionItem.source else { return nil } -// var rect = maskLayerToRect ?? transitionMaskView.frame -// // clip rect bottom when tabBar visible -// guard let tabBarController = toVC.tabBarController, -// !tabBarController.tabBar.isHidden, -// let tabBarSuperView = tabBarController.tabBar.superview -// else { return rect } -// let tabBarFrameInWindow = tabBarSuperView.convert(tabBarController.tabBar.frame, to: nil) -// let offset = rect.maxY - tabBarFrameInWindow.minY -// guard offset > 0 else { return rect } -// rect.size.height -= offset -// return rect -// }() -// maskLayerToFinalPath = maskLayerToFinalRect.flatMap { UIBezierPath(rect: $0) }?.cgPath - } + // create the mask path and apply it in the to .end animation + let maskLayerPath: CGPath? = { + guard toPosition == .end else { return nil } + let path = createTransitionItemMaskLayerPath(transitionContext: transitionContext) + return path + }() itemAnimator.addAnimations { - if let maskLayer = self.transitionItem.interactiveTransitionMaskLayer, - let maskLayerToFinalPath = maskLayerToFinalPath { - maskLayer.path = maskLayerToFinalPath + if let path = maskLayerPath { + self.transitionItem.interactiveTransitionMaskLayer?.path = path } if toPosition == .end { switch self.transitionItem.source { @@ -540,3 +461,66 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { } } + +extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { + private func createTransitionItemMaskLayerPath(transitionContext: UIViewControllerContextTransitioning) -> CGPath? { + guard let interactiveTransitionMaskView = transitionItem.interactiveTransitionMaskView else { return nil } + guard let snapshotTransitioning = transitionItem.snapshotTransitioning else { return nil } + + switch transitionItem.source { + case .mediaView: break + case .profileAvatar: return nil + case .profileBanner: return nil + case .none: return nil + } + + // cutoff top navigation bar + let navigationBarCutoffMaskRect: CGRect? = { + let toVC = transitionItem.previewableViewController + guard let navigationBar = toVC.navigationController?.navigationBar, + let navigationBarSuperView = navigationBar.superview + else { return nil } + let navigationBarFrameInWindow = navigationBarSuperView.convert(navigationBar.frame, to: nil) + + var rect = interactiveTransitionMaskView.frame + let _toViewFrameInWindow = toVC.view.superview.flatMap { $0.convert(toVC.view.frame, to: nil) } + if let toViewFrameInWindow = _toViewFrameInWindow, toViewFrameInWindow.minY > navigationBarFrameInWindow.maxY { + rect.origin.y = toViewFrameInWindow.minY + } else { + rect.origin.y = navigationBarFrameInWindow.maxY + UIView.separatorLineHeight(of: toVC.view) // extra hairline + } + + guard snapshotTransitioning.frame.minY > rect.minY else { + return nil + } + return rect + }() + + // cutoff tabBar when bar visible + let tabBarCutoffMaskRect: CGRect? = { + let toVC = transitionItem.previewableViewController + guard let tabBarController = toVC.tabBarController, + !tabBarController.tabBar.isHidden, + let tabBarSuperView = tabBarController.tabBar.superview + else { return nil } + let tabBarFrameInWindow = tabBarSuperView.convert(tabBarController.tabBar.frame, to: nil) + + var rect = interactiveTransitionMaskView.frame + let offset = rect.maxY - tabBarFrameInWindow.minY + guard offset > 0 else { return nil } + rect.size.height -= offset + return rect + }() + + var rect = interactiveTransitionMaskView.frame + let cutoffRects: [CGRect] = [ + navigationBarCutoffMaskRect ?? interactiveTransitionMaskView.frame, + tabBarCutoffMaskRect ?? interactiveTransitionMaskView.frame + ] + for cutoffRect in cutoffRects { + rect = rect.intersection(cutoffRect) + } + + return UIBezierPath(rect: rect).cgPath + } +} diff --git a/TwidereX/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift b/TwidereX/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift index 4fcdf2b8..3d4cfc18 100644 --- a/TwidereX/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift +++ b/TwidereX/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift @@ -46,7 +46,7 @@ class MediaPreviewTransitionItem: Identifiable { extension MediaPreviewTransitionItem { enum Source { case none - case mediaView(MediaView.ViewModel) + case mediaView(MediaView.ViewModel, viewModels: [MediaView.ViewModel]) case profileAvatar(ProfileHeaderView) case profileBanner(ProfileHeaderView) @@ -57,8 +57,14 @@ extension MediaPreviewTransitionItem { switch self { case .none: break - case .mediaView(let viewModel): - viewModel.shouldHideForTransitioning = position != .end + case .mediaView(let viewModel, let viewModels): + let shouldHideForTransitioning = position != .end + viewModels.forEach { $0.shouldHideForTransitioning = false } + if let index = index, let viewModel = viewModels[safe: index] { + viewModel.shouldHideForTransitioning = shouldHideForTransitioning + } else { + viewModel.shouldHideForTransitioning = shouldHideForTransitioning + } case .profileAvatar(let view): // TODO: break diff --git a/TwidereX/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift b/TwidereX/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift index 8583f199..a3d0b914 100644 --- a/TwidereX/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift +++ b/TwidereX/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift @@ -24,9 +24,13 @@ extension MediaPreviewableViewController { height: 44 ) return frame - case .mediaView(let mediaViewModel): - guard mediaViewModel.frameInWindow != .zero else { return nil } - return mediaViewModel.frameInWindow + case .mediaView(let mediaViewModel, let viewModels): + guard let _viewModel = viewModels[safe: index] else { + guard mediaViewModel.frameInWindow != .zero else { return nil } + return mediaViewModel.frameInWindow + } + guard _viewModel.frameInWindow != .zero else { return nil } + return _viewModel.frameInWindow case .profileAvatar: return nil // TODO: case .profileBanner: diff --git a/TwidereX/Supporting Files/SceneDelegate.swift b/TwidereX/Supporting Files/SceneDelegate.swift index baa2a997..e39aa675 100644 --- a/TwidereX/Supporting Files/SceneDelegate.swift +++ b/TwidereX/Supporting Files/SceneDelegate.swift @@ -11,6 +11,7 @@ import Combine import Intents import FPSIndicator import CoreDataStack +import CoreData class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -58,7 +59,15 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let sceneCoordinator = SceneCoordinator(scene: scene, sceneDelegate: self, context: AppContext.shared) self.coordinator = sceneCoordinator - sceneCoordinator.setup() + let userActivity = connectionOptions.userActivities.first ?? session.stateRestorationActivity + if userActivity?.activityType == UserActivity.openNewWindowActivityType, + let objectIDURI = userActivity?.userInfo?[UserActivity.sessionUserInfoAuthenticationIndexObjectIDKey] as? URL, + let objectID = AppContext.shared.managedObjectContext.persistentStoreCoordinator?.managedObjectID(forURIRepresentation: objectIDURI) + { + sceneCoordinator.setup(authentication: ManagedObjectRecord(objectID: objectID)) + } else { + sceneCoordinator.setup() + } window.makeKeyAndVisible() @@ -150,34 +159,25 @@ extension SceneDelegate { switch shortcutItem.type { case "com.twidere.TwidereX.compose": + guard let authContext = coordinator.authContext else { return false } + if let topMost = topMostViewController(), topMost.isModal { topMost.dismiss(animated: false) } let composeViewModel = ComposeViewModel(context: coordinator.context) - assertionFailure("TODO: check authContext and handle alert") -// let composeContentViewModel = ComposeContentViewModel( -// context: .shared, -// authContext: <#T##AuthContext#>, -// kind: .post, -// configurationContext: .init( -// apiService: coordinator.context.apiService, -// authenticationService: coordinator.context.authenticationService, -// mastodonEmojiService: coordinator.context.mastodonEmojiService, -// statusViewConfigureContext: .init( -// dateTimeProvider: DateTimeSwiftProvider(), -// twitterTextProvider: OfficialTwitterTextProvider(), -// authenticationContext: coordinator.context.authenticationService.$activeAuthenticationContext -// ) -// ) -// ) -// coordinator.present( -// scene: .compose( -// viewModel: composeViewModel, -// contentViewModel: composeContentViewModel -// ), -// from: nil, -// transition: .modal(animated: true) -// ) + let composeContentViewModel = ComposeContentViewModel( + context: coordinator.context, + authContext: authContext, + kind: .post + ) + coordinator.present( + scene: .compose( + viewModel: composeViewModel, + contentViewModel: composeContentViewModel + ), + from: nil, + transition: .modal(animated: true) + ) return true case "com.twidere.TwidereX.search": if let topMost = topMostViewController(), topMost.isModal { @@ -213,6 +213,62 @@ extension SceneDelegate { } +extension SceneDelegate { + + public class func openSceneSessionForAccount( + _ record: ManagedObjectRecord, + fromRequestingScene requestingScene: UIWindowScene + ) throws { + let options = UIWindowScene.ActivationRequestOptions() + options.preferredPresentationStyle = .prominent + options.requestingScene = requestingScene + + if let activeSceneSession = Self.activeSceneSessionForAccount(record) { + UIApplication.shared.requestSceneSessionActivation( + activeSceneSession, // reuse old one + userActivity: nil, // ignore for actived session + options: options + ) + } else { + let userActivity = record.openNewWindowUserActivity + UIApplication.shared.requestSceneSessionActivation( + nil, // create new one + userActivity: userActivity, + options: options + ) + } + } + + class func activeSceneSessionForAccount(_ record: ManagedObjectRecord) -> UISceneSession? { + for openSession in UIApplication.shared.openSessions where openSession.configuration.delegateClass == SceneDelegate.self { + guard let userInfo = openSession.userInfo, + let objectIDURI = userInfo[UserActivity.sessionUserInfoAuthenticationIndexObjectIDKey] as? URL, + objectIDURI == record.objectID.uriRepresentation() + else { continue } + return openSession + } // end for … in + + return nil + } +} + +struct UserActivity { + static var openNewWindowActivityType: String { "com.twidere.TwidereX.openNewWindow" } + + static var sessionUserInfoAuthenticationIndexObjectIDKey: String { "authenticationIndex.objectID" } +} + +extension ManagedObjectRecord where T: AuthenticationIndex { + var openNewWindowUserActivity: NSUserActivity { + let userActivity = NSUserActivity(activityType: UserActivity.openNewWindowActivityType) + userActivity.userInfo = [ + UserActivity.sessionUserInfoAuthenticationIndexObjectIDKey: objectID.uriRepresentation() + ] + userActivity.targetContentIdentifier = "\(UserActivity.openNewWindowActivityType)-\(objectID)" + return userActivity + } +} + #if DEBUG extension SceneDelegate { static var isXcodeUnitTest: Bool { diff --git a/TwidereXIntent/Info.plist b/TwidereXIntent/Info.plist index 051466d7..57f097f7 100644 --- a/TwidereXIntent/Info.plist +++ b/TwidereXIntent/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 2.0.0 CFBundleVersion - 125 + 128 NSExtension NSExtensionAttributes diff --git a/TwidereXTests/Info.plist b/TwidereXTests/Info.plist index 2cc5125d..e0d1d83e 100644 --- a/TwidereXTests/Info.plist +++ b/TwidereXTests/Info.plist @@ -17,6 +17,6 @@ CFBundleShortVersionString 2.0.0 CFBundleVersion - 125 + 128 diff --git a/TwidereXUITests/Info.plist b/TwidereXUITests/Info.plist index 2cc5125d..e0d1d83e 100644 --- a/TwidereXUITests/Info.plist +++ b/TwidereXUITests/Info.plist @@ -17,6 +17,6 @@ CFBundleShortVersionString 2.0.0 CFBundleVersion - 125 + 128